diff --git a/README.md b/README.md index 1686741..d917a05 100644 --- a/README.md +++ b/README.md @@ -70,23 +70,38 @@ Reload your editor, then type: **"List all my agents"** | `get_phone_numbers` | List phone numbers owned by your organization | | `get_voices` | List available voices with gender, language, and model filters | | `get_playbooks` | Read a multi-agent (Playbooks) agent's SOPs, intent router, and shared auth tools | +| `get_branch_draft` | View a branch's pending (unpublished) draft changes | +| `get_revision` | Get a single committed revision's metadata and resolved config | ### Write +Edits are saved to a branch's **draft** (agents use the branch/revision model). Pass an optional `branch_id` to any editing tool — omit it to edit the live branch; if the agent has several branches you'll be asked which one. Run `publish_draft` once to commit. + | Tool | Description | |---|---| | `create_agent` | Create a new AI voice agent (`single_prompt`, or `multi_agents` for Playbooks) | -| `update_agent_prompt` | Update an agent's system prompt / instructions | +| `update_agent` | Update agent settings — name, prompt, first message, voice, model, language, variables, pre-call API, etc. | +| `add_agent_tool` | Add or update an API-call tool the agent can invoke during a call | +| `remove_agent_tool` | Remove a tool from an agent by name | +| `configure_call_actions` | Enable/disable end_call and set a transfer number — agent-level | | `add_playbooks` | Add SOP playbooks (intent + prompt + scoped API tools + auth level) to a multi-agent | | `update_playbook` | Edit, archive, or restore one playbook | | `configure_playbooks` | Set the intent router, conversation guide, and shared weak/strong auth tools | -| `configure_call_actions` | Enable/disable end_call and set a transfer number — agent-level, applies across all playbooks | -| `update_agent_config` | Update agent settings — name, language, voice, STT, first message, etc. | -| `add_agent_tool` | Add or update an API-call tool the agent can invoke during a call | -| `remove_agent_tool` | Remove a tool from an agent by name | -| `set_pre_call_api` | Configure (or disable) the pre-call API that runs before a call to enrich variables | | `delete_agent` | Archive (soft-delete) or unarchive an agent | -| `publish_draft` | Publish or discard a draft on a versioned agent | +| `duplicate_agent` | Copy an agent | + +### Versioning (branches & revisions) + +| Tool | Description | +|---|---| +| `list_branches` | List the agent's branches (which is live, which have a pending draft) | +| `create_branch` | Create a working branch from another branch's head | +| `rename_branch` | Rename a branch | +| `make_branch_live` | Make a branch's head the live (serving) config | +| `publish_draft` | Publish (commit) or discard a branch's pending draft | +| `list_revisions` | List a branch's committed revisions | +| `diff` | Compare two configs (revisions or a branch draft) | +| `test_agent` | Start a test call against a branch's head, its draft, or a specific revision | ### Act diff --git a/package.json b/package.json index 849e65a..7277388 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "scripts": { "build": "esbuild src/index.ts --platform=node --bundle --format=esm --outdir=dist --banner:js=\"#!/usr/bin/env node\" --packages=external", "dev": "tsx src/index.ts", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "smoke": "npm run build && node smoke.mjs" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", diff --git a/smoke.mjs b/smoke.mjs new file mode 100644 index 0000000..09557a7 --- /dev/null +++ b/smoke.mjs @@ -0,0 +1,68 @@ +/** + * Minimal smoke test: starts the built server, lists tools over stdio, and + * asserts the expected v2 tool surface is registered (and removed tools are gone). + * No backend needed — tools/list doesn't hit the API. + * + * Usage: npm run build && node smoke.mjs + */ +import { spawn } from "node:child_process"; + +const EXPECTED = [ + // versioning v2 + "list_branches", "create_branch", "rename_branch", "make_branch_live", + "get_branch_draft", "publish_draft", "list_revisions", "get_revision", + "diff", "test_agent", + // editing + "update_agent", "add_agent_tool", "remove_agent_tool", "configure_call_actions", + "create_agent", "delete_agent", "duplicate_agent", + // playbooks + "get_playbooks", "add_playbooks", "update_playbook", "configure_playbooks", + // calls + "make_call", "debug_call", "list_calls", +]; + +// Removed in the v2 cutover — must NOT be present. +const REMOVED = [ + "update_agent_config", "update_agent_prompt", "set_pre_call_api", + "activate_version", "list_versions", "get_version", "get_draft", + "list_drafts", "diff_versions", "get_draft_diff", "test_draft", + "test_version", "rename_draft", "update_version", "compare_version_metrics", +]; + +const srv = spawn(process.execPath, ["dist/index.js"], { + env: { ...process.env, ATOMS_API_KEY: "smoke-test" }, + stdio: ["pipe", "pipe", "inherit"], +}); + +const fail = (msg) => { console.error(`❌ ${msg}`); srv.kill(); process.exit(1); }; +const send = (o) => srv.stdin.write(JSON.stringify(o) + "\n"); + +let buf = ""; +srv.stdout.on("data", (d) => { + buf += d.toString(); + for (const line of buf.split("\n")) { + if (!line.trim()) continue; + let msg; + try { msg = JSON.parse(line); } catch { continue; } + if (msg.id !== 2) continue; + + const names = new Set((msg.result?.tools ?? []).map((t) => t.name)); + const missing = EXPECTED.filter((n) => !names.has(n)); + const leaked = REMOVED.filter((n) => names.has(n)); + if (missing.length) fail(`missing expected tools: ${missing.join(", ")}`); + if (leaked.length) fail(`removed tools still registered: ${leaked.join(", ")}`); + + // Every tool must expose a name + inputSchema. + for (const t of msg.result?.tools ?? []) { + if (!t.name || !t.inputSchema) fail(`tool missing name/inputSchema: ${JSON.stringify(t).slice(0, 80)}`); + } + + console.log(`✅ ${names.size} tools registered; all ${EXPECTED.length} expected present, none of ${REMOVED.length} removed leaked.`); + srv.kill(); + process.exit(0); + } +}); + +send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "smoke", version: "1" } } }); +setTimeout(() => send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }), 300); +setTimeout(() => fail("timed out waiting for tools/list"), 8000); diff --git a/src/api.ts b/src/api.ts index 05d5ad1..8f74e04 100644 --- a/src/api.ts +++ b/src/api.ts @@ -15,7 +15,7 @@ interface ApiResult { * Automatically includes the API key and resolves the org context. */ export async function atomsApi( - method: "GET" | "POST" | "PATCH" | "DELETE", + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", path: string, body?: unknown, extraHeaders?: Record @@ -51,7 +51,22 @@ export async function atomsApi( return { ok: response.ok, status: response.status, data }; } +/** Backend discriminator (branch-model-guard.ts) for deprecated v1 versioning endpoints. */ +const VERSIONING_V2_MIGRATION_ERROR = "versioning_v2_migration_required"; + export function formatApiError(result: ApiResult): string { + // Config freeze: the backend locks all config writes during a maintenance window (HTTP 423). + // Surface it as a clear, non-alarming state — reads and test-calls are unaffected. + if (result.status === 423) { + return "Agent config is frozen for a maintenance window — edits are paused. Test-calls and reads still work; try your edit again shortly."; + } + + // Deprecated v1 versioning endpoint after the branch-model cutover. This should not happen once + // migrated; if it does, the MCP is out of date relative to the backend. + if (result.data?.error_type === VERSIONING_V2_MIGRATION_ERROR) { + return "This Smallest MCP server is out of date and called a deprecated endpoint. Update it (restart your editor to pull the latest, or re-run the installer), then try again."; + } + const msg = result.data?.message ?? result.data?.error ?? JSON.stringify(result.data); return `API error ${result.status}: ${msg}`; } diff --git a/src/tools/actions.ts b/src/tools/actions.ts index c559fb5..ce585ac 100644 --- a/src/tools/actions.ts +++ b/src/tools/actions.ts @@ -10,5 +10,4 @@ export { registerGetCampaigns } from "./get-campaigns.js"; export { registerGetPhoneNumbers } from "./get-phone-numbers.js"; export { registerGetUsageStats } from "./get-usage-stats.js"; export { registerMakeCall } from "./make-call.js"; -export { registerUpdateAgentConfig } from "./update-agent-config.js"; -export { registerUpdateAgentPrompt } from "./update-agent-prompt.js"; +export { registerUpdateAgent } from "./update-agent.js"; diff --git a/src/tools/activate-version.ts b/src/tools/activate-version.ts deleted file mode 100644 index c52ef91..0000000 --- a/src/tools/activate-version.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; - -import { atomsApi, formatApiError } from "../api.js"; - -export function registerActivateVersion(server: McpServer) { - server.registerTool( - "activate_version", - { - description: - "Activate a specific published version, making it the live configuration for the agent. Use this to roll back to a previous version or switch between versions. The previously active version is automatically deactivated. Use list_versions to find version IDs.", - inputSchema: { - agent_id: z.string().describe("The agent ID"), - version_id: z.string().describe("The published version ID to activate"), - }, - }, - async (params) => { - const result = await atomsApi( - "PATCH", - `/agent/${encodeURIComponent(params.agent_id)}/versions/${encodeURIComponent(params.version_id)}/activate` - ); - - if (!result.ok) { - if (result.status === 404) { - return { - content: [{ type: "text" as const, text: `Version not found: ${params.version_id}` }], - }; - } - return { content: [{ type: "text" as const, text: formatApiError(result) }] }; - } - - const activated = result.data?.data ?? result.data; - - return { - content: [ - { - type: "text" as const, - text: JSON.stringify( - { - message: "Version activated successfully. This is now the live configuration.", - agentId: params.agent_id, - versionId: params.version_id, - versionNumber: activated?.versionNumber, - label: activated?.label ?? null, - }, - null, - 2 - ), - }, - ], - }; - } - ); -} diff --git a/src/tools/add-agent-tool.ts b/src/tools/add-agent-tool.ts index 6de9656..4ba792d 100644 --- a/src/tools/add-agent-tool.ts +++ b/src/tools/add-agent-tool.ts @@ -1,7 +1,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { fetchAgentAndTools, persistAgentTools, VERSIONED_DRAFT_HINT } from "./agent-tools-helper.js"; +import { fetchAgentAndTools, persistAgentTools, DRAFT_HINT } from "./agent-tools-helper.js"; /** Schema for one API-call tool — used both for the single-tool params and the batch `tools` array. * Exported for reuse by the Playbooks tools (playbook tools use the same function shape). */ @@ -115,20 +115,15 @@ export function registerAddAgentTool(server: McpServer) { "add_agent_tool", { description: - "Add (or update) one or more API-call tools on a single_prompt agent. API-call tools let the agent make an HTTP request to an external API during a call — e.g. look up an order, book an appointment, or post to a CRM. " + - "The agent decides when to invoke a tool based on its name and description, filling in any declared parameters. " + - "Pass a single tool via the top-level fields, or several at once via `tools` (preferred when configuring multiple tools — they land in one draft write). " + - "Upserts by name: tools with existing names are replaced; others are added (existing tools are preserved). " + - "For versioned agents the change is saved as a draft — pass `draft_id` to stack onto an existing draft (e.g. one returned by update_agent_prompt or set_pre_call_api) instead of creating a new one, then publish_draft once. " + - "Caveat: the draft's tools section is written wholesale, so when targeting a draft that already had tool edits, include ALL desired tools in this call. Use get_agent_prompt to see an agent's current live tools.", + "Add (or update) one or more API-call tools on a single_prompt agent. API-call tools let the agent make an HTTP request to an external API during a call — e.g. look up an order, book an appointment, or post to a CRM. The agent decides when to invoke a tool from its name + description, filling in any declared parameters. " + + "Pass a single tool via the top-level fields, or several at once via `tools`. Upserts by name: an existing tool with the same name is replaced, others are preserved. " + + "Changes are saved to the branch's draft via read-modify-write against the open draft — make tool edits one at a time (sequential edits stack; concurrent edits to the same branch can drop each other), then publish_draft once to make everything live. Use remove_agent_tool to delete a tool by name, and configure_call_actions for end_call / transfer_call.", inputSchema: { agent_id: z.string().describe("The agent ID to add the tool(s) to"), - draft_id: z + branch_id: z .string() .optional() - .describe( - "Existing draft to write into (stacks this change onto the draft's other edits). Omit to create a new draft from the live version." - ), + .describe("Branch whose draft to edit (from list_branches). Omit to use the live branch; if the agent has multiple branches you'll be asked to pick one."), tools: z .array(apiToolSchema) .optional() @@ -149,7 +144,7 @@ export function registerAddAgentTool(server: McpServer) { }, }, async (params) => { - // Collect the tool inputs: batch `tools` array, or the single top-level tool. + // Collect the batch `tools` array or the single top-level tool. let inputs: ApiToolInput[]; if (params.tools && params.tools.length > 0) { inputs = params.tools; @@ -181,14 +176,11 @@ export function registerAddAgentTool(server: McpServer) { ]; } - // Reject duplicate names within the batch. const seen = new Set(); for (const t of inputs) { if (seen.has(t.name)) { return { - content: [ - { type: "text" as const, text: `Duplicate tool name '${t.name}' in the tools array.` }, - ], + content: [{ type: "text" as const, text: `Duplicate tool name '${t.name}' in the tools array.` }], }; } seen.add(t.name); @@ -206,18 +198,19 @@ export function registerAddAgentTool(server: McpServer) { }; } - const fetched = await fetchAgentAndTools(params.agent_id); + const fetched = await fetchAgentAndTools(params.agent_id, params.branch_id); if (!fetched.ok) { return { content: [{ type: "text" as const, text: fetched.message }] }; } // Upsert by name (case-sensitive): keep tools not being replaced, append the new ones. + const newTools = inputs.map(buildApiCallTool); const newNames = new Set(inputs.map((t) => t.name)); const kept = fetched.tools.filter((t) => !newNames.has(t?.name)); const replacedCount = fetched.tools.length - kept.length; - const tools = [...kept, ...inputs.map(buildApiCallTool)]; + const tools = [...kept, ...newTools]; - const persisted = await persistAgentTools(fetched.agent, fetched.prompt, tools, params.draft_id); + const persisted = await persistAgentTools(fetched.agent, fetched.branchId, tools); if (!persisted.ok) { return { content: [{ type: "text" as const, text: persisted.message }] }; } @@ -230,13 +223,9 @@ export function registerAddAgentTool(server: McpServer) { agentId: params.agent_id, tools: inputs.map((t) => ({ name: t.name, method: t.method, url: t.url })), totalTools: tools.length, + status: "draft", + hint: DRAFT_HINT, }; - if (persisted.versioned) { - result.versioned = true; - result.draftId = persisted.draftId; - result.status = "draft"; - result.hint = VERSIONED_DRAFT_HINT; - } return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } diff --git a/src/tools/agent-tools-helper.ts b/src/tools/agent-tools-helper.ts index 7cbb020..366a2f5 100644 --- a/src/tools/agent-tools-helper.ts +++ b/src/tools/agent-tools-helper.ts @@ -1,28 +1,45 @@ import { atomsApi, formatApiError } from "../api.js"; +import { resolveBranch, saveConfigToBranch } from "../versioning.js"; import type { IAgentDTO } from "../types.js"; /** - * Shared helpers for managing an agent's single_prompt tools (functions), - * e.g. API-call tools. Mirrors how update_agent_prompt reads/writes the - * workflow so tools and prompt are kept consistent. + * Shared helpers for managing a single_prompt agent's config and tools. + * + * All edits go to a branch's draft (v2 branch model): each edit auto-opens the + * branch's single draft and stacks onto it. Callers publish_draft once to commit. */ export type FetchToolsResult = - | { ok: true; agent: IAgentDTO; prompt: string | null; tools: any[] } + | { ok: true; agent: IAgentDTO; branchId: string; tools: any[] } | { ok: false; message: string }; /** - * Fetch the agent plus its current single_prompt prompt and tools array. - * Blocks conversation-flow (workflow_graph) agents. + * Fetch the agent plus the CURRENT tools array for the chosen branch — resolved + * from the branch's open draft when one exists (so stacked edits compose without + * clobbering), else the branch head. Blocks conversation-flow (workflow_graph) + * agents. `branchId` omitted → the live branch (or ask, if multiple exist). */ -export async function fetchAgentAndTools(agentId: string): Promise { - const agentResult = await atomsApi("GET", `/agent/${encodeURIComponent(agentId)}`); +export async function fetchAgentAndTools(agentId: string, branchId?: string): Promise { + const branch = await resolveBranch(agentId, branchId); + if (!branch.ok) return { ok: false, message: branch.message }; + + // Resolve the tools of the TARGET branch: its open draft when one exists (so + // stacked edits compose), else its head revision — never the live config, + // which would seed a non-live branch's draft from the wrong tools. + const q = branch.value.openDraftId + ? `?draftId=${encodeURIComponent(branch.value.openDraftId)}` + : branch.value.headRevisionId + ? `?versionId=${encodeURIComponent(branch.value.headRevisionId)}` + : ""; + const agentResult = await atomsApi("GET", `/agent/${encodeURIComponent(agentId)}${q}`); if (!agentResult.ok) { if (agentResult.status === 404) return { ok: false, message: `Agent not found: ${agentId}` }; return { ok: false, message: formatApiError(agentResult) }; } - const agent = (agentResult.data?.data ?? agentResult.data) as IAgentDTO; + const agent = (agentResult.data?.data ?? agentResult.data) as IAgentDTO & { + _resolvedConfig?: { tools?: any[] }; + }; if (agent.workflowType === "workflow_graph") { return { @@ -32,162 +49,38 @@ export async function fetchAgentAndTools(agentId: string): Promise { - if (existingDraftId) { - return { ok: true, draftId: existingDraftId }; - } - - const createDraftResult = await atomsApi("POST", `/agent/${encodeURIComponent(agentId)}/drafts`, { - sourceVersionId: activeVersionId, - }); - if (!createDraftResult.ok) { - return { ok: false, message: `Failed to create draft: ${formatApiError(createDraftResult)}` }; - } - - const draft = createDraftResult.data?.data ?? createDraftResult.data; - const draftId = draft?.draftId as string | undefined; - if (!draftId) { - return { ok: false, message: "Draft created but no draftId returned by the API." }; - } - return { ok: true, draftId }; -} - -/** - * Persist the full tools array on an agent. - * - * - Versioned agent (has activeVersionId): writes the tools to a draft's - * workflow_tools section — an existing draft when `draftId` is given - * (stacking a new revision onto it), else a fresh draft from the active - * version. The prompt lives in a separate section and is left untouched. - * Caller must publish_draft to go live. - * - Non-versioned agent: replaces the workflow's tools directly (the prompt is - * re-sent unchanged because the workflow update requires a non-empty prompt). - * - * The tools array is always sent in full — the backend replaces the array - * wholesale (deepMerge does not merge arrays), so the caller is responsible - * for upserting/removing within the array before calling this. - */ -export async function persistAgentTools( - agent: IAgentDTO, - prompt: string | null, - tools: any[], - draftId?: string -): Promise { - const agentId = agent._id; - - if (agent.activeVersionId) { - const target = await resolveTargetDraft(agentId, agent.activeVersionId, draftId); - if (!target.ok) return target; - - const updateDraftResult = await atomsApi( - "PATCH", - `/agent/${encodeURIComponent(agentId)}/drafts/${encodeURIComponent(target.draftId)}/config`, - { singlePromptConfig: { tools } } - ); - if (!updateDraftResult.ok) { - return { - ok: false, - message: `Failed to save tools to draft ${target.draftId}: ${formatApiError(updateDraftResult)}`, - }; - } - - return { ok: true, versioned: true, draftId: target.draftId }; - } - - // Non-versioned: full workflow replace. The workflow schema requires a - // non-empty prompt, so we must have one to preserve. - if (!agent.workflowId) { - return { ok: false, message: `Agent ${agentId} has no workflow associated. Cannot update tools.` }; - } - if (!prompt || prompt.trim().length === 0) { + // Guard against a silent wipe: a missing _resolvedConfig means we couldn't read + // the current tools, so a wholesale write would blank them. (An empty tools + // array on a present _resolvedConfig is a legitimate no-tools state.) + if (!agent._resolvedConfig) { return { ok: false, - message: - "This agent has no system prompt yet. Set one with update_agent_prompt before adding tools (the workflow requires a non-empty prompt).", + message: "Could not resolve the agent's current tools to edit them safely. Aborting to avoid overwriting the tools list.", }; } - const result = await atomsApi("PATCH", `/workflow/${encodeURIComponent(agent.workflowId)}`, { - type: "single_prompt", - singlePromptConfig: { prompt, tools }, - }); - if (!result.ok) { - return { ok: false, message: formatApiError(result) }; - } + const tools = (agent._resolvedConfig.tools ?? []) as any[]; - return { ok: true, versioned: false }; + return { ok: true, agent, branchId: branch.value.branchId, tools }; } +export type PersistResult = { ok: true } | { ok: false; message: string }; + /** - * Persist a flat agent-config payload (e.g. `{ preCallAPI: {...} }`). - * - * - Versioned agent: writes the payload to a draft's config via - * PATCH /agent/:id/drafts/:draftId/config — an existing draft when `draftId` - * is given (stacking a new revision onto it), else a fresh draft from the - * active version. Caller must publish_draft. - * - Non-versioned agent: PATCH /agent/:id directly. - * - * This mirrors how update_agent_config routes config-field changes. + * Persist the full tools array to a branch's draft. The array is always sent in + * full — the backend replaces it wholesale — so callers upsert/remove within the + * array (against the draft-resolved read from fetchAgentAndTools) first. */ -export async function persistAgentConfig( +export async function persistAgentTools( agent: IAgentDTO, - payload: Record, - draftId?: string -): Promise { - const agentId = agent._id; - - if (agent.activeVersionId) { - const target = await resolveTargetDraft(agentId, agent.activeVersionId, draftId); - if (!target.ok) return target; - - const updateDraftResult = await atomsApi( - "PATCH", - `/agent/${encodeURIComponent(agentId)}/drafts/${encodeURIComponent(target.draftId)}/config`, - payload - ); - if (!updateDraftResult.ok) { - return { - ok: false, - message: `Failed to save config to draft ${target.draftId}: ${formatApiError(updateDraftResult)}`, - }; - } - - return { ok: true, versioned: true, draftId: target.draftId }; - } - - const result = await atomsApi("PATCH", `/agent/${encodeURIComponent(agentId)}`, payload); - if (!result.ok) { - return { ok: false, message: formatApiError(result) }; - } - return { ok: true, versioned: false }; + branchId: string, + tools: any[] +): Promise { + const saved = await saveConfigToBranch(agent._id, branchId, { singlePromptConfig: { tools } }); + if (!saved.ok) return { ok: false, message: `Failed to save tools: ${saved.message}` }; + return { ok: true }; } -/** Standard hint appended when changes land in a draft (versioned agents). */ -export const VERSIONED_DRAFT_HINT = - "Changes are in draft state (not live yet). Pass this draftId as draft_id to other edit tools to stack more changes into the same draft, then publish_draft once to make everything live (or make_call with the draft's version_id to test first)."; +/** Standard hint appended when changes land in a branch draft. */ +export const DRAFT_HINT = + "Changes are saved to the branch's draft (not live yet). Stack more edits with other tools, then run publish_draft once to make everything live (or test first with test_agent)."; diff --git a/src/tools/chat.ts b/src/tools/chat.ts index acc1729..04ce8b8 100644 --- a/src/tools/chat.ts +++ b/src/tools/chat.ts @@ -26,8 +26,8 @@ export function registerChatWithAgent(server: McpServer) { "cut off. Returns the full transcript. " + "Use this to test an agent's prompt/behaviour programmatically (e.g. an automated build → test → " + "evaluate → refine loop): run a scripted conversation, read the transcript, then adjust the prompt " + - "with update_agent_prompt and run again. This places a real (chargeable) chat session on the agent. " + - "Note: the agent must be published; for unpublished drafts use test_draft (mode=chat) to start one.", + "with update_agent and run again. This places a real (chargeable) chat session on the agent. " + + "Note: the agent must be published; to chat with unpublished draft changes use test_agent (mode=chat, include_draft=true).", inputSchema: { agent_id: z.string().describe("The agent ID to chat with (must be a published agent)"), messages: z diff --git a/src/tools/compare-version-metrics.ts b/src/tools/compare-version-metrics.ts deleted file mode 100644 index a25f3c8..0000000 --- a/src/tools/compare-version-metrics.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; - -import { atomsApi, formatApiError } from "../api.js"; - -export function registerCompareVersionMetrics(server: McpServer) { - server.registerTool( - "compare_version_metrics", - { - description: - "A/B compare call performance metrics between two published versions. Shows total calls, answered calls, average duration, completion rate, total cost, and percentage deltas. Optionally filter by date range. Use this to evaluate which version performs better before deciding which to keep active.", - inputSchema: { - agent_id: z.string().describe("The agent ID"), - version_a: z.string().describe("First version ID to compare"), - version_b: z.string().describe("Second version ID to compare"), - date_from: z - .string() - .optional() - .describe("Start date for metrics (ISO 8601 format, e.g. '2025-01-01')"), - date_to: z - .string() - .optional() - .describe("End date for metrics (ISO 8601 format, e.g. '2025-01-31')"), - }, - }, - async (params) => { - const queryParts: string[] = [ - `versionA=${encodeURIComponent(params.version_a)}`, - `versionB=${encodeURIComponent(params.version_b)}`, - ]; - if (params.date_from) queryParts.push(`dateFrom=${encodeURIComponent(params.date_from)}`); - if (params.date_to) queryParts.push(`dateTo=${encodeURIComponent(params.date_to)}`); - - const result = await atomsApi( - "GET", - `/agent/${encodeURIComponent(params.agent_id)}/versions/compare-metrics?${queryParts.join("&")}` - ); - - if (!result.ok) { - return { content: [{ type: "text" as const, text: formatApiError(result) }] }; - } - - const data = result.data?.data ?? result.data; - - return { - content: [ - { - type: "text" as const, - text: JSON.stringify(data, null, 2), - }, - ], - }; - } - ); -} diff --git a/src/tools/configure-call-actions.ts b/src/tools/configure-call-actions.ts index 37e4124..7662e60 100644 --- a/src/tools/configure-call-actions.ts +++ b/src/tools/configure-call-actions.ts @@ -1,7 +1,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { atomsApi, formatApiError } from "../api.js"; +import { fetchAgentAndTools, persistAgentTools, DRAFT_HINT } from "./agent-tools-helper.js"; /** * Agent-LEVEL call actions — end_call and transfer_call. These live in the @@ -14,13 +14,13 @@ export function registerConfigureCallActions(server: McpServer) { "configure_call_actions", { description: - "Enable/disable the agent's end_call action and set/remove a transfer_call number. These are AGENT-LEVEL settings (the console's Tools tab) that apply to the whole agent — for multi-agent (Playbooks) agents the runtime injects them into every playbook, never gated behind auth. Changes land on a draft; publish_draft to go live. Without an enabled end_call the agent cannot hang up on its own.", + "Enable/disable the agent's end_call action and set/remove a transfer_call number. These are AGENT-LEVEL settings (the console's Tools tab) that apply to the whole agent — for multi-agent (Playbooks) agents the runtime injects them into every playbook, never gated behind auth. Changes land on the branch's draft; publish_draft to go live. Without an enabled end_call the agent cannot hang up on its own.", inputSchema: { agent_id: z.string().describe("The agent ID"), - draft_id: z + branch_id: z .string() .optional() - .describe("Draft to edit (stacks onto its other changes). Omit to create a new draft from the active version."), + .describe("Branch whose draft to edit (from list_branches). Omit to use the live branch; if the agent has multiple branches you'll be asked to pick one."), end_call: z .boolean() .optional() @@ -44,25 +44,13 @@ export function registerConfigureCallActions(server: McpServer) { }; } - const qs = params.draft_id ? `?draftId=${encodeURIComponent(params.draft_id)}` : ""; - const agentResult = await atomsApi("GET", `/agent/${encodeURIComponent(params.agent_id)}${qs}`); - if (!agentResult.ok) { - return { content: [{ type: "text" as const, text: formatApiError(agentResult) }] }; - } - const agent = agentResult.data?.data ?? agentResult.data; - if (!agent?.activeVersionId) { - return { - content: [ - { - type: "text" as const, - text: "This agent is not versioned — for legacy single_prompt agents manage the end_call/transfer tools via add_agent_tool/remove_agent_tool on the workflow.", - }, - ], - }; + // Draft-aware read of the current agent-level tools (preserves api_call tools). + const fetched = await fetchAgentAndTools(params.agent_id, params.branch_id); + if (!fetched.ok) { + return { content: [{ type: "text" as const, text: fetched.message }] }; } - // Read-modify-write the agent-level tools section, preserving api_call tools. - let tools: any[] = Array.isArray(agent?._resolvedConfig?.tools) ? [...agent._resolvedConfig.tools] : []; + let tools: any[] = [...fetched.tools]; if (params.end_call !== undefined) { tools = tools.filter((t) => t?.type !== "end_call"); @@ -93,27 +81,9 @@ export function registerConfigureCallActions(server: McpServer) { } } - let draftId = params.draft_id; - if (!draftId) { - const create = await atomsApi("POST", `/agent/${encodeURIComponent(params.agent_id)}/drafts`, { - sourceVersionId: agent.activeVersionId, - }); - if (!create.ok) { - return { content: [{ type: "text" as const, text: `Failed to create draft: ${formatApiError(create)}` }] }; - } - const draft = create.data?.data ?? create.data; - draftId = draft?.draftId; - } - - const save = await atomsApi( - "PATCH", - `/agent/${encodeURIComponent(params.agent_id)}/drafts/${encodeURIComponent(draftId!)}/config`, - { singlePromptConfig: { tools } } - ); - if (!save.ok) { - return { - content: [{ type: "text" as const, text: `Failed to save call actions: ${formatApiError(save)}` }], - }; + const persisted = await persistAgentTools(fetched.agent, fetched.branchId, tools); + if (!persisted.ok) { + return { content: [{ type: "text" as const, text: persisted.message }] }; } return { @@ -122,13 +92,14 @@ export function registerConfigureCallActions(server: McpServer) { type: "text" as const, text: JSON.stringify( { - message: "Call actions saved to draft", - draftId, + message: "Call actions saved to draft.", + agentId: params.agent_id, end_call: tools.some((t) => t?.type === "end_call" && t?.enabled !== false), transfer_call: tools.find((t) => t?.type === "transfer_call" && t?.enabled !== false)?.transferNumber ?? null, other_tools: tools.filter((t) => t?.type !== "end_call" && t?.type !== "transfer_call").length, - hint: "Use publish_draft to make this live.", + status: "draft", + hint: DRAFT_HINT, }, null, 2 diff --git a/src/tools/create-agent.ts b/src/tools/create-agent.ts index 408e9c8..652a59d 100644 --- a/src/tools/create-agent.ts +++ b/src/tools/create-agent.ts @@ -9,7 +9,7 @@ export function registerCreateAgent(server: McpServer) { "create_agent", { description: - "Create a new AI agent in your organization. By default the agent is a single_prompt agent with gpt-4.1 model and daniel voice (waves_lightning_v3_1); set workflow_type to multi_agents for a Playbooks agent (an intent router + specialist SOP playbooks — add them via add_playbooks after creation). The STT transcriber defaults to Pulse — change it (e.g. to pulse-legacy) via update_agent_config after creation. Returns the created agent's ID. For single_prompt agents, set the prompt via update_agent_prompt after creation.", + "Create a new AI agent in your organization. By default the agent is a single_prompt agent with gpt-4.1 model and daniel voice (waves_lightning_v3_1); set workflow_type to multi_agents for a Playbooks agent (an intent router + specialist SOP playbooks — add them via add_playbooks after creation). The STT transcriber defaults to Pulse — change it (e.g. to pulse-legacy) via update_agent after creation. Returns the created agent's ID. For single_prompt agents, set the prompt via update_agent after creation.", inputSchema: { name: z.string().optional().describe("Name for the new agent"), description: z.string().optional().describe("Short description of what the agent does"), @@ -74,7 +74,7 @@ export function registerCreateAgent(server: McpServer) { global_prompt: z .string() .optional() - .describe("Global system prompt for the agent (max 4000 chars). For the main prompt, use update_agent_prompt after creation."), + .describe("Global system prompt for the agent (max 4000 chars). For the main prompt, use update_agent after creation."), first_message: z .string() .optional() @@ -192,7 +192,7 @@ export function registerCreateAgent(server: McpServer) { }); if (!firstMsgResult.ok) { warnings.push( - `Agent created but failed to set first message: ${formatApiError(firstMsgResult)}. Use update_agent_config to set it manually.` + `Agent created but failed to set first message: ${formatApiError(firstMsgResult)}. Use update_agent to set it manually.` ); } } diff --git a/src/tools/create-branch.ts b/src/tools/create-branch.ts new file mode 100644 index 0000000..b1cd790 --- /dev/null +++ b/src/tools/create-branch.ts @@ -0,0 +1,60 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +import { atomsApi, formatApiError } from "../api.js"; +import { resolveLiveBranch } from "../versioning.js"; + +export function registerCreateBranch(server: McpServer) { + server.registerTool( + "create_branch", + { + description: + "Create a new branch to work on a set of changes in isolation, without touching the live agent. The branch starts from a source branch's head (the live branch by default). Edit its draft with the usual tools (pass the new branch_id), publish_draft to commit, then make_branch_live to serve it.", + inputSchema: { + agent_id: z.string().describe("The agent ID"), + name: z.string().min(1).max(100).describe("Branch name (1-100 chars). Cannot be 'main'."), + source_branch_id: z + .string() + .optional() + .describe("Branch to fork from (from list_branches). Omit to fork from the live branch."), + }, + }, + async (params) => { + let sourceBranchId = params.source_branch_id; + if (!sourceBranchId) { + const live = await resolveLiveBranch(params.agent_id); + if (!live.ok) return { content: [{ type: "text" as const, text: live.message }] }; + sourceBranchId = live.value.branchId; + } + + const result = await atomsApi("POST", `/agent/${encodeURIComponent(params.agent_id)}/branches`, { + sourceBranchId, + name: params.name, + }); + if (!result.ok) { + return { content: [{ type: "text" as const, text: formatApiError(result) }] }; + } + + const branch = result.data?.data ?? result.data; + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + message: `Branch '${params.name}' created.`, + agentId: params.agent_id, + branchId: branch?._id ?? null, + name: branch?.name ?? params.name, + sourceBranchId, + hint: "Edit it with update_agent / add_agent_tool (pass this branch_id), publish_draft to commit, then make_branch_live to serve it.", + }, + null, + 2 + ), + }, + ], + }; + } + ); +} diff --git a/src/tools/diff-versions.ts b/src/tools/diff-versions.ts deleted file mode 100644 index 11ba310..0000000 --- a/src/tools/diff-versions.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; - -import { atomsApi, formatApiError } from "../api.js"; - -export function registerDiffVersions(server: McpServer) { - server.registerTool( - "diff_versions", - { - description: - "Compare two published versions side-by-side to see exactly what changed between them. Shows unchanged sections and detailed diffs for modified config sections (voice, prompt, LLM, language, etc.). Use list_versions to find version IDs.", - inputSchema: { - agent_id: z.string().describe("The agent ID"), - version_a: z.string().describe("First version ID to compare"), - version_b: z.string().describe("Second version ID to compare"), - }, - }, - async (params) => { - const query = `?versionA=${encodeURIComponent(params.version_a)}&versionB=${encodeURIComponent(params.version_b)}`; - - const result = await atomsApi( - "GET", - `/agent/${encodeURIComponent(params.agent_id)}/versions/diff${query}` - ); - - if (!result.ok) { - return { content: [{ type: "text" as const, text: formatApiError(result) }] }; - } - - const data = result.data?.data ?? result.data; - - return { - content: [ - { - type: "text" as const, - text: JSON.stringify(data, null, 2), - }, - ], - }; - } - ); -} diff --git a/src/tools/diff.ts b/src/tools/diff.ts new file mode 100644 index 0000000..6125aba --- /dev/null +++ b/src/tools/diff.ts @@ -0,0 +1,29 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +import { atomsApi, formatApiError } from "../api.js"; + +export function registerDiff(server: McpServer) { + server.registerTool( + "diff", + { + description: + "Compare two agent configs and show what changed, section by section (field paths with old/new values). Each side is a reference: a revision_id (from list_revisions) or \":draft\" for a branch's open draft (branch_id from list_branches). E.g. diff a branch's draft against the live head to preview a publish.", + inputSchema: { + agent_id: z.string().describe("The agent ID"), + a: z.string().describe("Left side: a revision_id, or \":draft\" for a branch's open draft"), + b: z.string().describe("Right side: a revision_id, or \":draft\" for a branch's open draft"), + }, + }, + async (params) => { + const query = `?a=${encodeURIComponent(params.a)}&b=${encodeURIComponent(params.b)}`; + const result = await atomsApi("GET", `/agent/${encodeURIComponent(params.agent_id)}/diff${query}`); + if (!result.ok) { + return { content: [{ type: "text" as const, text: formatApiError(result) }] }; + } + + const data = result.data?.data ?? result.data; + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + } + ); +} diff --git a/src/tools/get-branch-draft.ts b/src/tools/get-branch-draft.ts new file mode 100644 index 0000000..5a8a06f --- /dev/null +++ b/src/tools/get-branch-draft.ts @@ -0,0 +1,55 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +import { atomsApi, formatApiError } from "../api.js"; +import { resolveBranch } from "../versioning.js"; + +export function registerGetBranchDraft(server: McpServer) { + server.registerTool( + "get_branch_draft", + { + description: + "Get a branch's pending (unpublished) draft — its latest draft revision and edit history (which sections changed per edit). Each branch has at most one open draft. Use list_branches to see which branches have one (hasOpenDraft).", + inputSchema: { + agent_id: z.string().describe("The agent ID"), + branch_id: z + .string() + .optional() + .describe("Branch whose draft to read (from list_branches). Omit for the live branch; if the agent has multiple branches you'll be asked to pick one."), + }, + }, + async (params) => { + const branch = await resolveBranch(params.agent_id, params.branch_id); + if (!branch.ok) { + return { content: [{ type: "text" as const, text: branch.message }] }; + } + + if (!branch.value.hasOpenDraft) { + return { + content: [ + { + type: "text" as const, + text: `No open draft on branch ${branch.value.name ?? branch.value.branchId} — no unpublished changes.`, + }, + ], + }; + } + + const result = await atomsApi( + "GET", + `/agent/${encodeURIComponent(params.agent_id)}/branches/${encodeURIComponent(branch.value.branchId)}/draft` + ); + if (!result.ok) { + if (result.status === 404) { + return { + content: [{ type: "text" as const, text: "No open draft on this branch — no unpublished changes." }], + }; + } + return { content: [{ type: "text" as const, text: formatApiError(result) }] }; + } + + const data = result.data?.data ?? result.data; + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + } + ); +} diff --git a/src/tools/get-draft-diff.ts b/src/tools/get-draft-diff.ts deleted file mode 100644 index 1f88a72..0000000 --- a/src/tools/get-draft-diff.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; - -import { atomsApi, formatApiError } from "../api.js"; - -export function registerGetDraftDiff(server: McpServer) { - server.registerTool( - "get_draft_diff", - { - description: - "Compare a draft against its source version (or a specific published version) to see what changed. Shows unchanged sections and detailed diffs for modified sections. Useful before publishing to review changes.", - inputSchema: { - agent_id: z.string().describe("The agent ID"), - draft_id: z.string().describe("The draft ID to diff"), - compare_to: z - .string() - .optional() - .describe("Version ID to compare against. If omitted, compares against the draft's source version."), - }, - }, - async (params) => { - let path = `/agent/${encodeURIComponent(params.agent_id)}/drafts/${encodeURIComponent(params.draft_id)}/diff`; - if (params.compare_to) { - path += `?compareTo=${encodeURIComponent(params.compare_to)}`; - } - - const result = await atomsApi("GET", path); - - if (!result.ok) { - return { content: [{ type: "text" as const, text: formatApiError(result) }] }; - } - - const data = result.data?.data ?? result.data; - - return { - content: [ - { - type: "text" as const, - text: JSON.stringify(data, null, 2), - }, - ], - }; - } - ); -} diff --git a/src/tools/get-draft.ts b/src/tools/get-draft.ts deleted file mode 100644 index 2b9dc13..0000000 --- a/src/tools/get-draft.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; - -import { atomsApi, formatApiError } from "../api.js"; - -export function registerGetDraft(server: McpServer) { - server.registerTool( - "get_draft", - { - description: - "Get detailed information about a specific draft, including its latest revision, edit history (which sections changed per revision), and resolved config. Use list_drafts first to find draft IDs.", - inputSchema: { - agent_id: z.string().describe("The agent ID"), - draft_id: z.string().describe("The draft ID (returned by list_drafts, update_agent_config, or update_agent_prompt)"), - limit: z - .number() - .int() - .min(1) - .max(100) - .optional() - .describe("Max number of edit history entries to return (default 50)"), - }, - }, - async (params) => { - let path = `/agent/${encodeURIComponent(params.agent_id)}/drafts/${encodeURIComponent(params.draft_id)}`; - if (params.limit !== undefined) { - path += `?limit=${params.limit}`; - } - - const result = await atomsApi("GET", path); - - if (!result.ok) { - if (result.status === 404) { - return { - content: [{ type: "text" as const, text: `Draft not found: ${params.draft_id}` }], - }; - } - return { content: [{ type: "text" as const, text: formatApiError(result) }] }; - } - - const data = result.data?.data ?? result.data; - - return { - content: [ - { - type: "text" as const, - text: JSON.stringify(data, null, 2), - }, - ], - }; - } - ); -} diff --git a/src/tools/get-revision.ts b/src/tools/get-revision.ts new file mode 100644 index 0000000..4f1564c --- /dev/null +++ b/src/tools/get-revision.ts @@ -0,0 +1,45 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +import { atomsApi, formatApiError } from "../api.js"; +import { resolveBranch } from "../versioning.js"; + +export function registerGetRevision(server: McpServer) { + server.registerTool( + "get_revision", + { + description: + "Get a single committed revision — its metadata and fully resolved config. Use list_revisions to find revision IDs.", + inputSchema: { + agent_id: z.string().describe("The agent ID"), + revision_id: z.string().describe("The revision ID (from list_revisions)"), + branch_id: z + .string() + .optional() + .describe("Branch the revision belongs to (from list_branches). Omit for the live branch; if the agent has multiple branches you'll be asked to pick one."), + }, + }, + async (params) => { + const branch = await resolveBranch(params.agent_id, params.branch_id); + if (!branch.ok) { + return { content: [{ type: "text" as const, text: branch.message }] }; + } + + const result = await atomsApi( + "GET", + `/agent/${encodeURIComponent(params.agent_id)}/branches/${encodeURIComponent(branch.value.branchId)}/revisions/${encodeURIComponent(params.revision_id)}` + ); + if (!result.ok) { + if (result.status === 404) { + return { + content: [{ type: "text" as const, text: `Revision not found on this branch: ${params.revision_id}` }], + }; + } + return { content: [{ type: "text" as const, text: formatApiError(result) }] }; + } + + const data = result.data?.data ?? result.data; + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + } + ); +} diff --git a/src/tools/get-version.ts b/src/tools/get-version.ts deleted file mode 100644 index 66ee82c..0000000 --- a/src/tools/get-version.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; - -import { atomsApi, formatApiError } from "../api.js"; - -export function registerGetVersion(server: McpServer) { - server.registerTool( - "get_version", - { - description: - "Get full details for a specific published version, including its resolved configuration across all sections (voice, LLM, language, prompt, detection, timeouts, etc.). Use list_versions to find version IDs.", - inputSchema: { - agent_id: z.string().describe("The agent ID"), - version_id: z.string().describe("The published version ID"), - }, - }, - async (params) => { - const result = await atomsApi( - "GET", - `/agent/${encodeURIComponent(params.agent_id)}/versions/${encodeURIComponent(params.version_id)}` - ); - - if (!result.ok) { - if (result.status === 404) { - return { - content: [{ type: "text" as const, text: `Version not found: ${params.version_id}` }], - }; - } - return { content: [{ type: "text" as const, text: formatApiError(result) }] }; - } - - const data = result.data?.data ?? result.data; - - return { - content: [ - { - type: "text" as const, - text: JSON.stringify(data, null, 2), - }, - ], - }; - } - ); -} diff --git a/src/tools/get-voices.ts b/src/tools/get-voices.ts index de0f264..5f48563 100644 --- a/src/tools/get-voices.ts +++ b/src/tools/get-voices.ts @@ -30,7 +30,7 @@ export function registerGetVoices(server: McpServer) { "get_voices", { description: - "List available voices for agents. Returns voice IDs, names, gender, language, and supported models. Use the voiceId with update_agent_config's synthesizer.voiceConfig to change an agent's voice. A voice whose supportedModels include 'lightning-v3.1-pro' is a Lightning V3.1 Pro voice (use it with the waves_lightning_v3_1 model). Optionally include your organization's cloned voices.", + "List available voices for agents. Returns voice IDs, names, gender, language, and supported models. Use the voiceId with update_agent's synthesizer.voiceConfig to change an agent's voice. A voice whose supportedModels include 'lightning-v3.1-pro' is a Lightning V3.1 Pro voice (use it with the waves_lightning_v3_1 model). Optionally include your organization's cloned voices.", inputSchema: { gender: z .enum(["male", "female"]) diff --git a/src/tools/index.ts b/src/tools/index.ts index a1fc8a1..5338009 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,19 +1,19 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { registerActivateVersion } from "./activate-version.js"; import { registerAddAgentTool } from "./add-agent-tool.js"; import { registerAddAudienceMembers } from "./add-audience-members.js"; import { registerAnalyticsTools } from "./analytics.js"; import { registerChatWithAgent } from "./chat.js"; -import { registerCompareVersionMetrics } from "./compare-version-metrics.js"; +import { registerConfigureCallActions } from "./configure-call-actions.js"; import { registerCreateAgent } from "./create-agent.js"; +import { registerCreateBranch } from "./create-branch.js"; import { registerCreateCampaign } from "./create-campaign.js"; import { registerDebugCall } from "./debug-call.js"; import { registerDeleteAgent } from "./delete-agent.js"; import { registerDeleteAudienceMembers } from "./delete-audience-members.js"; import { registerDeleteAudience } from "./delete-audience.js"; import { registerDeleteCampaign } from "./delete-campaign.js"; -import { registerDiffVersions } from "./diff-versions.js"; +import { registerDiff } from "./diff.js"; import { registerDuplicateAgent } from "./duplicate-agent.js"; import { registerExportCampaignLogs } from "./export-campaign-logs.js"; import { registerGetAgent } from "./get-agent.js"; @@ -25,23 +25,23 @@ import { registerGetAudiences } from "./get-audiences.js"; import { registerGetAutoReload } from "./get-auto-reload.js"; import { registerGetBillingAlerts } from "./get-billing-alerts.js"; import { registerListCalls } from "./get-call-logs.js"; +import { registerGetBranchDraft } from "./get-branch-draft.js"; import { registerGetCampaign } from "./get-campaign.js"; import { registerGetCampaigns } from "./get-campaigns.js"; import { registerGetCreditBalance } from "./get-credit-balance.js"; import { registerGetCreditLedger } from "./get-credit-ledger.js"; -import { registerGetDraftDiff } from "./get-draft-diff.js"; -import { registerGetDraft } from "./get-draft.js"; import { registerGetInvoices } from "./get-invoices.js"; import { registerGetPaymentMethods } from "./get-payment-methods.js"; import { registerGetPhoneNumbers } from "./get-phone-numbers.js"; import { registerGetPlans } from "./get-plans.js"; +import { registerGetRevision } from "./get-revision.js"; import { registerGetUsageBreakdown } from "./get-usage-breakdown.js"; import { registerGetUsageStats } from "./get-usage-stats.js"; -import { registerGetVersion } from "./get-version.js"; import { registerGetVoices } from "./get-voices.js"; import { registerInviteMember } from "./invite-member.js"; -import { registerListDrafts } from "./list-drafts.js"; -import { registerListVersions } from "./list-versions.js"; +import { registerListBranches } from "./list-branches.js"; +import { registerListRevisions } from "./list-revisions.js"; +import { registerMakeBranchLive } from "./make-branch-live.js"; import { registerMakeCall } from "./make-call.js"; import { registerPauseCampaign } from "./pause-campaign.js"; import { @@ -50,61 +50,49 @@ import { registerGetPlaybooks, registerUpdatePlaybook, } from "./playbooks.js"; -import { registerConfigureCallActions } from "./configure-call-actions.js"; import { registerPublishDraft } from "./publish-draft.js"; import { registerRedeemCoupon } from "./redeem-coupon.js"; import { registerRemoveAgentTool } from "./remove-agent-tool.js"; -import { registerRenameDraft } from "./rename-draft.js"; +import { registerRenameBranch } from "./rename-branch.js"; import { registerSearchAudienceMembers } from "./search-audience-members.js"; -import { registerSetPreCallApi } from "./set-pre-call-api.js"; import { registerStartCampaign } from "./start-campaign.js"; -import { registerTestDraft } from "./test-draft.js"; -import { registerTestVersion } from "./test-version.js"; +import { registerTestAgent } from "./test-agent.js"; import { registerTextToSpeech } from "./text-to-speech.js"; import { registerTranscribeAudio } from "./transcribe-audio.js"; -import { registerUpdateAgentConfig } from "./update-agent-config.js"; -import { registerUpdateAgentPrompt } from "./update-agent-prompt.js"; +import { registerUpdateAgent } from "./update-agent.js"; import { registerUpdateBillingAlerts } from "./update-billing-alerts.js"; -import { registerUpdateVersion } from "./update-version.js"; import { registerValidateCoupon } from "./validate-coupon.js"; export function registerTools(server: McpServer) { - // Agent CRUD + // Agent CRUD & editing registerGetAgents(server); registerGetAgent(server); registerGetAgentPrompt(server); registerCreateAgent(server); - registerUpdateAgentPrompt(server); - registerUpdateAgentConfig(server); + registerUpdateAgent(server); registerAddAgentTool(server); registerRemoveAgentTool(server); - registerSetPreCallApi(server); + registerConfigureCallActions(server); registerDeleteAgent(server); registerDuplicateAgent(server); // Playbooks (multi-agent SOP orchestration) registerGetPlaybooks(server); registerAddPlaybooks(server); - registerConfigureCallActions(server); registerUpdatePlaybook(server); registerConfigurePlaybooks(server); - // Drafts - registerListDrafts(server); - registerGetDraft(server); - registerRenameDraft(server); - registerGetDraftDiff(server); + // Versioning v2 — branches, drafts, revisions + registerListBranches(server); + registerCreateBranch(server); + registerRenameBranch(server); + registerMakeBranchLive(server); + registerGetBranchDraft(server); registerPublishDraft(server); - registerTestDraft(server); - - // Published versions - registerListVersions(server); - registerGetVersion(server); - registerUpdateVersion(server); - registerActivateVersion(server); - registerDiffVersions(server); - registerCompareVersionMetrics(server); - registerTestVersion(server); + registerListRevisions(server); + registerGetRevision(server); + registerDiff(server); + registerTestAgent(server); // Audiences registerGetAudiences(server); diff --git a/src/tools/list-branches.ts b/src/tools/list-branches.ts new file mode 100644 index 0000000..3ae1b3f --- /dev/null +++ b/src/tools/list-branches.ts @@ -0,0 +1,61 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +import { atomsApi, formatApiError } from "../api.js"; + +export function registerListBranches(server: McpServer) { + server.registerTool( + "list_branches", + { + description: + "List the agent's branches. The live (serving) branch is marked with isLive; hasOpenDraft flags a branch that has unpublished draft changes waiting for publish_draft. Use this to find a branch_id (for make_branch_live) or to see where edits are in progress before publishing or discarding.", + inputSchema: { + agent_id: z.string().describe("The agent ID"), + }, + }, + async (params) => { + const result = await atomsApi( + "GET", + `/agent/${encodeURIComponent(params.agent_id)}/branches` + ); + + if (!result.ok) { + return { content: [{ type: "text" as const, text: formatApiError(result) }] }; + } + + const data = result.data?.data ?? result.data; + const branches = data?.branches ?? data; + + if (!Array.isArray(branches) || branches.length === 0) { + return { + content: [ + { + type: "text" as const, + text: `No branches found for agent ${params.agent_id}.`, + }, + ], + }; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + branches.map((s: any) => ({ + branchId: s.branch?._id ?? null, + name: s.branch?.name ?? null, + isLive: !!s.isLive, + hasOpenDraft: !!s.hasOpenDraft, + headRevisionNumber: s.headRevisionNumber ?? null, + revisionsCount: s.revisionsCount ?? null, + })), + null, + 2 + ), + }, + ], + }; + } + ); +} diff --git a/src/tools/list-drafts.ts b/src/tools/list-drafts.ts deleted file mode 100644 index 6359d97..0000000 --- a/src/tools/list-drafts.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; - -import { atomsApi, formatApiError } from "../api.js"; - -export function registerListDrafts(server: McpServer) { - server.registerTool( - "list_drafts", - { - description: - "List all active (unpublished) drafts for a versioned agent. Shows draft name, revision count, last editor, and last edit time. Useful to see what changes are in progress before deciding to publish or discard.", - inputSchema: { - agent_id: z.string().describe("The agent ID"), - }, - }, - async (params) => { - const result = await atomsApi( - "GET", - `/agent/${encodeURIComponent(params.agent_id)}/drafts` - ); - - if (!result.ok) { - return { content: [{ type: "text" as const, text: formatApiError(result) }] }; - } - - const drafts = result.data?.data ?? result.data; - - if (!Array.isArray(drafts) || drafts.length === 0) { - return { - content: [ - { - type: "text" as const, - text: `No active drafts found for agent ${params.agent_id}.`, - }, - ], - }; - } - - return { - content: [ - { - type: "text" as const, - text: JSON.stringify( - drafts.map((d: any) => ({ - draftId: d.draftId, - draftName: d.draftName ?? null, - sourceVersionId: d.sourceVersionId ?? null, - editCount: d.editCount ?? null, - lastEditor: d.lastEditorName ?? d.lastEditor ?? null, - lastEdited: d.lastEdited ?? d.updatedAt ?? null, - createdAt: d.createdAt ?? null, - })), - null, - 2 - ), - }, - ], - }; - } - ); -} diff --git a/src/tools/list-revisions.ts b/src/tools/list-revisions.ts new file mode 100644 index 0000000..d52984b --- /dev/null +++ b/src/tools/list-revisions.ts @@ -0,0 +1,80 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +import { atomsApi, formatApiError } from "../api.js"; +import { resolveBranch } from "../versioning.js"; + +export function registerListRevisions(server: McpServer) { + server.registerTool( + "list_revisions", + { + description: + "List the committed revisions on a specific branch, newest first. Revisions are branch-scoped — this returns one branch's history, not a global list. Shows revision number, label, who published it, and its security-check status. Use get_revision for a single revision's config.", + inputSchema: { + agent_id: z.string().describe("The agent ID"), + branch_id: z + .string() + .optional() + .describe("Branch whose revisions to list (from list_branches). Omit for the live branch; if the agent has multiple branches you'll be asked to pick one."), + limit: z.number().int().min(1).max(100).optional().describe("Max revisions to return (default 20)"), + skip: z.number().int().min(0).optional().describe("Number of revisions to skip for pagination (default 0)"), + }, + }, + async (params) => { + const branch = await resolveBranch(params.agent_id, params.branch_id); + if (!branch.ok) { + return { content: [{ type: "text" as const, text: branch.message }] }; + } + + const queryParts: string[] = []; + if (params.limit !== undefined) queryParts.push(`limit=${params.limit}`); + if (params.skip !== undefined) queryParts.push(`skip=${params.skip}`); + const query = queryParts.length > 0 ? `?${queryParts.join("&")}` : ""; + + const result = await atomsApi( + "GET", + `/agent/${encodeURIComponent(params.agent_id)}/branches/${encodeURIComponent(branch.value.branchId)}/revisions${query}` + ); + if (!result.ok) { + return { content: [{ type: "text" as const, text: formatApiError(result) }] }; + } + + const data = result.data?.data ?? result.data; + const revisions = data?.revisions ?? data; + const total = data?.total; + + if (!Array.isArray(revisions) || revisions.length === 0) { + return { + content: [ + { + type: "text" as const, + text: `No committed revisions on branch ${branch.value.name ?? branch.value.branchId}.`, + }, + ], + }; + } + + const summary = revisions.map((v: any) => ({ + revisionId: v._id, + revisionNumber: v.revisionNumber ?? v.versionNumber ?? null, + label: v.label ?? null, + publishedBy: v.publishedByName ?? v.publishedBy ?? null, + publishedAt: v.publishedAt ?? null, + securityCheck: v.securityCheck?.status ?? null, + })); + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { branchId: branch.value.branchId, revisions: summary, total: total ?? revisions.length }, + null, + 2 + ), + }, + ], + }; + } + ); +} diff --git a/src/tools/list-versions.ts b/src/tools/list-versions.ts deleted file mode 100644 index 926c858..0000000 --- a/src/tools/list-versions.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; - -import { atomsApi, formatApiError } from "../api.js"; - -export function registerListVersions(server: McpServer) { - server.registerTool( - "list_versions", - { - description: - "List all published versions for a versioned agent, sorted by version number (newest first). Shows version number, label, who published it, whether it's active, and whether it's pinned. Use this to review version history or find a version to roll back to.", - inputSchema: { - agent_id: z.string().describe("The agent ID"), - limit: z - .number() - .int() - .min(1) - .max(100) - .optional() - .describe("Max versions to return (default 20)"), - skip: z - .number() - .int() - .min(0) - .optional() - .describe("Number of versions to skip for pagination (default 0)"), - is_pinned: z - .boolean() - .optional() - .describe("Filter to only pinned versions (or only unpinned if false)"), - }, - }, - async (params) => { - const queryParts: string[] = []; - if (params.limit !== undefined) queryParts.push(`limit=${params.limit}`); - if (params.skip !== undefined) queryParts.push(`skip=${params.skip}`); - if (params.is_pinned !== undefined) queryParts.push(`isPinned=${params.is_pinned}`); - const query = queryParts.length > 0 ? `?${queryParts.join("&")}` : ""; - - const result = await atomsApi( - "GET", - `/agent/${encodeURIComponent(params.agent_id)}/versions${query}` - ); - - if (!result.ok) { - return { content: [{ type: "text" as const, text: formatApiError(result) }] }; - } - - const data = result.data?.data ?? result.data; - const versions = data?.versions ?? data; - const total = data?.total; - - if (!Array.isArray(versions) || versions.length === 0) { - return { - content: [ - { - type: "text" as const, - text: `No published versions found for agent ${params.agent_id}.`, - }, - ], - }; - } - - const summary = versions.map((v: any) => ({ - versionId: v._id, - versionNumber: v.versionNumber, - label: v.label ?? null, - description: v.description ?? null, - isActive: v.isActive ?? false, - isPinned: v.isPinned ?? false, - publishedBy: v.publishedByName ?? v.publishedBy ?? null, - publishedAt: v.publishedAt ?? null, - activatedBy: v.activatedByName ?? v.activatedBy ?? null, - activatedAt: v.activatedAt ?? null, - })); - - return { - content: [ - { - type: "text" as const, - text: JSON.stringify( - { versions: summary, total: total ?? versions.length }, - null, - 2 - ), - }, - ], - }; - } - ); -} diff --git a/src/tools/make-branch-live.ts b/src/tools/make-branch-live.ts new file mode 100644 index 0000000..2cb86f1 --- /dev/null +++ b/src/tools/make-branch-live.ts @@ -0,0 +1,54 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +import { atomsApi, formatApiError } from "../api.js"; + +export function registerMakeBranchLive(server: McpServer) { + server.registerTool( + "make_branch_live", + { + description: + "Make a branch's head revision the live (serving) configuration for the agent. Under the branch model only a branch head can serve — so this switches which branch the agent runs. Use list_branches to find branch IDs. The head must have passed its security check (otherwise this is rejected).", + inputSchema: { + agent_id: z.string().describe("The agent ID"), + branch_id: z.string().describe("The branch to make live (from list_branches)"), + }, + }, + async (params) => { + const result = await atomsApi( + "POST", + `/agent/${encodeURIComponent(params.agent_id)}/branches/${encodeURIComponent(params.branch_id)}/live` + ); + + if (!result.ok) { + if (result.status === 404) { + return { + content: [{ type: "text" as const, text: `Branch not found: ${params.branch_id}` }], + }; + } + return { content: [{ type: "text" as const, text: formatApiError(result) }] }; + } + + const summary = result.data?.data ?? result.data; + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + message: "Branch is now live. Its head revision is the serving configuration.", + agentId: params.agent_id, + branchId: params.branch_id, + branchName: summary?.branch?.name ?? null, + headRevisionNumber: summary?.headRevisionNumber ?? null, + }, + null, + 2 + ), + }, + ], + }; + } + ); +} diff --git a/src/tools/make-call.ts b/src/tools/make-call.ts index ce746f5..2fca66b 100644 --- a/src/tools/make-call.ts +++ b/src/tools/make-call.ts @@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { atomsApi, formatApiError } from "../api.js"; +import { resolveLiveBranch } from "../versioning.js"; import type { IAgentDTO } from "../types.js"; export function registerMakeCall(server: McpServer) { @@ -59,12 +60,17 @@ export function registerMakeCall(server: McpServer) { if (params.variables) { body.variables = params.variables; } - // For versioned agents, always include the version ID so the dispatcher - // can resolve the agent config. Without it, calls get stuck in queue. + // Always include the revision to call so the dispatcher can resolve the + // agent config. Use the caller's version_id, else the live branch head. + // Best-effort: if the branch can't be resolved, let the backend fall back + // to the live config rather than failing the call. if (params.version_id) { body.versionId = params.version_id; - } else if (agent.activeVersionId) { - body.versionId = agent.activeVersionId; + } else { + const live = await resolveLiveBranch(params.agent_id); + if (live.ok && live.value.headRevisionId) { + body.versionId = live.value.headRevisionId; + } } // MCP calls use test slots to avoid consuming production concurrency. diff --git a/src/tools/playbooks.ts b/src/tools/playbooks.ts index 50736fb..6b640eb 100644 --- a/src/tools/playbooks.ts +++ b/src/tools/playbooks.ts @@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { atomsApi, formatApiError } from "../api.js"; +import { resolveBranch, saveConfigToBranch } from "../versioning.js"; import { apiToolSchema, buildApiCallTool, type ApiToolInput } from "./add-agent-tool.js"; /** @@ -119,20 +120,24 @@ function buildPlaybook(p: PlaybookInput, taken: Set): Playbook { } type SectionResult = - | { ok: true; section: PlaybooksSection; agent: any } + | { ok: true; section: PlaybooksSection; agent: any; branchId: string } | { ok: false; message: string }; -/** Fetch the playbooks section from the agent's resolved config (active / draft / version). */ -async function fetchPlaybooksSection( - agentId: string, - opts: { draftId?: string; versionId?: string } = {} -): Promise { - const qs = opts.draftId - ? `?draftId=${encodeURIComponent(opts.draftId)}` - : opts.versionId - ? `?versionId=${encodeURIComponent(opts.versionId)}` +/** + * Fetch the playbooks section for a branch — resolved from its open draft when one + * exists (so stacked edits compose), else its head. Returns the branch id to write + * back to. `branchId` omitted → the live branch (or ask, if multiple exist). + */ +async function fetchPlaybooksSection(agentId: string, branchId?: string): Promise { + const branch = await resolveBranch(agentId, branchId); + if (!branch.ok) return { ok: false, message: branch.message }; + + const q = branch.value.openDraftId + ? `?draftId=${encodeURIComponent(branch.value.openDraftId)}` + : branch.value.headRevisionId + ? `?versionId=${encodeURIComponent(branch.value.headRevisionId)}` : ""; - const result = await atomsApi("GET", `/agent/${encodeURIComponent(agentId)}${qs}`); + const result = await atomsApi("GET", `/agent/${encodeURIComponent(agentId)}${q}`); if (!result.ok) { if (result.status === 404) return { ok: false, message: `Agent not found: ${agentId}` }; return { ok: false, message: formatApiError(result) }; @@ -144,7 +149,17 @@ async function fetchPlaybooksSection( message: `Agent ${agentId} is a ${agent?.workflowType ?? "unknown"} agent — playbooks only apply to multi_agents agents. Create one with create_agent { workflow_type: "multi_agents" }.`, }; } - const raw = agent?._resolvedConfig?.playbooks; + // Guard against a silent wipe: a missing _resolvedConfig means we couldn't read + // the current playbooks, so a wholesale write would blank them. (A present + // _resolvedConfig with no playbooks section is a legitimate empty state — a fresh + // multi_agents agent — so we only abort when _resolvedConfig itself is absent.) + if (!agent._resolvedConfig) { + return { + ok: false, + message: "Could not resolve the agent's current playbooks to edit them safely. Aborting to avoid overwriting the playbooks list.", + }; + } + const raw = agent._resolvedConfig.playbooks; const section: PlaybooksSection = { ...EMPTY_SECTION, ...(raw ?? {}), @@ -152,29 +167,12 @@ async function fetchPlaybooksSection( auth: { weakTools: [], strongTools: [], ...(raw?.auth ?? {}) }, playbooks: raw?.playbooks ?? [], }; - return { ok: true, section, agent }; + return { ok: true, section, agent, branchId: branch.value.branchId }; } -type DraftResult = { ok: true; draftId: string; created: boolean } | { ok: false; message: string }; - -/** Use the given draft, or create a fresh one from the agent's active version. */ -async function resolveDraft(agentId: string, draftId: string | undefined, agent: any): Promise { - if (draftId) return { ok: true, draftId, created: false }; - const create = await atomsApi("POST", `/agent/${encodeURIComponent(agentId)}/drafts`, { - sourceVersionId: agent?.activeVersionId, - }); - if (!create.ok) return { ok: false, message: `Failed to create draft: ${formatApiError(create)}` }; - const draft = create.data?.data ?? create.data; - if (!draft?.draftId) return { ok: false, message: "Draft creation returned no draftId" }; - return { ok: true, draftId: draft.draftId, created: true }; -} - -async function savePlaybooks(agentId: string, draftId: string, section: PlaybooksSection) { - return atomsApi( - "PATCH", - `/agent/${encodeURIComponent(agentId)}/drafts/${encodeURIComponent(draftId)}/config`, - { playbooks: section } - ); +/** Persist the playbooks section onto the branch's draft (auto-opens the draft). */ +async function savePlaybooks(agentId: string, branchId: string, section: PlaybooksSection) { + return saveConfigToBranch(agentId, branchId, { playbooks: section }); } /** Case-insensitive duplicate check across ALL playbooks (archived included — the backend enforces the same). */ @@ -242,11 +240,11 @@ function textErr(message: string) { return { content: [{ type: "text" as const, text: message }] }; } -const DRAFT_PARAM = z +const BRANCH_PARAM = z .string() .optional() .describe( - "Draft to edit. Omit to start a NEW draft from the active version (its id is returned — pass it to subsequent playbooks calls so all edits land on the same draft). Changes go live only after publish_draft." + "Branch whose draft to edit (from list_branches). Omit to use the live branch; if the agent has multiple branches you'll be asked to pick one. Edits stack on the branch's single draft — publish_draft to go live." ); // ── get_playbooks ──────────────────────────────────────────────────────────── @@ -256,19 +254,15 @@ export function registerGetPlaybooks(server: McpServer) { "get_playbooks", { description: - "Read a multi_agents agent's Playbooks config: the intent router (fallback + mid-call rerouting), shared auth tools, and the SOP list (id, intent, auth level, tool count). Pass playbook_id for one playbook's full detail (prompt, tools, intent description). Reads the active version by default; pass draft_id or version_id to inspect those instead.", + "Read a multi_agents agent's Playbooks config: the intent router (fallback + mid-call rerouting), shared auth tools, and the SOP list (id, intent, auth level, tool count). Pass playbook_id for one playbook's full detail (prompt, tools, intent description). Reads the branch's open draft when it has one, else its head; pass branch_id to target a specific branch.", inputSchema: { agent_id: z.string().describe("The multi_agents agent ID"), - draft_id: z.string().optional().describe("Read this draft's config instead of the active version"), - version_id: z.string().optional().describe("Read this published version's config instead of the active version"), + branch_id: BRANCH_PARAM, playbook_id: z.string().optional().describe("Return the full config of just this playbook"), }, }, async (params) => { - const fetched = await fetchPlaybooksSection(params.agent_id, { - draftId: params.draft_id, - versionId: params.version_id, - }); + const fetched = await fetchPlaybooksSection(params.agent_id, params.branch_id); if (!fetched.ok) return textErr(fetched.message); if (params.playbook_id) { const pb = fetched.section.playbooks.find((p) => p.id === params.playbook_id); @@ -288,15 +282,15 @@ export function registerAddPlaybooks(server: McpServer) { "add_playbooks", { description: - "Add one or more playbooks (SOPs) to a multi_agents agent. Each playbook = an intent (name + description the classifier routes on) + a specialist prompt + optional scoped tools and an auth level. Edits land on a draft (auto-created from the active version when draft_id is omitted) — use publish_draft to go live. The first enabled playbook becomes the router fallback automatically if none is set. Names and intent names must be unique on the agent (case-insensitive, archived included).", + "Add one or more playbooks (SOPs) to a multi_agents agent. Each playbook = an intent (name + description the classifier routes on) + a specialist prompt + optional scoped tools and an auth level. Edits land on the branch's draft — use publish_draft to go live. The first enabled playbook becomes the router fallback automatically if none is set. Names and intent names must be unique on the agent (case-insensitive, archived included).", inputSchema: { agent_id: z.string().describe("The multi_agents agent ID"), - draft_id: DRAFT_PARAM, + branch_id: BRANCH_PARAM, playbooks: z.array(playbookInputSchema).min(1).describe("The SOPs to add (batch them — one draft write)"), }, }, async (params) => { - const fetched = await fetchPlaybooksSection(params.agent_id, { draftId: params.draft_id }); + const fetched = await fetchPlaybooksSection(params.agent_id, params.branch_id); if (!fetched.ok) return textErr(fetched.message); const section = fetched.section; @@ -324,21 +318,18 @@ export function registerAddPlaybooks(server: McpServer) { } } - const draft = await resolveDraft(params.agent_id, params.draft_id, fetched.agent); - if (!draft.ok) return textErr(draft.message); - const save = await savePlaybooks(params.agent_id, draft.draftId, section); - if (!save.ok) return textErr(`Failed to save playbooks: ${formatApiError(save)}`); + const save = await savePlaybooks(params.agent_id, fetched.branchId, section); + if (!save.ok) return textErr(`Failed to save playbooks: ${save.message}`); const warnings = publishWarnings(section, fetched.agent); return text({ message: `Added ${added.length} playbook(s) to draft`, - draft_id: draft.draftId, - ...(draft.created && { draft_created: true }), + branch_id: fetched.branchId, added: added.map((p) => ({ id: p.id, name: p.name, intent: p.intentName, auth: p.authLevel, tools: (p.tools ?? []).length })), total_playbooks: section.playbooks.length, ...(fallbackNote && { fallback: fallbackNote }), ...(warnings.length > 0 && { warnings }), - hint: "Draft only — publish_draft to go live. Pass this draft_id to further playbooks calls to keep editing the same draft.", + hint: "Draft only — publish_draft to go live.", }); } ); @@ -354,7 +345,7 @@ export function registerUpdatePlaybook(server: McpServer) { "Edit one playbook (SOP) on a multi_agents agent: change its prompt, intent, auth level, tools, or archive/restore it (enabled=false/true — playbooks are archived, never deleted, so call history stays resolvable). Edits land on a draft (auto-created when draft_id omitted); publish_draft to go live. The router fallback cannot be archived — repoint it first via configure_playbooks.", inputSchema: { agent_id: z.string().describe("The multi_agents agent ID"), - draft_id: DRAFT_PARAM, + branch_id: BRANCH_PARAM, playbook_id: z.string().describe("The playbook id to edit (see get_playbooks)"), name: z.string().min(1).optional().describe("New customer-facing label"), intent_name: z.string().min(1).optional().describe("New intent label (must stay unique on the agent)"), @@ -372,7 +363,7 @@ export function registerUpdatePlaybook(server: McpServer) { }, }, async (params) => { - const fetched = await fetchPlaybooksSection(params.agent_id, { draftId: params.draft_id }); + const fetched = await fetchPlaybooksSection(params.agent_id, params.branch_id); if (!fetched.ok) return textErr(fetched.message); const section = fetched.section; @@ -416,16 +407,13 @@ export function registerUpdatePlaybook(server: McpServer) { pb.tools = params.tools.map((t) => buildApiCallTool(t as ApiToolInput)); } - const draft = await resolveDraft(params.agent_id, params.draft_id, fetched.agent); - if (!draft.ok) return textErr(draft.message); - const save = await savePlaybooks(params.agent_id, draft.draftId, section); - if (!save.ok) return textErr(`Failed to save playbooks: ${formatApiError(save)}`); + const save = await savePlaybooks(params.agent_id, fetched.branchId, section); + if (!save.ok) return textErr(`Failed to save playbooks: ${save.message}`); const warnings = publishWarnings(section, fetched.agent); return text({ message: `Playbook "${pb.id}" updated on draft`, - draft_id: draft.draftId, - ...(draft.created && { draft_created: true }), + branch_id: fetched.branchId, playbook: { id: pb.id, name: pb.name, intent: pb.intentName, auth: pb.authLevel, enabled: pb.enabled !== false, tools: (pb.tools ?? []).length }, ...(warnings.length > 0 && { warnings }), hint: "Draft only — publish_draft to go live.", @@ -444,7 +432,7 @@ export function registerConfigurePlaybooks(server: McpServer) { "Configure the section-level Playbooks settings of a multi_agents agent: the intent router (fallback playbook, mid-call rerouting), the conversation guide (persona/tone/global rules injected into EVERY playbook — define them once here, not per-SOP), and the shared identity tools that satisfy weak/strong auth. Edits land on a draft (auto-created when draft_id omitted); publish_draft to go live.", inputSchema: { agent_id: z.string().describe("The multi_agents agent ID"), - draft_id: DRAFT_PARAM, + branch_id: BRANCH_PARAM, fallback_playbook_id: z .string() .optional() @@ -468,7 +456,7 @@ export function registerConfigurePlaybooks(server: McpServer) { }, }, async (params) => { - const fetched = await fetchPlaybooksSection(params.agent_id, { draftId: params.draft_id }); + const fetched = await fetchPlaybooksSection(params.agent_id, params.branch_id); if (!fetched.ok) return textErr(fetched.message); const section = fetched.section; @@ -500,16 +488,13 @@ export function registerConfigurePlaybooks(server: McpServer) { } if (changed.length === 0) return textErr("Nothing to change — pass at least one setting."); - const draft = await resolveDraft(params.agent_id, params.draft_id, fetched.agent); - if (!draft.ok) return textErr(draft.message); - const save = await savePlaybooks(params.agent_id, draft.draftId, section); - if (!save.ok) return textErr(`Failed to save playbooks: ${formatApiError(save)}`); + const save = await savePlaybooks(params.agent_id, fetched.branchId, section); + if (!save.ok) return textErr(`Failed to save playbooks: ${save.message}`); const warnings = publishWarnings(section, fetched.agent); return text({ message: `Updated: ${changed.join(", ")}`, - draft_id: draft.draftId, - ...(draft.created && { draft_created: true }), + branch_id: fetched.branchId, ...(warnings.length > 0 && { warnings }), hint: "Draft only — publish_draft to go live.", }); diff --git a/src/tools/publish-draft.ts b/src/tools/publish-draft.ts index a702a32..4149c16 100644 --- a/src/tools/publish-draft.ts +++ b/src/tools/publish-draft.ts @@ -2,152 +2,70 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { atomsApi, formatApiError } from "../api.js"; - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -/** How long to wait for the async security check before giving up (~60s). */ -const SECURITY_CHECK_POLL_INTERVAL_MS = 4000; -const SECURITY_CHECK_MAX_POLLS = 15; +import { publishBranch, resolveBranch } from "../versioning.js"; export function registerPublishDraft(server: McpServer) { server.registerTool( "publish_draft", { description: - "Publish a draft as a new version and (by default) activate it to make it live. " + - "Publishing triggers an async security check on the prompt; this tool waits for it to pass before activating and reports the real outcome — " + - "if the check is still pending after ~60s the version is published but NOT live yet (use activate_version once it passes), and if it fails the version cannot be activated until the prompt is fixed. " + - "Can also discard a draft instead of publishing.", + "Publish a branch's pending draft edits, committing them as a new revision. If the branch is live, the changes serve immediately; otherwise use make_branch_live to serve them. " + + "Publishing runs an async security check on the prompt; this tool waits for it and reports the real outcome — " + + "if the check is still running after ~60s the changes aren't committed yet (they commit automatically once it passes), and if it fails the draft stays open so you can fix the prompt and publish again. " + + "Can also discard the pending draft instead of publishing.", inputSchema: { agent_id: z.string().describe("The agent ID"), - draft_id: z.string().describe("The draft ID to publish (returned by update_agent_config, update_agent_prompt, add_agent_tool, set_pre_call_api, etc.)"), + branch_id: z + .string() + .optional() + .describe("Branch to publish (from list_branches). Omit to use the live branch; if the agent has multiple branches you'll be asked to pick one."), action: z .enum(["publish", "discard"]) .default("publish") - .describe("Whether to publish the draft (make it live) or discard it"), - activate: z - .boolean() - .default(true) - .describe( - "Activate the published version once its security check passes (default true). Pass false to publish only — activate later with activate_version." - ), + .describe("Whether to publish the draft (commit it) or discard it"), label: z .string() .optional() - .describe("Version label (max 200 chars, e.g. 'Changed voice to yuvika')"), - description: z - .string() - .optional() - .describe("Changelog description (max 2000 chars)"), + .describe("Revision label (max 200 chars, e.g. 'Changed voice to yuvika')"), }, }, async (params) => { - const agentPath = `/agent/${encodeURIComponent(params.agent_id)}`; - const draftPath = `${agentPath}/drafts/${encodeURIComponent(params.draft_id)}`; + const branch = await resolveBranch(params.agent_id, params.branch_id); + if (!branch.ok) { + return { content: [{ type: "text" as const, text: branch.message }] }; + } if (params.action === "discard") { - const result = await atomsApi("DELETE", draftPath); - + const result = await atomsApi( + "DELETE", + `/agent/${encodeURIComponent(params.agent_id)}/branches/${encodeURIComponent(branch.value.branchId)}/draft` + ); if (!result.ok) { + if (result.status === 404) { + return { + content: [ + { type: "text" as const, text: "No pending draft to discard on this branch." }, + ], + }; + } return { content: [{ type: "text" as const, text: formatApiError(result) }] }; } return { content: [ - { - type: "text" as const, - text: `Draft ${params.draft_id} discarded. No changes were applied to the live agent.`, - }, + { type: "text" as const, text: "Draft discarded. No changes were committed." }, ], }; } - // ── Step 1: publish (the backend always publishes INACTIVE and kicks off - // an async security check on the prompt; activation is a separate step). ── - const publishBody: Record = {}; - if (params.label !== undefined) publishBody.label = params.label; - if (params.description !== undefined) publishBody.description = params.description; - - const result = await atomsApi("POST", `${draftPath}/publish`, publishBody); - - if (!result.ok) { - return { content: [{ type: "text" as const, text: formatApiError(result) }] }; - } - - const version = result.data?.data ?? result.data; - const versionId: string | undefined = version?._id; - const versionNumber: number | undefined = version?.versionNumber; - - const published = { - agentId: params.agent_id, - versionId, - versionNumber, - label: params.label ?? null, - }; - - if (!params.activate) { - return { - content: [ - { - type: "text" as const, - text: JSON.stringify( - { - message: - "Draft published as a new version (NOT active). Activate it with activate_version once its security check passes.", - ...published, - active: false, - securityCheck: version?.securityCheck?.status ?? null, - }, - null, - 2 - ), - }, - ], - }; - } - - if (!versionId) { - return { - content: [ - { - type: "text" as const, - text: JSON.stringify( - { - message: - "Draft published, but the API returned no version id — cannot activate automatically. Use list_versions + activate_version to make it live.", - ...published, - active: false, - }, - null, - 2 - ), - }, - ], - }; + const published = await publishBranch(params.agent_id, branch.value, { label: params.label }); + if (!published.ok) { + return { content: [{ type: "text" as const, text: published.message }] }; } - const versionPath = `${agentPath}/versions/${encodeURIComponent(versionId)}`; - - // ── Step 2: wait for the async security check (workflow_graph agents have - // securityCheck = null and can be activated immediately). ── - let checkStatus: string | null = version?.securityCheck?.status ?? null; - let checkReason: string | null = version?.securityCheck?.reason ?? null; - - let polls = 0; - while (checkStatus === "pending" && polls < SECURITY_CHECK_MAX_POLLS) { - await sleep(SECURITY_CHECK_POLL_INTERVAL_MS); - polls += 1; - - const detail = await atomsApi("GET", versionPath); - if (!detail.ok) break; // fall through to an activation attempt; it will surface the real error - - const detailData = detail.data?.data ?? detail.data; - const v = detailData?.version ?? detailData; - checkStatus = v?.securityCheck?.status ?? null; - checkReason = v?.securityCheck?.reason ?? null; - } + const { state, isLive, revisionId, revisionNumber, reason } = published.value; - if (checkStatus === "failed") { + if (state === "failed") { return { content: [ { @@ -155,11 +73,12 @@ export function registerPublishDraft(server: McpServer) { text: JSON.stringify( { message: - "Draft published, but the prompt FAILED the security check — the version cannot be activated. Fix the prompt and republish.", - ...published, - active: false, + "Publish blocked: the prompt FAILED the security check, so nothing was committed. The draft is kept — fix the prompt and publish again.", + agentId: params.agent_id, + branchId: branch.value.branchId, + committed: false, securityCheck: "failed", - reason: checkReason, + reason: reason ?? null, }, null, 2 @@ -169,40 +88,19 @@ export function registerPublishDraft(server: McpServer) { }; } - if (checkStatus === "pending") { + if (state === "scanning") { return { content: [ { type: "text" as const, text: JSON.stringify( { - message: `Draft published as version ${versionNumber}, but its security check is still pending after ${Math.round((SECURITY_CHECK_POLL_INTERVAL_MS * SECURITY_CHECK_MAX_POLLS) / 1000)}s — the version is NOT live yet. Re-run activate_version(version_id) in a moment to make it live.`, - ...published, - active: false, - securityCheck: "pending", - }, - null, - 2 - ), - }, - ], - }; - } - - // ── Step 3: activate (security check passed, or not applicable). ── - const activateResult = await atomsApi("PATCH", `${versionPath}/activate`); - - if (!activateResult.ok) { - return { - content: [ - { - type: "text" as const, - text: JSON.stringify( - { - message: `Draft published as version ${versionNumber}, but activation failed — the version is NOT live. ${formatApiError(activateResult)}`, - ...published, - active: false, - securityCheck: checkStatus, + message: + "Published; its security check is still running after ~60s so it isn't committed yet. It commits automatically once the check passes — re-check with list_revisions in a moment.", + agentId: params.agent_id, + branchId: branch.value.branchId, + committed: false, + securityCheck: "scanning", }, null, 2 @@ -218,11 +116,15 @@ export function registerPublishDraft(server: McpServer) { type: "text" as const, text: JSON.stringify( { - message: "Draft published and activated. Changes are now live.", - ...published, - active: true, - securityCheck: checkStatus ?? "not_applicable", - ...(polls > 0 && { securityCheckWaitSecs: (polls * SECURITY_CHECK_POLL_INTERVAL_MS) / 1000 }), + message: isLive + ? "Published and live. Changes are now serving." + : "Published to the branch. Use make_branch_live to serve this branch.", + agentId: params.agent_id, + branchId: branch.value.branchId, + live: isLive, + revisionId: revisionId ?? null, + revisionNumber: revisionNumber ?? null, + label: params.label ?? null, }, null, 2 diff --git a/src/tools/remove-agent-tool.ts b/src/tools/remove-agent-tool.ts index 7683ce3..1cc3e1a 100644 --- a/src/tools/remove-agent-tool.ts +++ b/src/tools/remove-agent-tool.ts @@ -1,7 +1,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { fetchAgentAndTools, persistAgentTools, VERSIONED_DRAFT_HINT } from "./agent-tools-helper.js"; +import { fetchAgentAndTools, persistAgentTools, DRAFT_HINT } from "./agent-tools-helper.js"; export function registerRemoveAgentTool(server: McpServer) { server.registerTool( @@ -9,20 +9,18 @@ export function registerRemoveAgentTool(server: McpServer) { { description: "Remove a tool (by name) from a single_prompt agent. Works for any tool type (api_call, transfer_call, etc.). " + - "For versioned agents the change is saved as a draft — pass `draft_id` to stack onto an existing draft, then publish_draft to make it live. Use get_agent_prompt to see the agent's current tool names.", + "The change is saved to the agent's draft — publish_draft to make it live. Use get_agent_prompt to see the agent's current tool names.", inputSchema: { agent_id: z.string().describe("The agent ID to remove the tool from"), name: z.string().min(1).describe("The exact name of the tool to remove"), - draft_id: z + branch_id: z .string() .optional() - .describe( - "Existing draft to write into (stacks this change onto the draft's other edits). Omit to create a new draft from the live version." - ), + .describe("Branch whose draft to edit (from list_branches). Omit to use the live branch; if the agent has multiple branches you'll be asked to pick one."), }, }, async (params) => { - const fetched = await fetchAgentAndTools(params.agent_id); + const fetched = await fetchAgentAndTools(params.agent_id, params.branch_id); if (!fetched.ok) { return { content: [{ type: "text" as const, text: fetched.message }] }; } @@ -42,7 +40,7 @@ export function registerRemoveAgentTool(server: McpServer) { }; } - const persisted = await persistAgentTools(fetched.agent, fetched.prompt, tools, params.draft_id); + const persisted = await persistAgentTools(fetched.agent, fetched.branchId, tools); if (!persisted.ok) { return { content: [{ type: "text" as const, text: persisted.message }] }; } @@ -51,13 +49,9 @@ export function registerRemoveAgentTool(server: McpServer) { message: `Tool '${params.name}' removed (${tools.length} tool${tools.length === 1 ? "" : "s"} remaining).`, agentId: params.agent_id, totalTools: tools.length, + status: "draft", + hint: DRAFT_HINT, }; - if (persisted.versioned) { - result.versioned = true; - result.draftId = persisted.draftId; - result.status = "draft"; - result.hint = VERSIONED_DRAFT_HINT; - } return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } diff --git a/src/tools/rename-branch.ts b/src/tools/rename-branch.ts new file mode 100644 index 0000000..44fff37 --- /dev/null +++ b/src/tools/rename-branch.ts @@ -0,0 +1,45 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +import { atomsApi, formatApiError } from "../api.js"; + +export function registerRenameBranch(server: McpServer) { + server.registerTool( + "rename_branch", + { + description: "Rename a branch. The default branch cannot be renamed, and 'main' is reserved. Use list_branches to find branch IDs.", + inputSchema: { + agent_id: z.string().describe("The agent ID"), + branch_id: z.string().describe("The branch to rename (from list_branches)"), + name: z.string().min(1).max(100).describe("New branch name (1-100 chars)"), + }, + }, + async (params) => { + const result = await atomsApi( + "PATCH", + `/agent/${encodeURIComponent(params.agent_id)}/branches/${encodeURIComponent(params.branch_id)}`, + { name: params.name } + ); + if (!result.ok) { + if (result.status === 404) { + return { content: [{ type: "text" as const, text: `Branch not found: ${params.branch_id}` }] }; + } + return { content: [{ type: "text" as const, text: formatApiError(result) }] }; + } + + const branch = result.data?.data ?? result.data; + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { message: `Branch renamed to '${params.name}'.`, agentId: params.agent_id, branchId: params.branch_id, name: branch?.name ?? params.name }, + null, + 2 + ), + }, + ], + }; + } + ); +} diff --git a/src/tools/rename-draft.ts b/src/tools/rename-draft.ts deleted file mode 100644 index 231f7d1..0000000 --- a/src/tools/rename-draft.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; - -import { atomsApi, formatApiError } from "../api.js"; - -export function registerRenameDraft(server: McpServer) { - server.registerTool( - "rename_draft", - { - description: - "Rename a draft to give it a more descriptive name. Useful when working with multiple drafts to keep them organized.", - inputSchema: { - agent_id: z.string().describe("The agent ID"), - draft_id: z.string().describe("The draft ID to rename"), - draft_name: z - .string() - .min(1) - .max(100) - .describe("New name for the draft (max 100 chars)"), - }, - }, - async (params) => { - const result = await atomsApi( - "PATCH", - `/agent/${encodeURIComponent(params.agent_id)}/drafts/${encodeURIComponent(params.draft_id)}`, - { draftName: params.draft_name } - ); - - if (!result.ok) { - return { content: [{ type: "text" as const, text: formatApiError(result) }] }; - } - - return { - content: [ - { - type: "text" as const, - text: `Draft ${params.draft_id} renamed to "${params.draft_name}".`, - }, - ], - }; - } - ); -} diff --git a/src/tools/set-pre-call-api.ts b/src/tools/set-pre-call-api.ts deleted file mode 100644 index 78b1c70..0000000 --- a/src/tools/set-pre-call-api.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; - -import { atomsApi, formatApiError } from "../api.js"; -import type { IAgentDTO } from "../types.js"; -import { persistAgentConfig, VERSIONED_DRAFT_HINT } from "./agent-tools-helper.js"; - -export function registerSetPreCallApi(server: McpServer) { - server.registerTool( - "set_pre_call_api", - { - description: - "Configure (or disable) an agent's pre-call API. This is an HTTP request the platform makes automatically BEFORE the call connects — typically to enrich the agent with data, e.g. look up a customer record by phone number. " + - "Values extracted via response_variables become {{variables}} you can reference in the prompt and first message. " + - "This is different from add_agent_tool: a pre-call API always runs once before the call and is not chosen by the LLM, whereas an api_call tool is invoked by the agent during the conversation. " + - "There is exactly one pre-call API per agent (this replaces it). For versioned agents the change is saved as a draft — pass `draft_id` to stack onto an existing draft, then publish_draft to make it live.", - inputSchema: { - agent_id: z.string().describe("The agent ID to configure"), - draft_id: z - .string() - .optional() - .describe( - "Existing draft to write into (stacks this change onto the draft's other edits). Omit to create a new draft from the live version." - ), - enabled: z - .boolean() - .optional() - .describe("Enable the pre-call API (default true). Pass false to disable it while keeping the saved config."), - url: z - .string() - .url() - .optional() - .describe("Endpoint URL. Required when enabling. May contain {{variable}} placeholders (e.g. system variables like {{from_number}})."), - method: z - .enum(["GET", "POST", "PUT", "PATCH", "DELETE"]) - .optional() - .describe("HTTP method. Default GET."), - headers: z - .record(z.string(), z.string()) - .optional() - .describe("Request headers as key/value pairs. Values may contain {{variable}} placeholders."), - body: z - .record(z.string(), z.any()) - .optional() - .describe("Request body as a JSON object (for POST/PUT/PATCH). Values may contain {{variable}} placeholders."), - query_params: z - .record(z.string(), z.string()) - .optional() - .describe("URL query parameters as key/value pairs. Values may contain {{variable}} placeholders."), - timeout_secs: z - .number() - .min(1) - .max(30) - .optional() - .describe("Request timeout in seconds (1-30). Default 5. Note: seconds, not milliseconds."), - response_variables: z - .array( - z.object({ - variableName: z.string().min(1).describe("Variable name the agent can reference as {{variableName}}"), - jsonPath: z.string().min(1).describe("JSON path into the response, e.g. data.customer.name"), - }) - ) - .optional() - .describe("Extract values from the API response into variables usable in the prompt and first message."), - }, - }, - async (params) => { - const agentResult = await atomsApi("GET", `/agent/${encodeURIComponent(params.agent_id)}`); - if (!agentResult.ok) { - if (agentResult.status === 404) { - return { content: [{ type: "text" as const, text: `Agent not found: ${params.agent_id}` }] }; - } - return { content: [{ type: "text" as const, text: formatApiError(agentResult) }] }; - } - - const agent = (agentResult.data?.data ?? agentResult.data) as IAgentDTO; - if (agent.workflowType === "workflow_graph") { - return { - content: [ - { - type: "text" as const, - text: "Smallest MCP does not support conversation flow (workflow_graph) agents. The pre-call API can only be set on single_prompt agents.", - }, - ], - }; - } - - const current = agent.preCallAPI; - const enabling = params.enabled !== false; - - const url = params.url ?? current?.url; - if (enabling && (!url || url.trim().length === 0)) { - return { - content: [ - { - type: "text" as const, - text: "A 'url' is required to enable the pre-call API (none provided and none currently configured).", - }, - ], - }; - } - - // Build the preCallAPI object, preserving existing fields when not overridden. - const preCallAPI: Record = { - isEnabled: enabling, - url: url ?? "", - method: params.method ?? current?.method ?? "GET", - timeout: params.timeout_secs ?? current?.timeout ?? 5, - responseVariables: params.response_variables ?? current?.responseVariables ?? [], - }; - const headers = params.headers ?? current?.headers; - if (headers !== undefined) preCallAPI.headers = headers; - const body = params.body ?? current?.body; - if (body !== undefined) preCallAPI.body = body; - const queryParams = params.query_params ?? current?.queryParams; - if (queryParams !== undefined) preCallAPI.queryParams = queryParams; - - const persisted = await persistAgentConfig(agent, { preCallAPI }, params.draft_id); - if (!persisted.ok) { - return { content: [{ type: "text" as const, text: persisted.message }] }; - } - - const result: Record = { - message: enabling - ? `Pre-call API ${current?.isEnabled ? "updated" : "enabled"} (${preCallAPI.method} ${preCallAPI.url}).` - : "Pre-call API disabled.", - agentId: params.agent_id, - preCallAPI: { - isEnabled: enabling, - method: preCallAPI.method, - url: preCallAPI.url, - extractsVariables: (preCallAPI.responseVariables as unknown[]).length, - }, - }; - if (persisted.versioned) { - result.versioned = true; - result.draftId = persisted.draftId; - result.status = "draft"; - result.hint = VERSIONED_DRAFT_HINT; - } - - return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; - } - ); -} diff --git a/src/tools/test-agent.ts b/src/tools/test-agent.ts new file mode 100644 index 0000000..6de1f2f --- /dev/null +++ b/src/tools/test-agent.ts @@ -0,0 +1,85 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +import { atomsApi, formatApiError } from "../api.js"; +import { resolveBranch } from "../versioning.js"; + +export function registerTestAgent(server: McpServer) { + server.registerTool( + "test_agent", + { + description: + "Start a test call against a branch — its committed head by default, its open draft (include_draft: true) to try unpublished changes, or a specific revision_id. Modes: webcall (default) or chat return LiveKit connection details; telephony places a real call to to_phone (required, E.164).", + inputSchema: { + agent_id: z.string().describe("The agent ID"), + branch_id: z + .string() + .optional() + .describe("Branch to test (from list_branches). Omit for the live branch; if the agent has multiple branches you'll be asked to pick one."), + include_draft: z + .boolean() + .optional() + .describe("Test the branch's open draft (unpublished changes) instead of its committed head. Cannot be combined with revision_id."), + revision_id: z + .string() + .optional() + .describe("Test a specific committed revision. Cannot be combined with include_draft."), + mode: z + .enum(["webcall", "chat", "telephony"]) + .optional() + .describe("Test mode. Default webcall. telephony places a real call to to_phone."), + to_phone: z + .string() + .optional() + .describe("Destination phone number in E.164 — required when mode is telephony."), + }, + }, + async (params) => { + if (params.include_draft && params.revision_id) { + return { + content: [ + { type: "text" as const, text: "Provide either include_draft or revision_id, not both." }, + ], + }; + } + if (params.mode === "telephony" && !params.to_phone) { + return { + content: [{ type: "text" as const, text: "to_phone (E.164) is required when mode is telephony." }], + }; + } + + const branch = await resolveBranch(params.agent_id, params.branch_id); + if (!branch.ok) { + return { content: [{ type: "text" as const, text: branch.message }] }; + } + + const body: Record = { mode: params.mode ?? "webcall" }; + if (params.include_draft !== undefined) body.includeDraft = params.include_draft; + if (params.revision_id !== undefined) body.revisionId = params.revision_id; + if (params.to_phone !== undefined) body.toPhone = params.to_phone; + + const result = await atomsApi( + "POST", + `/agent/${encodeURIComponent(params.agent_id)}/branches/${encodeURIComponent(branch.value.branchId)}/test-call`, + body + ); + if (!result.ok) { + return { content: [{ type: "text" as const, text: formatApiError(result) }] }; + } + + const data = result.data?.data ?? result.data; + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { message: `Test call initiated (${params.mode ?? "webcall"})`, branchId: branch.value.branchId, ...data }, + null, + 2 + ), + }, + ], + }; + } + ); +} diff --git a/src/tools/test-draft.ts b/src/tools/test-draft.ts deleted file mode 100644 index 3d388e8..0000000 --- a/src/tools/test-draft.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; - -import { atomsApi, formatApiError } from "../api.js"; - -export function registerTestDraft(server: McpServer) { - server.registerTool( - "test_draft", - { - description: - "Initiate a test call using a draft's configuration (before publishing). Supports webcall, chat, and telephony modes. Use this to verify draft changes work correctly before making them live.", - inputSchema: { - agent_id: z.string().describe("The agent ID"), - draft_id: z.string().describe("The draft ID to test"), - mode: z - .enum(["webcall", "chat", "telephony"]) - .default("webcall") - .describe("Test call mode: webcall (browser audio), chat (text only), or telephony (phone call)"), - to_phone: z - .string() - .optional() - .describe("Phone number in E.164 format. Required when mode is telephony."), - }, - }, - async (params) => { - const body: Record = { mode: params.mode }; - if (params.to_phone) { - body.toPhone = params.to_phone; - } - - const result = await atomsApi( - "POST", - `/agent/${encodeURIComponent(params.agent_id)}/drafts/${encodeURIComponent(params.draft_id)}/test-call`, - body - ); - - if (!result.ok) { - return { content: [{ type: "text" as const, text: formatApiError(result) }] }; - } - - const data = result.data?.data ?? result.data; - - return { - content: [ - { - type: "text" as const, - text: JSON.stringify( - { - message: `Test call initiated for draft ${params.draft_id} in ${params.mode} mode`, - ...data, - }, - null, - 2 - ), - }, - ], - }; - } - ); -} diff --git a/src/tools/test-version.ts b/src/tools/test-version.ts deleted file mode 100644 index f431e97..0000000 --- a/src/tools/test-version.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; - -import { atomsApi, formatApiError } from "../api.js"; - -export function registerTestVersion(server: McpServer) { - server.registerTool( - "test_version", - { - description: - "Initiate a test call using a specific published version's configuration. Supports webcall, chat, and telephony modes. Use this to test a non-active version before activating it (e.g. verifying a rollback candidate).", - inputSchema: { - agent_id: z.string().describe("The agent ID"), - version_id: z.string().describe("The published version ID to test"), - mode: z - .enum(["webcall", "chat", "telephony"]) - .default("webcall") - .describe("Test call mode: webcall (browser audio), chat (text only), or telephony (phone call)"), - to_phone: z - .string() - .optional() - .describe("Phone number in E.164 format. Required when mode is telephony."), - }, - }, - async (params) => { - const body: Record = { mode: params.mode }; - if (params.to_phone) { - body.toPhone = params.to_phone; - } - - const result = await atomsApi( - "POST", - `/agent/${encodeURIComponent(params.agent_id)}/versions/${encodeURIComponent(params.version_id)}/test-call`, - body - ); - - if (!result.ok) { - return { content: [{ type: "text" as const, text: formatApiError(result) }] }; - } - - const data = result.data?.data ?? result.data; - - return { - content: [ - { - type: "text" as const, - text: JSON.stringify( - { - message: `Test call initiated for version ${params.version_id} in ${params.mode} mode`, - ...data, - }, - null, - 2 - ), - }, - ], - }; - } - ); -} diff --git a/src/tools/update-agent-prompt.ts b/src/tools/update-agent-prompt.ts deleted file mode 100644 index 461dbd6..0000000 --- a/src/tools/update-agent-prompt.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; - -import { atomsApi, formatApiError } from "../api.js"; -import type { IAgentDTO } from "../types.js"; - -export function registerUpdateAgentPrompt(server: McpServer) { - server.registerTool( - "update_agent_prompt", - { - description: - "Update an agent's system prompt / instructions. Pass the full new prompt text. Only works for single_prompt agents. Optionally update the first message too. " + - "For versioned agents, changes are saved as a draft — pass `draft_id` to stack onto an existing draft (e.g. one returned by add_agent_tool or set_pre_call_api), then publish_draft once to make everything live.", - inputSchema: { - agent_id: z.string().describe("The agent ID to update"), - prompt: z.string().describe("The new system prompt for the agent"), - first_message: z - .string() - .optional() - .describe("Update the first message the agent says when a call starts (max 500 chars)"), - draft_id: z - .string() - .optional() - .describe( - "Existing draft to write into (stacks this change onto the draft's other edits). Omit to create a new draft from the live version." - ), - }, - }, - async (params) => { - // Step 1: Get the agent to find its workflowId and workflowType - const agentResult = await atomsApi("GET", `/agent/${encodeURIComponent(params.agent_id)}`); - - if (!agentResult.ok) { - if (agentResult.status === 404) { - return { - content: [{ type: "text" as const, text: `Agent not found: ${params.agent_id}` }], - }; - } - return { content: [{ type: "text" as const, text: formatApiError(agentResult) }] }; - } - - const agent = (agentResult.data?.data ?? agentResult.data) as IAgentDTO; - const workflowId = agent?.workflowId; - const workflowType: IAgentDTO["workflowType"] = agent?.workflowType; - - // Block conversation flow agents - if (workflowType === "workflow_graph") { - return { - content: [ - { - type: "text" as const, - text: "Smallest MCP does not support conversation flow (workflow_graph) agents. Please use single_prompt agents or recreate the agent via create_agent.", - }, - ], - }; - } - - const isVersioned = !!agent.activeVersionId; - - // --- Versioned agent: use draft flow --- - if (isVersioned) { - let draftId = params.draft_id; - - if (!draftId) { - const createDraftResult = await atomsApi( - "POST", - `/agent/${encodeURIComponent(params.agent_id)}/drafts`, - { sourceVersionId: agent.activeVersionId } - ); - - if (!createDraftResult.ok) { - return { - content: [ - { - type: "text" as const, - text: `Failed to create draft for prompt update: ${formatApiError(createDraftResult)}`, - }, - ], - }; - } - - const draft = createDraftResult.data?.data ?? createDraftResult.data; - draftId = draft?.draftId; - } - - if (!draftId) { - return { - content: [ - { type: "text" as const, text: "Draft created but no draftId returned by the API." }, - ], - }; - } - - const configBody: Record = { - singlePromptConfig: { prompt: params.prompt }, - }; - if (params.first_message !== undefined) { - configBody.firstMessage = params.first_message; - } - - const updateDraftResult = await atomsApi( - "PATCH", - `/agent/${encodeURIComponent(params.agent_id)}/drafts/${encodeURIComponent(draftId)}/config`, - configBody - ); - - if (!updateDraftResult.ok) { - return { - content: [ - { - type: "text" as const, - text: `Failed to update prompt on draft ${draftId}: ${formatApiError(updateDraftResult)}`, - }, - ], - }; - } - - return { - content: [ - { - type: "text" as const, - text: JSON.stringify( - { - message: `Prompt${params.first_message !== undefined ? " and first message" : ""} saved to draft.`, - versioned: true, - agentId: params.agent_id, - draftId, - status: "draft", - hint: "Changes are in draft state (not live yet). Pass this draftId as draft_id to other edit tools to stack more changes into the same draft, then publish_draft once to make everything live.", - }, - null, - 2 - ), - }, - ], - }; - } - - // --- Non-versioned agent: direct workflow update --- - if (!workflowId) { - return { - content: [ - { - type: "text" as const, - text: `Agent ${params.agent_id} has no workflow associated. Cannot update prompt.`, - }, - ], - }; - } - - // Get current workflow to preserve existing tools - const workflowResult = await atomsApi( - "GET", - `/agent/${encodeURIComponent(params.agent_id)}/workflow` - ); - - if (!workflowResult.ok) { - return { - content: [ - { - type: "text" as const, - text: `Failed to fetch existing workflow (needed to preserve tools): ${formatApiError(workflowResult)}`, - }, - ], - }; - } - - const workflowData = workflowResult.data?.data ?? workflowResult.data; - const existingTools = - workflowData?.data?.singlePromptConfig?.tools ?? - workflowData?.singlePromptConfig?.tools ?? - workflowData?.tools ?? - []; - - // Update workflow prompt - const result = await atomsApi("PATCH", `/workflow/${encodeURIComponent(workflowId)}`, { - type: "single_prompt", - singlePromptConfig: { - prompt: params.prompt, - tools: existingTools, - }, - }); - - if (!result.ok) { - return { content: [{ type: "text" as const, text: formatApiError(result) }] }; - } - - // Optionally update first message on the agent config - if (params.first_message !== undefined) { - const firstMsgResult = await atomsApi( - "PATCH", - `/agent/${encodeURIComponent(params.agent_id)}`, - { firstMessage: params.first_message } - ); - if (!firstMsgResult.ok) { - return { - content: [ - { - type: "text" as const, - text: `Agent prompt updated successfully, but failed to update first message: ${formatApiError(firstMsgResult)}`, - }, - ], - }; - } - return { - content: [ - { - type: "text" as const, - text: `Agent ${params.agent_id} prompt and first message updated successfully.`, - }, - ], - }; - } - - return { - content: [ - { - type: "text" as const, - text: `Agent ${params.agent_id} prompt updated successfully.`, - }, - ], - }; - } - ); -} diff --git a/src/tools/update-agent-config.ts b/src/tools/update-agent.ts similarity index 74% rename from src/tools/update-agent-config.ts rename to src/tools/update-agent.ts index 21a7c34..1cb14e4 100644 --- a/src/tools/update-agent-config.ts +++ b/src/tools/update-agent.ts @@ -2,17 +2,23 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { atomsApi, formatApiError } from "../api.js"; +import { resolveBranch, saveConfigToBranch } from "../versioning.js"; import { resolveProModelId } from "../voice-catalog.js"; +import { DRAFT_HINT } from "./agent-tools-helper.js"; import type { IAgentDTO } from "../types.js"; -export function registerUpdateAgentConfig(server: McpServer) { +export function registerUpdateAgent(server: McpServer) { server.registerTool( - "update_agent_config", + "update_agent", { description: - "Update an agent's configuration (name, language, first message, voice settings, model, variables, etc.). Only provided fields are updated. To update the agent's prompt/instructions, use update_agent_prompt instead. For versioned agents, changes are saved as a draft — use publish_draft to make them live, or test the draft first via make_call with the draft's version_id.", + "Update an agent — name, prompt/instructions, first message, voice, model, language, variables, the pre-call API, and other settings. Only provided fields are updated. Config changes are saved to the branch's draft (publish_draft to make them live, or test first with test_agent using include_draft); metadata (name, phone numbers, inbound toggle) applies immediately. To add/remove the agent's API-call tools use add_agent_tool / remove_agent_tool; for end_call/transfer use configure_call_actions.", inputSchema: { agent_id: z.string().describe("The agent ID to update"), + branch_id: z + .string() + .optional() + .describe("Branch whose draft to edit (from list_branches). Omit to use the live branch; if the agent has multiple branches you'll be asked to pick one."), name: z.string().optional().describe("New agent name"), description: z.string().optional().describe("Agent description"), language: z @@ -33,6 +39,10 @@ export function registerUpdateAgentConfig(server: McpServer) { .string() .optional() .describe("First message when call starts (max 500 chars)"), + prompt: z + .string() + .optional() + .describe("The agent's system prompt / instructions (full text). single_prompt agents only."), synthesizer: z .object({ voiceConfig: z @@ -77,7 +87,7 @@ export function registerUpdateAgentConfig(server: McpServer) { global_prompt: z .string() .optional() - .describe("Global system prompt for the agent (max 4000 chars). This is separate from the workflow prompt updated via update_agent_prompt."), + .describe("Global system prompt for the agent (max 4000 chars). This is separate from the main prompt (the `prompt` field)."), default_variables: z .record(z.string(), z.string()) .optional() @@ -180,6 +190,36 @@ export function registerUpdateAgentConfig(server: McpServer) { .describe( "Telephony product IDs (see get_phone_numbers) to assign to this agent. Takes effect IMMEDIATELY — number assignment is agent metadata, not versioned config, so no draft/publish is involved. Replaces the agent's current numbers; assigning a number already attached to another agent moves it. Pass [] to unassign all." ), + pre_call_api: z + .object({ + enabled: z + .boolean() + .optional() + .describe("Enable the pre-call API (default true). Pass false to disable it while keeping the saved config."), + url: z + .string() + .url() + .optional() + .describe("Endpoint URL. Required when enabling. May contain {{variable}} placeholders."), + method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]).optional().describe("HTTP method. Default GET."), + headers: z.record(z.string(), z.string()).optional().describe("Request headers as key/value pairs."), + body: z.record(z.string(), z.any()).optional().describe("Request body as a JSON object (for POST/PUT/PATCH)."), + query_params: z.record(z.string(), z.string()).optional().describe("URL query parameters as key/value pairs."), + timeout_secs: z.number().min(1).max(30).optional().describe("Request timeout in seconds (1-30). Default 5."), + response_variables: z + .array( + z.object({ + variableName: z.string().min(1).describe("Variable name the agent can reference as {{variableName}}"), + jsonPath: z.string().min(1).describe("JSON path into the response, e.g. data.customer.name"), + }) + ) + .optional() + .describe("Extract values from the API response into variables usable in the prompt and first message."), + }) + .optional() + .describe( + "Configure (or disable) the pre-call API — an HTTP request the platform makes automatically BEFORE the call connects to enrich the agent with data. Runs once and is not chosen by the LLM (unlike add_agent_tool, which the agent invokes during the call)." + ), }, }, async (params) => { @@ -285,40 +325,53 @@ export function registerUpdateAgentConfig(server: McpServer) { }; } - if (Object.keys(body).length === 0) { - return { - content: [{ type: "text" as const, text: "No fields provided to update." }], - }; + // Prompt lives in its own single_prompt section (separate from tools — no clobber). + if (params.prompt !== undefined) { + body.singlePromptConfig = { prompt: params.prompt }; } - const isVersioned = !!agent.activeVersionId; - - // --- Non-versioned agent: direct update --- - if (!isVersioned) { - const result = await atomsApi("PATCH", `/agent/${encodeURIComponent(params.agent_id)}`, body); - - if (!result.ok) { - return { content: [{ type: "text" as const, text: formatApiError(result) }] }; + // Pre-call API — build the object, preserving existing fields when not overridden. + if (params.pre_call_api !== undefined) { + const pc = params.pre_call_api; + const current = agent.preCallAPI; + const enabling = pc.enabled !== false; + const url = pc.url ?? current?.url; + if (enabling && (!url || url.trim().length === 0)) { + return { + content: [ + { + type: "text" as const, + text: "A pre_call_api.url is required to enable the pre-call API (none provided and none currently configured).", + }, + ], + }; } + const preCallAPI: Record = { + isEnabled: enabling, + url: url ?? "", + method: pc.method ?? current?.method ?? "GET", + timeout: pc.timeout_secs ?? current?.timeout ?? 5, + responseVariables: pc.response_variables ?? current?.responseVariables ?? [], + }; + const headers = pc.headers ?? current?.headers; + if (headers !== undefined) preCallAPI.headers = headers; + const pcBody = pc.body ?? current?.body; + if (pcBody !== undefined) preCallAPI.body = pcBody; + const queryParams = pc.query_params ?? current?.queryParams; + if (queryParams !== undefined) preCallAPI.queryParams = queryParams; + body.preCallAPI = preCallAPI; + } + if (Object.keys(body).length === 0) { return { - content: [ - { - type: "text" as const, - text: `Agent ${params.agent_id} config updated successfully. Fields updated: ${Object.keys(body).join(", ")}`, - }, - ], + content: [{ type: "text" as const, text: "No fields provided to update." }], }; } - // --- Versioned agent: create draft → update draft config --- - - // Separate metadata fields (can still be updated directly on the agent). - // Must mirror the backend's metadataOnlyFields: telephonyProductId in - // particular ONLY works here — the direct PATCH writes the number→agent - // binding into the products registry. Sent via the draft path it is - // silently inert: the draft accepts the key but nothing ever assigns the - // number, so it looks like it worked and inbound routing never changes. + // Metadata fields are still written directly on the agent, not via the + // branch draft. telephonyProductId in particular ONLY works here — the + // direct PATCH writes the number→agent binding into the products registry. + // Sent via the draft path it is silently inert. const metadataFields: Record = {}; const configFields: Record = {}; const METADATA_KEYS = ["name", "description", "allowInboundCall", "telephonyProductId"]; @@ -347,44 +400,35 @@ export function registerUpdateAgentConfig(server: McpServer) { } } - // Update config via draft if any config fields + // Update config via the chosen branch's draft if any config fields if (Object.keys(configFields).length > 0) { - // Step 1: Create draft from active version - const createDraftResult = await atomsApi( - "POST", - `/agent/${encodeURIComponent(params.agent_id)}/drafts`, - { sourceVersionId: agent.activeVersionId } - ); - - if (!createDraftResult.ok) { + const branch = await resolveBranch(params.agent_id, params.branch_id); + if (!branch.ok) { return { content: [ { type: "text" as const, - text: messages.length > 0 - ? `${messages.join(". ")}. However, failed to create draft for config changes: ${formatApiError(createDraftResult)}` - : `Failed to create draft: ${formatApiError(createDraftResult)}`, + text: messages.length > 0 ? `${messages.join(". ")}. However, ${branch.message}` : branch.message, }, ], }; } - const draft = createDraftResult.data?.data ?? createDraftResult.data; - const draftId = draft?.draftId; - - // Step 2: Update draft config - const updateDraftResult = await atomsApi( - "PATCH", - `/agent/${encodeURIComponent(params.agent_id)}/drafts/${encodeURIComponent(draftId)}/config`, - configFields - ); - - if (!updateDraftResult.ok) { - messages.push(`Draft ${draftId} created but failed to update config: ${formatApiError(updateDraftResult)}`); - } else { - messages.push(`Config changes saved to draft. Fields: ${Object.keys(configFields).join(", ")}`); + const saved = await saveConfigToBranch(params.agent_id, branch.value.branchId, configFields); + if (!saved.ok) { + return { + content: [ + { + type: "text" as const, + text: messages.length > 0 + ? `${messages.join(". ")}. However, ${saved.message}` + : saved.message, + }, + ], + }; } + messages.push(`Config changes saved to draft. Fields: ${Object.keys(configFields).join(", ")}`); return { content: [ { @@ -392,11 +436,9 @@ export function registerUpdateAgentConfig(server: McpServer) { text: JSON.stringify( { message: messages.join(". "), - versioned: true, agentId: params.agent_id, - draftId, status: "draft", - hint: "Changes are in draft state (not live yet). Use publish_draft to make them live, or make_call with version_id to test the draft first.", + hint: DRAFT_HINT, }, null, 2 diff --git a/src/tools/update-version.ts b/src/tools/update-version.ts deleted file mode 100644 index 603c404..0000000 --- a/src/tools/update-version.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; - -import { atomsApi, formatApiError } from "../api.js"; - -export function registerUpdateVersion(server: McpServer) { - server.registerTool( - "update_version", - { - description: - "Update metadata on a published version — label, description (release notes), or pin status. Pinned versions are highlighted in the version list for quick access. Does NOT change the version's configuration (published versions are immutable).", - inputSchema: { - agent_id: z.string().describe("The agent ID"), - version_id: z.string().describe("The published version ID to update"), - label: z - .string() - .max(200) - .optional() - .describe("Version label (max 200 chars, e.g. 'Production v2')"), - description: z - .string() - .max(2000) - .optional() - .describe("Release notes / changelog (max 2000 chars)"), - is_pinned: z - .boolean() - .optional() - .describe("Pin this version for quick access in the version list"), - }, - }, - async (params) => { - const body: Record = {}; - if (params.label !== undefined) body.label = params.label; - if (params.description !== undefined) body.description = params.description; - if (params.is_pinned !== undefined) body.isPinned = params.is_pinned; - - if (Object.keys(body).length === 0) { - return { - content: [{ type: "text" as const, text: "No fields provided to update." }], - }; - } - - const result = await atomsApi( - "PATCH", - `/agent/${encodeURIComponent(params.agent_id)}/versions/${encodeURIComponent(params.version_id)}`, - body - ); - - if (!result.ok) { - return { content: [{ type: "text" as const, text: formatApiError(result) }] }; - } - - const updated = result.data?.data ?? result.data; - - return { - content: [ - { - type: "text" as const, - text: JSON.stringify( - { - message: "Version metadata updated successfully.", - versionId: params.version_id, - label: updated?.label ?? params.label, - description: updated?.description ?? params.description, - isPinned: updated?.isPinned ?? params.is_pinned, - }, - null, - 2 - ), - }, - ], - }; - } - ); -} diff --git a/src/versioning.ts b/src/versioning.ts new file mode 100644 index 0000000..5626f60 --- /dev/null +++ b/src/versioning.ts @@ -0,0 +1,223 @@ +import { atomsApi, formatApiError } from "./api.js"; + +/** + * Agent versioning v2 (branch model) client helpers. + * + * An agent has BRANCHES; each branch has a series of committed revisions + * (head = live if it's the live branch) and at most one open draft (unnamed — + * present or not). Edits go to a branch's draft; publishing commits a new + * revision. The live branch is the one that serves traffic. + */ + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Poll cadence for the async security scan on publish (~60s total). */ +const SCAN_POLL_INTERVAL_MS = 4000; +const SCAN_MAX_POLLS = 15; + +export type BranchResult = { ok: true; value: T } | { ok: false; message: string }; + +export interface Branch { + branchId: string; + name: string | null; + isLive: boolean; + hasOpenDraft: boolean; + openDraftId: string | null; + headRevisionId: string | null; +} + +/** Map a BranchSummary ({ branch, isLive, hasOpenDraft, ... }) to our Branch shape. */ +function toBranch(summary: any): Branch { + return { + branchId: summary.branch?._id, + name: summary.branch?.name ?? null, + isLive: !!summary.isLive, + hasOpenDraft: !!summary.hasOpenDraft, + openDraftId: (summary.branch?.openDraftId as string | null) ?? null, + headRevisionId: (summary.branch?.headRevisionId as string | null) ?? null, + }; +} + +async function fetchBranches(agentId: string): Promise> { + const res = await atomsApi("GET", `/agent/${encodeURIComponent(agentId)}/branches`); + if (!res.ok) { + if (res.status === 404) return { ok: false, message: `Agent not found: ${agentId}` }; + return { ok: false, message: formatApiError(res) }; + } + const data = res.data?.data ?? res.data; + const summaries: any[] = Array.isArray(data?.branches) ? data.branches : []; + const branches = summaries.map(toBranch).filter((b) => b.branchId); + return { ok: true, value: branches }; +} + +/** The live (serving) branch. Used where "live" is always correct (e.g. make_call). */ +export async function resolveLiveBranch(agentId: string): Promise> { + const res = await fetchBranches(agentId); + if (!res.ok) return res; + const live = res.value.find((b) => b.isLive); + if (!live) { + return { + ok: false, + message: `No live branch found for agent ${agentId} — it may not be migrated to the branch model yet.`, + }; + } + return { ok: true, value: live }; +} + +/** + * Resolve the branch to operate on: + * - branchId given → that branch (error if it doesn't exist) + * - omitted + a single branch → that branch (no ambiguity) + * - omitted + multiple branches → ask the user which one (returns an actionable error) + */ +export async function resolveBranch(agentId: string, branchId?: string): Promise> { + const res = await fetchBranches(agentId); + if (!res.ok) return res; + const branches = res.value; + + if (branchId) { + const match = branches.find((b) => b.branchId === branchId); + if (!match) { + return { ok: false, message: `Branch not found: ${branchId}. Use list_branches to see this agent's branches.` }; + } + return { ok: true, value: match }; + } + + if (branches.length > 1) { + const list = branches + .map((b) => `- ${b.name} (branch_id: ${b.branchId})${b.isLive ? " [live]" : ""}${b.hasOpenDraft ? " [has draft]" : ""}`) + .join("\n"); + return { + ok: false, + message: `This agent has multiple branches — ask the user which one to use, then retry with branch_id:\n${list}`, + }; + } + + const only = branches.find((b) => b.isLive) ?? branches[0]; + if (!only) { + return { ok: false, message: `No branch found for agent ${agentId} — it may not be migrated to the branch model yet.` }; + } + return { ok: true, value: only }; +} + +/** + * Save a config payload onto a branch's draft (auto-opens the draft). `payload` + * is the same UI-shaped agent config the console uses: { singlePromptConfig: + * { prompt, tools }, preCallAPI, playbooks, synthesizer, firstMessage, ... }. + */ +export async function saveConfigToBranch( + agentId: string, + branchId: string, + payload: Record +): Promise> { + const res = await atomsApi( + "PUT", + `/agent/${encodeURIComponent(agentId)}/branches/${encodeURIComponent(branchId)}/draft`, + payload + ); + if (!res.ok) return { ok: false, message: formatApiError(res) }; + return { ok: true, value: { branchId } }; +} + +export type ScanState = "committed" | "scanning" | "failed"; + +export interface PublishOutcome { + branchId: string; + isLive: boolean; + state: ScanState; + revisionId?: string; + revisionNumber?: number; + reason?: string | null; +} + +/** + * Publish a branch's open draft and wait for the async security scan. + * Publish returns 200 { state: "committed", revision } (already-passed / graph) + * or 202 { state: "scanning" }; on scanning we poll until it commits or fails. + */ +export async function publishBranch( + agentId: string, + branch: Branch, + opts: { label?: string } = {} +): Promise> { + // `label` is forward-compatible: wired up by the publish-label backend change; ignored until then. + const body: Record = {}; + if (opts.label !== undefined) body.label = opts.label; + + const res = await atomsApi( + "POST", + `/agent/${encodeURIComponent(agentId)}/branches/${encodeURIComponent(branch.branchId)}/draft/publish`, + body + ); + if (!res.ok) return { ok: false, message: formatApiError(res) }; + + const settled = await settleScan(agentId, branch, res.data); + return { ok: true, value: { ...settled, isLive: branch.isLive } }; +} + +/** Read the publish result envelope; poll the draft if a scan is still running. */ +async function settleScan( + agentId: string, + branch: Branch, + envelope: any +): Promise> { + const data = envelope?.data ?? envelope; + if (data?.state === "committed") { + return { + branchId: branch.branchId, + state: "committed", + revisionId: data?.revision?._id, + revisionNumber: data?.revision?.revisionNumber ?? data?.revision?.versionNumber, + }; + } + return pollScan(agentId, branch); +} + +/** Fetch a single branch's current head revision id (or null). */ +async function fetchBranchHead(agentId: string, branchId: string): Promise { + const res = await atomsApi( + "GET", + `/agent/${encodeURIComponent(agentId)}/branches/${encodeURIComponent(branchId)}` + ); + if (!res.ok) return null; + const data = res.data?.data ?? res.data; + return (data?.branch?.headRevisionId as string | null) ?? null; +} + +/** + * Poll a scanning publish. Once the scan passes, reconcile commits and closes the + * draft (draft detail 404s); a failed scan leaves the draft open with + * securityCheck.status === "failed". A 404 alone isn't proof of commit (it could + * be a concurrently-discarded draft or eventual consistency), so on 404 we confirm + * the branch head actually advanced past the pre-publish head before reporting + * "committed"; otherwise we keep polling. + */ +async function pollScan(agentId: string, branch: Branch): Promise> { + const branchId = branch.branchId; + const priorHead = branch.headRevisionId; + const draftPath = `/agent/${encodeURIComponent(agentId)}/branches/${encodeURIComponent(branchId)}/draft`; + + for (let polls = 0; polls < SCAN_MAX_POLLS; polls++) { + await sleep(SCAN_POLL_INTERVAL_MS); + + const detail = await atomsApi("GET", draftPath); + + if (detail.status === 404) { + // Draft closed — confirm a new revision actually landed (head advanced). + const head = await fetchBranchHead(agentId, branchId); + if (head && head !== priorHead) { + return { branchId, state: "committed", revisionId: head }; + } + continue; // head unchanged → discarded/eventual consistency; keep polling. + } + if (!detail.ok) continue; + + const latest = (detail.data?.data ?? detail.data)?.latest; + const status: string | undefined = latest?.securityCheck?.status; + if (status === "failed" || status === "errored") { + return { branchId, state: "failed", reason: latest?.securityCheck?.reason ?? null }; + } + } + + return { branchId, state: "scanning" }; +}