diff --git a/apps/docs/content/docs/en/integrations/managed_agent.mdx b/apps/docs/content/docs/en/integrations/managed_agent.mdx index 2a42532eabf..dd652b99989 100644 --- a/apps/docs/content/docs/en/integrations/managed_agent.mdx +++ b/apps/docs/content/docs/en/integrations/managed_agent.mdx @@ -64,4 +64,200 @@ Open a Claude Platform Managed Agent session and return the assistant response a | `inputTokens` | number | Cumulative input tokens for the session. | | `outputTokens` | number | Cumulative output tokens for the session. | +### `managed_agent_create_session` + +Create a Claude Platform Managed Agent session and return its id without waiting for a reply. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `agent` | string | Yes | Managed-agent id inside the linked Claude workspace. | +| `environment` | string | Yes | Environment id inside the linked Claude workspace. | +| `environmentType` | string | No | Environment execution model hint \('cloud' \| 'self_hosted'\). | +| `userMessage` | string | No | Optional first message; seeds initial_events and starts the agent immediately. | +| `vaults` | array | No | Zero or more vault ids for MCP tool auth. | +| `vaultsAck` | boolean | No | Acknowledgement that the author may use the attached vaults. | +| `memoryStoreId` | string | No | Optional Agent Memory Store id. | +| `memoryAccess` | string | No | Memory store access mode: 'read_write' \(default\) or 'read_only'. | +| `memoryInstructions` | string | No | Per-attachment guidance for how the agent should use the memory store. | +| `files` | array | No | File attachments \(cloud envs only\), as \[\{fileId, mountPath?\}\]. | +| `sessionParameters` | object | No | Key/value session metadata forwarded to the session. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sessionId` | string | Anthropic session id \(sesn_...\). | +| `started` | boolean | True when a first message was seeded, so the agent is already running. | + +### `managed_agent_send_message` + +Send a user message to an existing Claude Platform Managed Agent session. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `userMessage` | string | Yes | The user message to send to the session. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sessionId` | string | The session the message was sent to. | +| `sent` | boolean | True when the event was accepted by the API. | + +### `managed_agent_get_session` + +Read a Managed Agent session: status, stop reason, token usage, metadata, and any tool calls awaiting approval. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sessionId` | string | The session that was read. | +| `status` | string | Session status — 'idle', 'running', 'rescheduling', or 'terminated'. | +| `stopReason` | string | Why the session last stopped, e.g. 'end_turn' or 'requires_action'. | +| `requiresAction` | boolean | True when the session is waiting on a tool confirmation or custom tool result. If this is true while pendingTools is empty, the session is blocked but the API named no blocking events — surface it rather than treating the session as done. | +| `pendingTools` | json | Blocking tool calls — \[\{id, eventType, kind, name, input\}\]. Route by kind: 'confirmation' ids go to Respond To Tool Confirmation, 'custom_tool_result' ids go to Respond To Custom Tool. | +| `metadata` | json | Session metadata. | +| `title` | string | Session title. | +| `inputTokens` | number | Cumulative input tokens. | +| `outputTokens` | number | Cumulative output tokens. | + +### `managed_agent_list_events` + +Read a Managed Agent session's event history and the agent's reply text. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `eventTypes` | array | No | Optional event-type filter, e.g. \['agent.message'\]. Omit to return every event. | +| `limit` | number | No | Maximum events to return, keeping the most recent \(default 500\). | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sessionId` | string | The session that was read. | +| `events` | json | Session events, oldest first. | +| `count` | number | Number of events returned. | +| `assistantText` | string | Concatenated text of every persisted agent.message, in order. | +| `truncated` | boolean | True when the limit was hit and older events were dropped. | + +### `managed_agent_update_session` + +Update a Managed Agent session's title or metadata. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `title` | string | No | New session title. | +| `sessionParameters` | object | No | Replacement metadata map \(replaces all stored metadata, not merged\). Leaving it empty leaves the stored metadata unchanged — use clearMetadata to remove it. | +| `clearMetadata` | boolean | No | Removes all of the session's stored metadata. Overrides any map supplied above. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sessionId` | string | The session that was updated. | +| `updated` | boolean | True when the update was accepted. | +| `metadata` | json | Metadata after the update. | +| `title` | string | Title after the update. | + +### `managed_agent_interrupt_session` + +Stop a running Managed Agent session; it stays usable afterwards. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sessionId` | string | The session that was interrupted. | +| `interrupted` | boolean | True when the interrupt was accepted. | + +### `managed_agent_respond_tool_confirmation` + +Allow or deny the tool calls a Managed Agent session is waiting on before it can continue. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `toolUseIds` | array | Yes | Blocking tool-use EVENT ids, from Get Session pendingTools\[\].id where kind is 'confirmation' \(not toolu_ ids\). | +| `decision` | string | Yes | 'allow' to let the tools run, or 'deny' to reject them. | +| `denyMessage` | string | No | Reason surfaced to the agent. Only sent when the decision is deny. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sessionId` | string | The session that was answered. | +| `decision` | string | The decision applied — 'allow' or 'deny'. | +| `confirmedToolUseIds` | json | The tool-use event ids that were answered. | + +### `managed_agent_respond_custom_tool` + +Return the result of a custom tool a Managed Agent session is waiting on so it can continue. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `customToolUseId` | string | Yes | The custom tool-use EVENT id being answered, from Get Session pendingTools\[\].id where kind is 'custom_tool_result'. | +| `result` | string | Yes | The tool's output, returned to the agent as text. | +| `isError` | boolean | No | Mark the result as a failure so the agent can adjust its approach. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sessionId` | string | The session that was answered. | +| `answeredToolUseId` | string | The custom tool-use event id that was answered. | + +### `managed_agent_archive_session` + +Archive a Managed Agent session, preserving its history. Not reversible. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sessionId` | string | The session that was archived. | +| `archived` | boolean | True when the archive was accepted. | + +### `managed_agent_delete_session` + +Permanently delete a Managed Agent session, its events, and its sandbox. Not reversible. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sessionId` | string | The session that was deleted. | +| `deleted` | boolean | True when the delete was accepted. | + diff --git a/apps/sim/blocks/blocks/managed_agent.test.ts b/apps/sim/blocks/blocks/managed_agent.test.ts new file mode 100644 index 00000000000..26f5a1e2140 --- /dev/null +++ b/apps/sim/blocks/blocks/managed_agent.test.ts @@ -0,0 +1,174 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { evaluateSubBlockCondition } from '@/lib/workflows/subblocks/visibility' +import { ManagedAgentBlock } from '@/blocks/blocks/managed_agent' + +const subBlockById = (id: string) => { + const config = ManagedAgentBlock.subBlocks.find((sub) => sub.id === id) + if (!config) throw new Error(`No sub-block '${id}' on the Managed Agent block`) + return config +} + +/** Mirrors how the editor and serializer evaluate visibility: raw stored values. */ +const isVisible = (id: string, values: Record) => + evaluateSubBlockCondition(subBlockById(id).condition, values) + +const selectTool = (params: Record) => + ManagedAgentBlock.tools.config?.tool?.(params as never) + +/** + * The block gained an operation selector after it shipped. Blocks saved before + * that have NO stored `operation` value, so these tests pin the promise that + * they keep behaving — and looking — exactly as they did. + */ +describe('Managed Agent block — legacy blocks without a stored operation', () => { + const legacyValues = { + credential: 'cred-1', + agent: 'agent_1', + environment: 'env_1', + environmentType: 'cloud', + userMessage: 'do the thing', + } + + it('resolves to the run-session tool', () => { + expect(selectTool(legacyValues)).toBe('managed_agent_run_session') + }) + + it('resolves to the run-session tool when operation is explicitly absent', () => { + expect(selectTool({})).toBe('managed_agent_run_session') + expect(selectTool({ operation: undefined })).toBe('managed_agent_run_session') + expect(selectTool({ operation: null })).toBe('managed_agent_run_session') + }) + + it.each(['agent', 'environment', 'environmentType', 'userMessage'])( + 'keeps the %s field visible', + (id) => { + expect(isVisible(id, legacyValues)).toBe(true) + } + ) + + it('keeps cloud-only fields visible', () => { + expect(isVisible('memoryStoreId', legacyValues)).toBe(true) + expect(isVisible('files', legacyValues)).toBe(true) + expect(isVisible('sessionParameters', legacyValues)).toBe(true) + }) + + it('still hides cloud-only fields on a self-hosted legacy block', () => { + const selfHosted = { ...legacyValues, environmentType: 'self_hosted' } + expect(isVisible('memoryStoreId', selfHosted)).toBe(false) + expect(isVisible('files', selfHosted)).toBe(false) + // Metadata applies to both environment models. + expect(isVisible('sessionParameters', selfHosted)).toBe(true) + }) + + it('does not show session-targeting fields', () => { + expect(isVisible('sessionId', legacyValues)).toBe(false) + expect(isVisible('toolUseIds', legacyValues)).toBe(false) + }) + + it('keeps the legacy run-session output shape as the fallback', () => { + expect(Object.keys(ManagedAgentBlock.outputs)).toEqual( + expect.arrayContaining(['content', 'sessionId', 'inputTokens', 'outputTokens']) + ) + }) +}) + +describe('Managed Agent block — operation routing', () => { + it.each([ + ['run_session', 'managed_agent_run_session'], + ['create_session', 'managed_agent_create_session'], + ['send_message', 'managed_agent_send_message'], + ['get_session', 'managed_agent_get_session'], + ['list_events', 'managed_agent_list_events'], + ['update_session', 'managed_agent_update_session'], + ['interrupt_session', 'managed_agent_interrupt_session'], + ['respond_tool_confirmation', 'managed_agent_respond_tool_confirmation'], + ['respond_custom_tool', 'managed_agent_respond_custom_tool'], + ['archive_session', 'managed_agent_archive_session'], + ['delete_session', 'managed_agent_delete_session'], + ])('maps %s to %s', (operation, toolId) => { + expect(selectTool({ operation })).toBe(toolId) + }) + + it('falls back to run session for an unknown operation', () => { + expect(selectTool({ operation: 'not_a_real_operation' })).toBe('managed_agent_run_session') + }) + + it('declares every mapped tool in tools.access', () => { + const operations = subBlockById('operation').options as Array<{ id: string }> + for (const { id } of operations) { + expect(ManagedAgentBlock.tools.access).toContain(selectTool({ operation: id })) + } + }) +}) + +describe('Managed Agent block — per-operation field visibility', () => { + it('shows the session id for operations that target an existing session', () => { + for (const operation of [ + 'send_message', + 'get_session', + 'list_events', + 'update_session', + 'interrupt_session', + 'respond_tool_confirmation', + 'respond_custom_tool', + 'archive_session', + 'delete_session', + ]) { + expect(isVisible('sessionId', { operation })).toBe(true) + } + }) + + it('hides the agent and environment pickers once a session exists', () => { + expect(isVisible('agent', { operation: 'send_message' })).toBe(false) + expect(isVisible('environment', { operation: 'get_session' })).toBe(false) + expect(isVisible('environmentType', { operation: 'archive_session' })).toBe(false) + }) + + it('shows the user message for the three operations that send one', () => { + expect(isVisible('userMessage', { operation: 'run_session' })).toBe(true) + expect(isVisible('userMessage', { operation: 'create_session' })).toBe(true) + expect(isVisible('userMessage', { operation: 'send_message' })).toBe(true) + expect(isVisible('userMessage', { operation: 'get_session' })).toBe(false) + }) + + it('makes the user message optional only for create session', () => { + const required = subBlockById('userMessage').required + expect(evaluateSubBlockCondition(required as never, { operation: 'create_session' })).toBe( + false + ) + expect(evaluateSubBlockCondition(required as never, { operation: 'send_message' })).toBe(true) + expect(evaluateSubBlockCondition(required as never, { operation: 'run_session' })).toBe(true) + // A legacy block has no stored operation and must stay required. + expect(evaluateSubBlockCondition(required as never, {})).toBe(true) + }) + + it('reveals the deny reason only when denying a tool confirmation', () => { + expect( + isVisible('denyMessage', { operation: 'respond_tool_confirmation', decision: 'deny' }) + ).toBe(true) + expect( + isVisible('denyMessage', { operation: 'respond_tool_confirmation', decision: 'allow' }) + ).toBe(false) + expect(isVisible('denyMessage', { operation: 'send_message', decision: 'deny' })).toBe(false) + }) + + it('separates confirmation fields from custom-tool-result fields', () => { + // A confirmation cannot unblock a custom tool, so the two operations must + // not share inputs — otherwise a workflow can silently answer the wrong way. + expect(isVisible('toolUseIds', { operation: 'respond_tool_confirmation' })).toBe(true) + expect(isVisible('toolUseIds', { operation: 'respond_custom_tool' })).toBe(false) + expect(isVisible('customToolUseId', { operation: 'respond_custom_tool' })).toBe(true) + expect(isVisible('customToolUseId', { operation: 'respond_tool_confirmation' })).toBe(false) + expect(isVisible('result', { operation: 'respond_custom_tool' })).toBe(true) + expect(isVisible('decision', { operation: 'respond_custom_tool' })).toBe(false) + }) + + it('shows metadata for update session but not the agent config fields', () => { + expect(isVisible('sessionParameters', { operation: 'update_session' })).toBe(true) + expect(isVisible('title', { operation: 'update_session' })).toBe(true) + expect(isVisible('vaults', { operation: 'update_session' })).toBe(false) + }) +}) diff --git a/apps/sim/blocks/blocks/managed_agent.ts b/apps/sim/blocks/blocks/managed_agent.ts index 894fdd4bc16..9088719af5e 100644 Binary files a/apps/sim/blocks/blocks/managed_agent.ts and b/apps/sim/blocks/blocks/managed_agent.ts differ diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index deadb0012bc..8d4fb0149ed 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-07-30", + "updatedAt": "2026-07-31", "integrations": [ { "type": "onepassword", @@ -2905,8 +2905,53 @@ "bgColor": "#DA7756", "iconName": "ClaudeIcon", "docsUrl": "https://docs.sim.ai/integrations/managed-agent", - "operations": [], - "operationCount": 0, + "operations": [ + { + "name": "Run session (create, send, wait for reply)", + "description": "Open a Claude Platform Managed Agent session and return the assistant response as text." + }, + { + "name": "Create session", + "description": "Create a Claude Platform Managed Agent session and return its id without waiting for a reply." + }, + { + "name": "Send message", + "description": "Send a user message to an existing Claude Platform Managed Agent session." + }, + { + "name": "Get session", + "description": "Read a Managed Agent session: status, stop reason, token usage, metadata, and any tool calls awaiting approval." + }, + { + "name": "List events", + "description": "Read a Managed Agent session's event history and the agent's reply text." + }, + { + "name": "Update session", + "description": "Update a Managed Agent session's title or metadata." + }, + { + "name": "Interrupt session", + "description": "Stop a running Managed Agent session; it stays usable afterwards." + }, + { + "name": "Respond to tool confirmation", + "description": "Allow or deny the tool calls a Managed Agent session is waiting on before it can continue." + }, + { + "name": "Respond to custom tool", + "description": "Return the result of a custom tool a Managed Agent session is waiting on so it can continue." + }, + { + "name": "Archive session", + "description": "Archive a Managed Agent session, preserving its history. Not reversible." + }, + { + "name": "Delete session", + "description": "Permanently delete a Managed Agent session, its events, and its sandbox. Not reversible." + } + ], + "operationCount": 11, "triggers": [], "triggerCount": 0, "authType": "api-key", diff --git a/apps/sim/lib/managed-agents/session-client.test.ts b/apps/sim/lib/managed-agents/session-client.test.ts index 8eb870d3f69..4cf6184c9d5 100644 --- a/apps/sim/lib/managed-agents/session-client.test.ts +++ b/apps/sim/lib/managed-agents/session-client.test.ts @@ -2,7 +2,18 @@ * @vitest-environment node */ import { afterEach, describe, expect, it, vi } from 'vitest' -import { buildSessionCreatePayload, listSessionEvents } from '@/lib/managed-agents/session-client' +import { + archiveSession, + buildSessionCreatePayload, + deleteSession, + listSessionEvents, + listSessionEventsPage, + parseSessionSnapshot, + resolvePendingToolGates, + sendCustomToolResults, + sendToolConfirmations, + updateSession, +} from '@/lib/managed-agents/session-client' const BASE = { apiKey: 'sk-ant-fake', @@ -189,3 +200,551 @@ describe('listSessionEvents — ordering', () => { expect(events.map((e) => e.id)).toEqual(['a', 'b', 'c', 'queued']) }) }) + +describe('buildSessionCreatePayload — initial_events', () => { + it('seeds a single user.message so create+send is one call', () => { + const payload = buildSessionCreatePayload({ ...BASE, initialMessage: 'hello there' }) + expect(payload.initial_events).toEqual([ + { type: 'user.message', content: [{ type: 'text', text: 'hello there' }] }, + ]) + }) + + it('trims the seeded message', () => { + const payload = buildSessionCreatePayload({ ...BASE, initialMessage: ' hi ' }) + expect(payload.initial_events).toEqual([ + { type: 'user.message', content: [{ type: 'text', text: 'hi' }] }, + ]) + }) + + it('omits initial_events entirely when there is no message', () => { + // An empty array is equivalent to omitting the field, and a blank message + // would be rejected — so neither is ever sent. + expect(buildSessionCreatePayload({ ...BASE }).initial_events).toBeUndefined() + expect( + buildSessionCreatePayload({ ...BASE, initialMessage: '' }).initial_events + ).toBeUndefined() + expect( + buildSessionCreatePayload({ ...BASE, initialMessage: ' ' }).initial_events + ).toBeUndefined() + }) +}) + +describe('parseSessionSnapshot', () => { + it('reads status, usage, title and metadata', () => { + const snapshot = parseSessionSnapshot({ + status: 'idle', + title: 'my session', + metadata: { slack_channel: 'C123', retries: 2, ok: true, dropped: { a: 1 } }, + usage: { input_tokens: 10, output_tokens: 20 }, + }) + expect(snapshot.status).toBe('idle') + expect(snapshot.title).toBe('my session') + expect(snapshot.usage).toEqual({ inputTokens: 10, outputTokens: 20 }) + // Scalars are stringified; non-scalars are dropped rather than mangled. + expect(snapshot.metadata).toEqual({ slack_channel: 'C123', retries: '2', ok: 'true' }) + }) + + it('reads the blocking event ids off a requires_action stop reason', () => { + const snapshot = parseSessionSnapshot({ + status: 'idle', + stop_reason: { type: 'requires_action', event_ids: ['sevt_1', 'sevt_2'] }, + }) + expect(snapshot.stopReason).toEqual({ + type: 'requires_action', + eventIds: ['sevt_1', 'sevt_2'], + }) + }) + + it('tolerates an unknown status and a missing body', () => { + expect(parseSessionSnapshot({ status: 'bogus' }).status).toBeUndefined() + expect(parseSessionSnapshot(null)).toEqual({}) + expect(parseSessionSnapshot(undefined)).toEqual({}) + }) +}) + +describe('session lifecycle calls', () => { + const originalFetch = global.fetch + afterEach(() => { + global.fetch = originalFetch + }) + + const captureFetch = (body: unknown = {}) => { + const spy = vi.fn(async () => Response.json(body)) as unknown as typeof fetch + global.fetch = spy + return spy as unknown as ReturnType + } + + it('updateSession posts title and metadata', async () => { + const spy = captureFetch({ status: 'idle', title: 'renamed' }) + await updateSession({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + title: 'renamed', + metadata: { slack_ts: '123' }, + }) + const [url, init] = spy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://api.anthropic.com/v1/sessions/sesn_1') + expect(init.method).toBe('POST') + expect(JSON.parse(init.body as string)).toEqual({ + title: 'renamed', + metadata: { slack_ts: '123' }, + }) + }) + + it('updateSession refuses a no-op update rather than sending an empty body', async () => { + captureFetch() + await expect(updateSession({ apiKey: 'sk-ant-fake', sessionId: 'sesn_1' })).rejects.toThrow( + /requires a title or metadata/ + ) + }) + + it('archiveSession POSTs the archive sub-resource', async () => { + const spy = captureFetch() + await archiveSession({ apiKey: 'sk-ant-fake', sessionId: 'sesn_1' }) + const [url, init] = spy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://api.anthropic.com/v1/sessions/sesn_1/archive') + expect(init.method).toBe('POST') + }) + + it('deleteSession issues a DELETE', async () => { + const spy = captureFetch() + await deleteSession({ apiKey: 'sk-ant-fake', sessionId: 'sesn_1' }) + const [url, init] = spy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://api.anthropic.com/v1/sessions/sesn_1') + expect(init.method).toBe('DELETE') + }) + + it('surfaces the status code and body when a call fails', async () => { + global.fetch = vi.fn( + async () => new Response('session is running', { status: 400 }) + ) as unknown as typeof fetch + await expect(archiveSession({ apiKey: 'sk-ant-fake', sessionId: 'sesn_1' })).rejects.toThrow( + /400.*session is running/ + ) + }) +}) + +describe('sendToolConfirmations', () => { + const originalFetch = global.fetch + afterEach(() => { + global.fetch = originalFetch + }) + + const capture = () => { + const spy = vi.fn(async () => Response.json({})) as unknown as typeof fetch + global.fetch = spy + return spy as unknown as ReturnType + } + + it('sends every confirmation in one request', async () => { + const spy = capture() + await sendToolConfirmations({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + confirmations: [ + { toolUseId: 'sevt_1', result: 'allow' }, + { toolUseId: 'sevt_2', result: 'allow' }, + ], + }) + expect(spy).toHaveBeenCalledTimes(1) + const [url, init] = spy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://api.anthropic.com/v1/sessions/sesn_1/events') + expect(JSON.parse(init.body as string)).toEqual({ + events: [ + { type: 'user.tool_confirmation', tool_use_id: 'sevt_1', result: 'allow' }, + { type: 'user.tool_confirmation', tool_use_id: 'sevt_2', result: 'allow' }, + ], + }) + }) + + it('uses deny_message on a denial and omits it on an allow', async () => { + const spy = capture() + await sendToolConfirmations({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + confirmations: [ + { toolUseId: 'sevt_1', result: 'deny', denyMessage: 'not the prod project' }, + { toolUseId: 'sevt_2', result: 'allow', denyMessage: 'ignored' }, + ], + }) + const [, init] = spy.mock.calls[0] as [string, RequestInit] + expect(JSON.parse(init.body as string).events).toEqual([ + { + type: 'user.tool_confirmation', + tool_use_id: 'sevt_1', + result: 'deny', + deny_message: 'not the prod project', + }, + { type: 'user.tool_confirmation', tool_use_id: 'sevt_2', result: 'allow' }, + ]) + }) +}) + +describe('resolvePendingToolGates', () => { + const originalFetch = global.fetch + afterEach(() => { + global.fetch = originalFetch + }) + + it('resolves ids to names in the order the API reported them', async () => { + global.fetch = vi.fn(async () => + Response.json({ + data: [ + { id: 'sevt_2', type: 'agent.mcp_tool_use', name: 'create_issue', input: { title: 'x' } }, + { id: 'sevt_1', type: 'agent.tool_use', name: 'bash', input: { command: 'ls' } }, + ], + next_page: null, + }) + ) as unknown as typeof fetch + + const gates = await resolvePendingToolGates({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + eventIds: ['sevt_1', 'sevt_2'], + }) + + expect(gates).toEqual([ + { + id: 'sevt_1', + eventType: 'agent.tool_use', + kind: 'confirmation', + name: 'bash', + input: { command: 'ls' }, + }, + { + id: 'sevt_2', + eventType: 'agent.mcp_tool_use', + kind: 'confirmation', + name: 'create_issue', + input: { title: 'x' }, + }, + ]) + }) + + it('filters the events request to tool-use types', async () => { + const spy = vi.fn(async () => + Response.json({ data: [], next_page: null }) + ) as unknown as typeof fetch + global.fetch = spy + + await resolvePendingToolGates({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + eventIds: ['sevt_1'], + }) + + const [url] = (spy as unknown as ReturnType).mock.calls[0] as [string] + const types = new URL(url).searchParams.getAll('types[]') + expect(types).toEqual(['agent.tool_use', 'agent.mcp_tool_use', 'agent.custom_tool_use']) + }) + + it('still returns the ids when enrichment fails — they alone can answer a gate', async () => { + global.fetch = vi.fn(async () => { + throw new Error('network down') + }) as unknown as typeof fetch + + const gates = await resolvePendingToolGates({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + eventIds: ['sevt_1', 'sevt_2'], + }) + expect(gates).toEqual([{ id: 'sevt_1' }, { id: 'sevt_2' }]) + }) + + it('labels a custom-tool gate as needing a custom tool result, not a confirmation', async () => { + // A confirmation cannot unblock a custom tool — the agent is waiting on the + // tool's actual output — so the kind must route callers to the right op. + global.fetch = vi.fn(async () => + Response.json({ + data: [{ id: 'sevt_9', type: 'agent.custom_tool_use', name: 'lookup_order' }], + next_page: null, + }) + ) as unknown as typeof fetch + + const gates = await resolvePendingToolGates({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + eventIds: ['sevt_9'], + }) + expect(gates[0]?.kind).toBe('custom_tool_result') + }) + + it('omits kind when the event could not be resolved', async () => { + global.fetch = vi.fn(async () => { + throw new Error('network down') + }) as unknown as typeof fetch + const gates = await resolvePendingToolGates({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + eventIds: ['sevt_1'], + }) + expect(gates[0]).toEqual({ id: 'sevt_1' }) + }) + + it('finds a gate that lives past the first page', async () => { + // Gates are the most RECENT tool calls. A read that capped instead of + // filtering would keep page 1 and miss exactly the events that matter. + let page = 0 + global.fetch = vi.fn(async () => { + const offset = page * 100 + page += 1 + const data = Array.from({ length: 100 }, (_, i) => ({ + id: `t${offset + i}`, + type: 'agent.tool_use', + name: `tool_${offset + i}`, + })) + return Response.json({ data, next_page: page < 4 ? `c${page}` : null }) + }) as unknown as typeof fetch + + const gates = await resolvePendingToolGates({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + eventIds: ['t399'], + }) + expect(gates).toEqual([ + { id: 't399', eventType: 'agent.tool_use', kind: 'confirmation', name: 'tool_399' }, + ]) + }) + + it('keeps paging when an entire page filters out', async () => { + let page = 0 + const spy = vi.fn(async () => { + page += 1 + // Page 1 holds nothing wanted; the target is only on page 2. + const data = + page === 1 + ? [{ id: 'other', type: 'agent.tool_use', name: 'noise' }] + : [{ id: 'want', type: 'agent.tool_use', name: 'target' }] + return Response.json({ data, next_page: page < 2 ? 'c1' : null }) + }) as unknown as typeof fetch + global.fetch = spy + + const gates = await resolvePendingToolGates({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + eventIds: ['want'], + }) + expect((spy as unknown as ReturnType).mock.calls).toHaveLength(2) + expect(gates[0]?.name).toBe('target') + }) + + it('stops paging as soon as every wanted id is found', async () => { + // The filter keeps `collected` tiny, so no cap can ever trip — without an + // explicit stop the walk runs to the end of the tool history for nothing. + let page = 0 + const spy = vi.fn(async () => { + page += 1 + return Response.json({ + data: [{ id: page === 1 ? 'want' : `other${page}`, type: 'agent.tool_use', name: 'x' }], + next_page: `c${page}`, // never null: only the stop condition ends this + }) + }) as unknown as typeof fetch + global.fetch = spy + + const gates = await resolvePendingToolGates({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + eventIds: ['want'], + }) + expect(gates).toHaveLength(1) + expect((spy as unknown as ReturnType).mock.calls).toHaveLength(1) + }) + + it('short-circuits with no ids', async () => { + const spy = vi.fn() as unknown as typeof fetch + global.fetch = spy + expect( + await resolvePendingToolGates({ apiKey: 'sk-ant-fake', sessionId: 'sesn_1', eventIds: [] }) + ).toEqual([]) + expect(spy).not.toHaveBeenCalled() + }) +}) + +describe('listSessionEvents — bounded reads', () => { + const originalFetch = global.fetch + afterEach(() => { + global.fetch = originalFetch + }) + + /** Emits `pages` pages of 100 chronologically-increasing events. */ + const pagedFetch = (pages: number) => { + let page = 0 + return vi.fn(async () => { + const offset = page * 100 + page += 1 + return Response.json({ + data: Array.from({ length: 100 }, (_, i) => ({ + id: `e${offset + i}`, + type: 'agent.message', + processed_at: new Date(Date.UTC(2026, 0, 1) + (offset + i) * 1000).toISOString(), + })), + next_page: page < pages ? `cursor-${page}` : null, + }) + }) as unknown as typeof fetch + } + + it('returns exactly maxItems, never a whole extra page', async () => { + global.fetch = pagedFetch(3) + const events = await listSessionEvents({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + maxItems: 250, + }) + expect(events).toHaveLength(250) + }) + + it('keeps the NEWEST events when capping, not the oldest', async () => { + // Ascending history: e0 (oldest) .. e249 (newest). A cap of 10 must return + // the last ten — capping the fetch instead would return e0..e9 and silently + // drop the agent's most recent reply, which is what callers read this for. + global.fetch = pagedFetch(3) + const events = await listSessionEvents({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + maxItems: 10, + }) + expect(events).toHaveLength(10) + expect(events[0]?.id).toBe('e290') + expect(events.at(-1)?.id).toBe('e299') + }) + + it('reports the untrimmed total so a full history is not mistaken for a tail', async () => { + // A history of exactly `maxItems` dropped nothing — `total === events.length` + // is what lets the caller tell that apart from a genuinely capped read. + global.fetch = pagedFetch(3) + const exact = await listSessionEventsPage({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + maxItems: 300, + }) + expect(exact.events).toHaveLength(300) + expect(exact.total).toBe(300) + + global.fetch = pagedFetch(3) + const capped = await listSessionEventsPage({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + maxItems: 120, + }) + expect(capped.events).toHaveLength(120) + expect(capped.total).toBe(300) + }) + + it('never returns the whole history for a zero or negative cap', async () => { + // `slice(-0)` is `slice(0)` — the entire array — so a zero cap must + // short-circuit rather than silently become an unbounded read. + global.fetch = pagedFetch(1) + const zero = await listSessionEventsPage({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + maxItems: 0, + }) + expect(zero.events).toHaveLength(0) + expect(zero.total).toBe(100) + + global.fetch = pagedFetch(1) + const negative = await listSessionEventsPage({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + maxItems: -5, + }) + expect(negative.events).toHaveLength(0) + }) + + it.each([0.5, 0.99, 0, -0.5, -5])( + 'never returns the whole history for the sub-integer cap %p', + async (maxItems) => { + // `slice` truncates its index toward zero, so any cap under 1 becomes + // `slice(-0)` — the entire array — unless it is floored first. + global.fetch = pagedFetch(1) + const res = await listSessionEventsPage({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + maxItems, + }) + expect(res.events).toHaveLength(0) + expect(res.total).toBe(100) + } + ) + + it('floors a fractional cap above 1 rather than widening it', async () => { + global.fetch = pagedFetch(1) + const res = await listSessionEventsPage({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + maxItems: 10.9, + }) + expect(res.events).toHaveLength(10) + }) + + it('returns the whole history when uncapped', async () => { + global.fetch = pagedFetch(3) + const events = await listSessionEvents({ apiKey: 'sk-ant-fake', sessionId: 'sesn_1' }) + expect(events).toHaveLength(300) + }) + + it('passes a types filter through as repeatable types[] params', async () => { + const spy = vi.fn(async () => + Response.json({ data: [], next_page: null }) + ) as unknown as typeof fetch + global.fetch = spy + await listSessionEvents({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + types: ['agent.message', ' agent.tool_use '], + }) + const [url] = (spy as unknown as ReturnType).mock.calls[0] as [string] + expect(new URL(url).searchParams.getAll('types[]')).toEqual(['agent.message', 'agent.tool_use']) + }) +}) + +describe('sendCustomToolResults', () => { + const originalFetch = global.fetch + afterEach(() => { + global.fetch = originalFetch + }) + + it('sends a user.custom_tool_result per pending call', async () => { + const spy = vi.fn(async () => Response.json({})) as unknown as typeof fetch + global.fetch = spy + await sendCustomToolResults({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + results: [{ customToolUseId: 'sevt_9', content: 'order #42 shipped', isError: false }], + }) + const [, init] = (spy as unknown as ReturnType).mock.calls[0] as [ + string, + RequestInit, + ] + expect(JSON.parse(init.body as string)).toEqual({ + events: [ + { + type: 'user.custom_tool_result', + custom_tool_use_id: 'sevt_9', + content: [{ type: 'text', text: 'order #42 shipped' }], + is_error: false, + }, + ], + }) + }) + + it('defaults is_error to false and honors an explicit failure', async () => { + const spy = vi.fn(async () => Response.json({})) as unknown as typeof fetch + global.fetch = spy + await sendCustomToolResults({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + results: [ + { customToolUseId: 'a', content: 'ok' }, + { customToolUseId: 'b', content: 'lookup failed', isError: true }, + ], + }) + const [, init] = (spy as unknown as ReturnType).mock.calls[0] as [ + string, + RequestInit, + ] + const events = JSON.parse(init.body as string).events + expect(events[0].is_error).toBe(false) + expect(events[1].is_error).toBe(true) + }) +}) diff --git a/apps/sim/lib/managed-agents/session-client.ts b/apps/sim/lib/managed-agents/session-client.ts index e03a6d3827e..aa3a6c7643e 100644 --- a/apps/sim/lib/managed-agents/session-client.ts +++ b/apps/sim/lib/managed-agents/session-client.ts @@ -31,7 +31,14 @@ export interface AnthropicSessionEvent { type?: string content?: Array<{ type: string; text?: string }> name?: string - stop_reason?: { type?: string } + /** Tool input, present on `agent.*_tool_use` events. */ + input?: unknown + /** + * On `session.status_idle`. When `type` is `requires_action`, `event_ids` + * lists the blocking tool-use event ids awaiting a `user.tool_confirmation` + * or `user.custom_tool_result`. + */ + stop_reason?: { type?: string; event_ids?: string[] } error?: { message?: string } message?: string /** Server-side record time; `null`/absent means still queued (handled after processed events). */ @@ -51,6 +58,14 @@ export interface SessionAuth { export interface CreateSessionInput extends SessionAuth { agentId: string environmentId: string + /** + * Seeds `initial_events` with a single `user.message`, starting the agent + * loop in the same call — the session is created directly in `running` + * instead of passing through `idle`. Only `user.message` and + * `user.define_outcome` are accepted there, and validation is all-or-nothing. + * https://platform.claude.com/docs/en/managed-agents/sessions + */ + initialMessage?: string /** * Environment execution model. Self-hosted environments reject the * `resources` array, so memory is routed via `metadata` and files are @@ -89,9 +104,21 @@ export type EnvironmentType = 'cloud' | 'self_hosted' /** Authoritative session status per `GET /v1/sessions/{id}`. */ export type SessionStatus = 'idle' | 'running' | 'rescheduling' | 'terminated' +/** Why an idle session stopped, per the session resource / idle event. */ +export interface SessionStopReason { + type?: string + /** Blocking tool-use event ids when `type` is `requires_action`. */ + eventIds?: string[] +} + export interface SessionSnapshot { status?: SessionStatus usage?: SessionUsage + /** Present once the session has stopped at least once. */ + stopReason?: SessionStopReason + /** Session metadata as stored on the Anthropic session. */ + metadata?: Record + title?: string } /** @@ -158,6 +185,15 @@ export function buildSessionCreatePayload(input: CreateSessionInput): Record 0) { payload.metadata = { ...input.sessionParameters } } + + // An empty/whitespace `initial_events` entry would be rejected, and an empty + // array is equivalent to omitting the field — so only seed a real message. + const initialMessage = input.initialMessage?.trim() + if (initialMessage) { + payload.initial_events = [ + { type: 'user.message', content: [{ type: 'text', text: initialMessage }] }, + ] + } return payload } @@ -200,7 +236,29 @@ interface UserInterruptEvent { type: 'user.interrupt' } -export type OutboundSessionEvent = UserMessageEvent | UserCustomToolResultEvent | UserInterruptEvent +/** + * Answers one `always_ask` permission gate. + * + * `tool_use_id` is the *event id* of the blocking `agent.tool_use` / + * `agent.mcp_tool_use` event (the ids listed in the idle event's + * `stop_reason.event_ids`) — NOT a `toolu_...` id. The denial reason field is + * `deny_message`, and it is only meaningful with `result: 'deny'`. + * https://platform.claude.com/docs/en/managed-agents/permission-policies + */ +interface UserToolConfirmationEvent { + type: 'user.tool_confirmation' + tool_use_id: string + result: ToolConfirmationResult + deny_message?: string +} + +export type ToolConfirmationResult = 'allow' | 'deny' + +export type OutboundSessionEvent = + | UserMessageEvent + | UserCustomToolResultEvent + | UserInterruptEvent + | UserToolConfirmationEvent /** POST /v1/sessions/{id}/events with a single `user.message`. */ export async function sendUserMessage( @@ -253,6 +311,157 @@ export async function interruptSession(input: { }) } +/** + * POST /v1/sessions/{id}/events with one `user.tool_confirmation` per blocking + * gate. Several confirmations may be sent in a single request, which is why + * this takes a list — answering all of a turn's gates at once avoids a partial + * resolve that leaves the session parked. + */ +export async function sendToolConfirmations( + input: SessionAuth & { + sessionId: string + confirmations: Array<{ + toolUseId: string + result: ToolConfirmationResult + denyMessage?: string + }> + } +): Promise { + const events: OutboundSessionEvent[] = input.confirmations.map((confirmation) => ({ + type: 'user.tool_confirmation', + tool_use_id: confirmation.toolUseId, + result: confirmation.result, + // `deny_message` is only meaningful on a denial; the API ignores it on an + // allow, but sending it would misrepresent the intent in the event history. + ...(confirmation.result === 'deny' && confirmation.denyMessage + ? { deny_message: confirmation.denyMessage } + : {}), + })) + await sendSessionEvents({ + apiKey: input.apiKey, + ...(input.signal ? { signal: input.signal } : {}), + sessionId: input.sessionId, + events, + }) +} + +/** + * How a blocking gate must be answered. + * + * `confirmation` — an `always_ask` permission gate on a server-executed tool; + * answered with `user.tool_confirmation` (allow/deny). + * `custom_tool_result` — a client-side custom tool the agent invoked; answered + * with `user.custom_tool_result` carrying the tool's actual output. Sending a + * confirmation for one of these does NOT unblock the session. + */ +export type PendingToolGateKind = 'confirmation' | 'custom_tool_result' + +/** A tool call blocking a session, resolved to its name/input. */ +export interface PendingToolGate { + /** Event id — pass this as `tool_use_id` / `custom_tool_use_id` when answering. */ + id: string + /** `agent.tool_use`, `agent.mcp_tool_use`, or `agent.custom_tool_use`. */ + eventType?: string + /** Which reply event unblocks this gate. Absent when the event could not be resolved. */ + kind?: PendingToolGateKind + name?: string + input?: unknown +} + +/** Maps a tool-use event type onto the reply event that unblocks it. */ +function gateKindFor(eventType: string | undefined): PendingToolGateKind | undefined { + if (eventType === 'agent.custom_tool_use') return 'custom_tool_result' + if (eventType === 'agent.tool_use' || eventType === 'agent.mcp_tool_use') return 'confirmation' + return undefined +} + +/** Event types that can block a session pending a client response. */ +const TOOL_USE_EVENT_TYPES = [ + 'agent.tool_use', + 'agent.mcp_tool_use', + 'agent.custom_tool_use', +] as const + +/** + * Resolves the blocking event ids on an idle `requires_action` session into + * named gates by cross-referencing the session's tool-use events. + * + * The ids alone are enough to answer a gate, so a failure to enrich is NOT an + * error — the ids are still returned, just without names. Callers get + * something actionable either way. + */ +export async function resolvePendingToolGates( + input: SessionAuth & { sessionId: string; eventIds: string[] } +): Promise { + const wanted = new Set(input.eventIds) + if (wanted.size === 0) return [] + let events: AnthropicSessionEvent[] = [] + try { + events = await listPaginated({ + apiKey: input.apiKey, + ...(input.signal ? { signal: input.signal } : {}), + path: `/v1/sessions/${input.sessionId}/events`, + searchParams: TOOL_USE_EVENT_TYPES.map((type): [string, string] => ['types[]', type]), + // Keep only the events actually being looked up rather than capping the + // read. A cap would retain the OLDEST page-order events, and blocking + // gates are by definition the most recent tool calls — exactly the ones a + // cap would drop. Filtering instead bounds memory to the id count while + // staying correct however the API orders its pages. + filter: (event) => Boolean(event.id && wanted.has(event.id)), + // Every wanted id is found at most once, so once the count matches there + // is nothing left to look for. Without this the filtered total never + // reaches any cap and the walk runs to the end of the tool history. + stopWhen: (found) => found.length >= wanted.size, + }) + } catch { + // Enrichment is best-effort — fall through to bare ids below. + } + const byId = new Map() + for (const event of events) { + if (event.id && wanted.has(event.id) && !byId.has(event.id)) byId.set(event.id, event) + } + // Preserve the API's `event_ids` order so the caller's prompts are stable. + return input.eventIds.map((id) => { + const event = byId.get(id) + const kind = gateKindFor(event?.type) + return { + id, + ...(event?.type ? { eventType: event.type } : {}), + ...(kind ? { kind } : {}), + ...(event?.name ? { name: event.name } : {}), + ...(event?.input !== undefined ? { input: event.input } : {}), + } + }) +} + +/** + * POST /v1/sessions/{id}/events with one `user.custom_tool_result` per pending + * custom-tool call. + * + * Custom tools are executed by the caller, not Anthropic, so a permission + * confirmation cannot unblock them — the agent is waiting for the tool's actual + * output (or an error). + */ +export async function sendCustomToolResults( + input: SessionAuth & { + sessionId: string + results: Array<{ customToolUseId: string; content: string; isError?: boolean }> + } +): Promise { + const events: OutboundSessionEvent[] = input.results.map((result) => ({ + type: 'user.custom_tool_result', + custom_tool_use_id: result.customToolUseId, + content: [{ type: 'text', text: result.content }], + is_error: result.isError ?? false, + })) + await sendSessionEvents({ + apiKey: input.apiKey, + ...(input.signal ? { signal: input.signal } : {}), + sessionId: input.sessionId, + events, + }) +} + /** GET /v1/sessions/{id}/events/stream — opens the SSE response. */ export async function openSessionStream( input: SessionAuth & { sessionId: string } @@ -285,7 +494,27 @@ interface AnthropicListPage { const MAX_LIST_PAGES = 1000 async function listPaginated( - input: SessionAuth & { path: string; beta?: string; maxItems?: number } + input: SessionAuth & { + path: string + beta?: string + maxItems?: number + /** Extra repeatable query pairs (e.g. `types[]` filters). */ + searchParams?: Array<[string, string]> + /** + * Applied per item as pages arrive, so only matches are retained. Use this + * instead of `maxItems` when the caller needs specific items rather than a + * prefix — a cap keeps whatever the API returned first, which is the oldest + * entries on a chronological endpoint. + */ + filter?: (item: T) => boolean + /** + * Checked after each page against everything collected so far. Lets a + * filtered read stop as soon as it has what it came for — otherwise + * `maxItems` never trips (the filtered total stays small) and the walk runs + * to the end of the history for nothing. + */ + stopWhen?: (collected: T[]) => boolean + } ): Promise { const collected: T[] = [] const maxItems = input.maxItems ?? 2000 @@ -295,6 +524,8 @@ async function listPaginated( for (let pageCount = 0; pageCount < MAX_LIST_PAGES && collected.length < maxItems; pageCount++) { const url = new URL(`${ANTHROPIC_API_BASE}${input.path}`) url.searchParams.set('limit', '100') + // `append`, not `set` — `types[]` is repeatable and each value must survive. + for (const [key, value] of input.searchParams ?? []) url.searchParams.append(key, value) if (page) url.searchParams.set('page', page) const resp = await fetch(url.toString(), { method: 'GET', @@ -307,11 +538,17 @@ async function listPaginated( } const body = (await resp.json()) as AnthropicListPage const items = Array.isArray(body.data) ? body.data : [] - collected.push(...items) + collected.push(...(input.filter ? items.filter(input.filter) : items)) + if (input.stopWhen?.(collected)) break + // Paging continues on the RAW page, not the filtered result: a page whose + // every item was filtered out is not the end of the list. if (!body.next_page || items.length === 0) break page = body.next_page } - return collected + // Pages arrive whole, so the last one can overshoot `maxItems` — trim to the + // exact cap the caller asked for. (`slice(0, Infinity)` is a no-op, so the + // unbounded default is unaffected.) + return collected.length > maxItems ? collected.slice(0, maxItems) : collected } /** @@ -322,12 +559,53 @@ async function listPaginated( * by a page cap. */ export async function listSessionEvents( - input: SessionAuth & { sessionId: string } + input: SessionAuth & { + sessionId: string + types?: string[] + /** + * Caps how many events are RETURNED. Defaults to unbounded, which is what + * the run loop's catch-up needs — it must reach the tail to see the terminal + * event. + * + * The cap keeps the MOST RECENT events, not the first ones the API happens + * to hand back. Capping the fetch instead would return the oldest slice of a + * long session and silently omit the agent's latest reply — the exact thing + * most callers are reading events for. Paging is therefore still exhaustive; + * the bound applies to the returned array. + */ + maxItems?: number + } ): Promise { + return (await listSessionEventsPage(input)).events +} + +/** An event read plus the size of the history it was taken from. */ +export interface SessionEventPage { + events: AnthropicSessionEvent[] + /** + * How many events the session actually has, before any cap. Compare against + * `events.length` to tell a capped read from a complete one — a history that + * happens to be exactly `maxItems` long has dropped nothing. + */ + total: number +} + +/** + * Same read as {@link listSessionEvents}, but also reports the untrimmed + * history size so callers can distinguish "this is a tail" from "this is + * everything, and it happens to be exactly the cap". + */ +export async function listSessionEventsPage( + input: SessionAuth & { sessionId: string; types?: string[]; maxItems?: number } +): Promise { + const types = (input.types ?? []).filter((type) => type.trim().length > 0) const events = await listPaginated({ apiKey: input.apiKey, signal: input.signal, path: `/v1/sessions/${input.sessionId}/events`, + ...(types.length > 0 + ? { searchParams: types.map((type): [string, string] => ['types[]', type.trim()]) } + : {}), maxItems: Number.POSITIVE_INFINITY, }) // The list endpoint's page order is not guaranteed chronological, so order by @@ -335,7 +613,23 @@ export async function listSessionEvents( // loop depends on ascending order both to accumulate assistant text in order // and to read the latest lifecycle event. Still-queued events (null // `processed_at`) are processed after everything else, so they sort last. - return events.sort((a, b) => parseProcessedAt(a.processed_at) - parseProcessedAt(b.processed_at)) + const ordered = events.sort( + (a, b) => parseProcessedAt(a.processed_at) - parseProcessedAt(b.processed_at) + ) + const total = ordered.length + if (input.maxItems === undefined || Number.isNaN(input.maxItems)) { + return { events: ordered, total } + } + // Floor first: `slice` truncates its index toward zero, so a cap between 0 + // and 1 would become `slice(-0)` — i.e. `slice(0)` — and hand back the ENTIRE + // history for what the caller asked to be the tightest possible bound. Doing + // it here means no caller can hit that, whatever it passes. + const maxItems = Math.floor(input.maxItems) + if (maxItems <= 0) return { events: [], total } + if (total <= maxItems) return { events: ordered, total } + // Slice AFTER ordering so the cap is "the newest N", independent of the order + // the API returned pages in. + return { events: ordered.slice(-maxItems), total } } /** Epoch millis for a `processed_at`, or +Infinity when absent/queued/unparseable (sorts last). */ @@ -401,25 +695,153 @@ export async function getSession( signal: input.signal, }) if (!resp.ok) return null - const body = (await resp.json()) as { - status?: unknown - usage?: { input_tokens?: unknown; output_tokens?: unknown } - } - const snapshot: SessionSnapshot = {} - if ( - body.status === 'idle' || - body.status === 'running' || - body.status === 'rescheduling' || - body.status === 'terminated' - ) { - snapshot.status = body.status - } - const usage: SessionUsage = {} - if (typeof body.usage?.input_tokens === 'number') usage.inputTokens = body.usage.input_tokens - if (typeof body.usage?.output_tokens === 'number') usage.outputTokens = body.usage.output_tokens - if (usage.inputTokens !== undefined || usage.outputTokens !== undefined) snapshot.usage = usage - return snapshot + return parseSessionSnapshot(await resp.json()) } catch { return null } } + +/** + * Maps a raw `/v1/sessions/{id}` body onto {@link SessionSnapshot}. Split out + * so it is directly unit-testable and shared by the best-effort `getSession` + * and the strict `retrieveSession`. + */ +export function parseSessionSnapshot(raw: unknown): SessionSnapshot { + const body = (raw ?? {}) as { + status?: unknown + title?: unknown + metadata?: unknown + usage?: { input_tokens?: unknown; output_tokens?: unknown } + stop_reason?: { type?: unknown; event_ids?: unknown } + } + const snapshot: SessionSnapshot = {} + if ( + body.status === 'idle' || + body.status === 'running' || + body.status === 'rescheduling' || + body.status === 'terminated' + ) { + snapshot.status = body.status + } + const usage: SessionUsage = {} + if (typeof body.usage?.input_tokens === 'number') usage.inputTokens = body.usage.input_tokens + if (typeof body.usage?.output_tokens === 'number') usage.outputTokens = body.usage.output_tokens + if (usage.inputTokens !== undefined || usage.outputTokens !== undefined) snapshot.usage = usage + + if (body.stop_reason && typeof body.stop_reason === 'object') { + const stopReason: SessionStopReason = {} + if (typeof body.stop_reason.type === 'string') stopReason.type = body.stop_reason.type + if (Array.isArray(body.stop_reason.event_ids)) { + const eventIds = body.stop_reason.event_ids.filter( + (id): id is string => typeof id === 'string' && id.length > 0 + ) + if (eventIds.length > 0) stopReason.eventIds = eventIds + } + if (stopReason.type !== undefined || stopReason.eventIds !== undefined) { + snapshot.stopReason = stopReason + } + } + + if (typeof body.title === 'string') snapshot.title = body.title + if (body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata)) { + const metadata: Record = {} + for (const [key, value] of Object.entries(body.metadata as Record)) { + if (typeof value === 'string') metadata[key] = value + else if (typeof value === 'number' || typeof value === 'boolean') + metadata[key] = String(value) + } + if (Object.keys(metadata).length > 0) snapshot.metadata = metadata + } + return snapshot +} + +/** + * GET /v1/sessions/{id}, but STRICT — throws on a non-2xx instead of returning + * `null`. The block's Get Session operation reports a missing/unauthorized + * session as a block error rather than silently yielding an empty result; + * {@link getSession} keeps its best-effort contract for the run loop. + */ +export async function retrieveSession( + input: SessionAuth & { sessionId: string } +): Promise { + const resp = await fetch(`${ANTHROPIC_API_BASE}/v1/sessions/${input.sessionId}`, { + method: 'GET', + headers: managedAgentsHeaders(input.apiKey), + signal: input.signal, + }) + if (!resp.ok) { + const detail = await resp.text().catch(() => '') + throw new Error(`Anthropic sessions.get failed (${resp.status}): ${detail.slice(0, 400)}`) + } + return parseSessionSnapshot(await resp.json()) +} + +/** + * POST /v1/sessions/{id} — updates session `title` and/or `metadata`. + * + * `metadata` is a FULL REPLACEMENT of the stored map, matching the API's + * replace semantics; callers that want to merge must read the session first. + * The session must be `idle` for agent-config updates; title/metadata updates + * are not gated that way. + * https://platform.claude.com/docs/en/managed-agents/session-operations + */ +export async function updateSession( + input: SessionAuth & { + sessionId: string + title?: string + metadata?: Record + } +): Promise { + const payload: Record = {} + if (input.title !== undefined) payload.title = input.title + if (input.metadata !== undefined) payload.metadata = input.metadata + if (Object.keys(payload).length === 0) { + throw new Error('Update session requires a title or metadata to change.') + } + const resp = await fetch(`${ANTHROPIC_API_BASE}/v1/sessions/${input.sessionId}`, { + method: 'POST', + headers: managedAgentsHeaders(input.apiKey, { json: true }), + body: JSON.stringify(payload), + signal: input.signal, + }) + if (!resp.ok) { + const detail = await resp.text().catch(() => '') + throw new Error(`Anthropic sessions.update failed (${resp.status}): ${detail.slice(0, 400)}`) + } + return parseSessionSnapshot(await resp.json().catch(() => ({}))) +} + +/** + * POST /v1/sessions/{id}/archive — makes the session read-only while + * preserving its history. A `running` session cannot be archived (interrupt + * it first), and archiving is NOT reversible. + */ +export async function archiveSession(input: SessionAuth & { sessionId: string }): Promise { + const resp = await fetch(`${ANTHROPIC_API_BASE}/v1/sessions/${input.sessionId}/archive`, { + method: 'POST', + headers: managedAgentsHeaders(input.apiKey), + signal: input.signal, + }) + if (!resp.ok) { + const detail = await resp.text().catch(() => '') + throw new Error(`Anthropic sessions.archive failed (${resp.status}): ${detail.slice(0, 400)}`) + } +} + +/** + * DELETE /v1/sessions/{id} — permanently removes the session record, its + * events, and its sandbox. A `running` session cannot be deleted (interrupt it + * first). Files, memory stores, vaults, skills, environments, and agents are + * independent resources and are NOT affected. + */ +export async function deleteSession(input: SessionAuth & { sessionId: string }): Promise { + const resp = await fetch(`${ANTHROPIC_API_BASE}/v1/sessions/${input.sessionId}`, { + method: 'DELETE', + headers: managedAgentsHeaders(input.apiKey), + signal: input.signal, + }) + if (!resp.ok) { + const detail = await resp.text().catch(() => '') + throw new Error(`Anthropic sessions.delete failed (${resp.status}): ${detail.slice(0, 400)}`) + } +} diff --git a/apps/sim/lib/managed-agents/wire-shapes.test.ts b/apps/sim/lib/managed-agents/wire-shapes.test.ts new file mode 100644 index 00000000000..69fd77b137d --- /dev/null +++ b/apps/sim/lib/managed-agents/wire-shapes.test.ts @@ -0,0 +1,131 @@ +/** + * @vitest-environment node + * + * Pins the exact HTTP shape of every Managed Agents call: method, URL, and the + * beta header each endpoint family requires. These are the details that cannot + * be caught by types or by the payload-builder tests, and that silently break + * if someone "tidies" a path or shares a header across endpoint families. + * + * Verified against https://platform.claude.com/docs/en/managed-agents/ + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_MEMORY_BETA, + archiveSession, + createSession, + deleteSession, + getEnvironmentType, + listSessionEvents, + MANAGED_AGENTS_BETA, + managedAgentsList, + openSessionStream, + retrieveSession, + sendCustomToolResults, + sendSessionEvents, + sendToolConfirmations, + updateSession, +} from '@/lib/managed-agents/session-client' + +const originalFetch = global.fetch +afterEach(() => { + global.fetch = originalFetch +}) + +const spyOn = (body: unknown = {}) => { + const spy = vi.fn(async () => Response.json(body)) as unknown as typeof fetch + global.fetch = spy + return spy as unknown as ReturnType +} + +const call = (spy: ReturnType) => { + const [url, init] = spy.mock.calls[0] as [string, RequestInit] + const headers = init.headers as Record + return { url: url.split('?')[0], method: init.method, headers } +} + +const AUTH = { apiKey: 'sk-ant-fake' } +const S = { ...AUTH, sessionId: 'sesn_1' } +const BASE = 'https://api.anthropic.com' + +describe('Managed Agents wire shapes', () => { + it.each([ + [ + 'createSession', + () => createSession({ ...AUTH, agentId: 'a', environmentId: 'e' }), + 'POST', + `${BASE}/v1/sessions`, + ], + ['retrieveSession', () => retrieveSession(S), 'GET', `${BASE}/v1/sessions/sesn_1`], + [ + 'updateSession', + () => updateSession({ ...S, title: 't' }), + 'POST', + `${BASE}/v1/sessions/sesn_1`, + ], + ['deleteSession', () => deleteSession(S), 'DELETE', `${BASE}/v1/sessions/sesn_1`], + ['archiveSession', () => archiveSession(S), 'POST', `${BASE}/v1/sessions/sesn_1/archive`], + ['listSessionEvents', () => listSessionEvents(S), 'GET', `${BASE}/v1/sessions/sesn_1/events`], + [ + 'sendSessionEvents', + () => sendSessionEvents({ ...S, events: [{ type: 'user.interrupt' }] }), + 'POST', + `${BASE}/v1/sessions/sesn_1/events`, + ], + [ + 'sendToolConfirmations', + () => sendToolConfirmations({ ...S, confirmations: [{ toolUseId: 'x', result: 'allow' }] }), + 'POST', + `${BASE}/v1/sessions/sesn_1/events`, + ], + [ + 'sendCustomToolResults', + () => sendCustomToolResults({ ...S, results: [{ customToolUseId: 'x', content: 'y' }] }), + 'POST', + `${BASE}/v1/sessions/sesn_1/events`, + ], + [ + 'getEnvironmentType', + () => getEnvironmentType({ ...AUTH, environmentId: 'env_1' }), + 'GET', + `${BASE}/v1/environments/env_1`, + ], + ])('%s hits %s %s with the managed-agents beta', async (_name, run, method, url) => { + const spy = spyOn({ id: 'sesn_1', config: { type: 'cloud' } }) + await run() + const c = call(spy) + expect(c.method).toBe(method) + expect(c.url).toBe(url) + expect(c.headers['anthropic-beta']).toBe(MANAGED_AGENTS_BETA) + expect(c.headers['anthropic-version']).toBe('2023-06-01') + expect(c.headers['x-api-key']).toBe('sk-ant-fake') + }) + + it('opens the event stream as SSE', async () => { + const spy = spyOn() + await openSessionStream(S) + const c = call(spy) + expect(c.method).toBe('GET') + expect(c.url).toBe(`${BASE}/v1/sessions/sesn_1/events/stream`) + expect(c.headers.accept).toBe('text/event-stream') + }) + + it('sends the SEPARATE memory beta on memory-store reads, never the managed-agents one', async () => { + // Combining the two headers on one request is a documented 400, so the + // memory-store family must carry its own and only its own. + const spy = spyOn({ data: [], next_page: null }) + await managedAgentsList({ ...AUTH, path: '/v1/memory_stores', beta: AGENT_MEMORY_BETA }) + const c = call(spy) + expect(c.headers['anthropic-beta']).toBe(AGENT_MEMORY_BETA) + expect(AGENT_MEMORY_BETA).not.toBe(MANAGED_AGENTS_BETA) + }) + + it('sets content-type only on requests that carry a body', async () => { + const post = spyOn({ id: 'sesn_1' }) + await createSession({ ...AUTH, agentId: 'a', environmentId: 'e' }) + expect(call(post).headers['content-type']).toBe('application/json') + + const get = spyOn({ status: 'idle' }) + await retrieveSession(S) + expect(call(get).headers['content-type']).toBeUndefined() + }) +}) diff --git a/apps/sim/tools/managed_agent/archive_session.ts b/apps/sim/tools/managed_agent/archive_session.ts new file mode 100644 index 00000000000..396fe7197c9 --- /dev/null +++ b/apps/sim/tools/managed_agent/archive_session.ts @@ -0,0 +1,66 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { archiveSession } from '@/lib/managed-agents/session-client' +import { + ACCESS_TOKEN_PARAM, + CREDENTIAL_PARAM, + resolveSessionTarget, + SESSION_ID_PARAM, + UNUSED_REQUEST, +} from '@/tools/managed_agent/shared' +import type { + ManagedAgentArchiveSessionParams, + ManagedAgentArchiveSessionResponse, +} from '@/tools/managed_agent/types' +import type { ToolConfig } from '@/tools/types' + +/** + * Archives a session — it becomes read-only but keeps its full history. + * + * This is the cleanup step a long-lived integration needs: without it every + * run leaves a live session behind in the Claude workspace forever. Archiving + * is NOT reversible, and a `running` session is rejected — interrupt it first. + */ +export const managedAgentArchiveSessionTool: ToolConfig< + ManagedAgentArchiveSessionParams, + ManagedAgentArchiveSessionResponse +> = { + id: 'managed_agent_archive_session', + name: 'Managed Agent Archive Session', + description: 'Archive a Managed Agent session, preserving its history. Not reversible.', + version: '1.0.0', + + params: { + credential: CREDENTIAL_PARAM, + accessToken: ACCESS_TOKEN_PARAM, + sessionId: SESSION_ID_PARAM, + }, + + request: UNUSED_REQUEST, + + directExecution: async (params, signal): Promise => { + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: { sessionId: '', archived: false }, error: target.error } + } + + try { + await archiveSession({ + apiKey: target.apiKey, + sessionId: target.sessionId, + ...(signal ? { signal } : {}), + }) + return { success: true, output: { sessionId: target.sessionId, archived: true } } + } catch (error) { + return { + success: false, + output: { sessionId: target.sessionId, archived: false }, + error: getErrorMessage(error, 'Failed to archive Managed Agent session'), + } + } + }, + + outputs: { + sessionId: { type: 'string', description: 'The session that was archived.' }, + archived: { type: 'boolean', description: 'True when the archive was accepted.' }, + }, +} diff --git a/apps/sim/tools/managed_agent/create_session.ts b/apps/sim/tools/managed_agent/create_session.ts new file mode 100644 index 00000000000..72ef392d962 --- /dev/null +++ b/apps/sim/tools/managed_agent/create_session.ts @@ -0,0 +1,201 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { + type CreateSessionInput, + createSession, + getEnvironmentType, +} from '@/lib/managed-agents/session-client' +import { + isTruthyAck, + normalizeFiles, + normalizeMemoryAccess, + normalizeSessionParameters, + normalizeStringList, +} from '@/tools/managed_agent/normalizers' +import { ACCESS_TOKEN_PARAM, CREDENTIAL_PARAM, UNUSED_REQUEST } from '@/tools/managed_agent/shared' +import type { + ManagedAgentCreateSessionParams, + ManagedAgentCreateSessionResponse, +} from '@/tools/managed_agent/types' +import type { ToolConfig } from '@/tools/types' + +/** + * Creates a Managed Agent session and returns its id WITHOUT waiting for the + * agent to finish. + * + * This is the non-blocking counterpart to `managed_agent_run_session`: it makes + * the session a durable handle a later workflow run can address (via + * `managed_agent_send_message` / `..._get_session`), which is what a + * conversational or webhook-driven integration needs. Supplying a first message + * seeds `initial_events`, so create-and-start is a single API call. + */ +export const managedAgentCreateSessionTool: ToolConfig< + ManagedAgentCreateSessionParams, + ManagedAgentCreateSessionResponse +> = { + id: 'managed_agent_create_session', + name: 'Managed Agent Create Session', + description: + 'Create a Claude Platform Managed Agent session and return its id without waiting for a reply.', + version: '1.0.0', + + params: { + credential: CREDENTIAL_PARAM, + accessToken: ACCESS_TOKEN_PARAM, + agent: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Managed-agent id inside the linked Claude workspace.', + }, + environment: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Environment id inside the linked Claude workspace.', + }, + environmentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: "Environment execution model hint ('cloud' | 'self_hosted').", + }, + userMessage: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional first message; seeds initial_events and starts the agent immediately.', + }, + vaults: { + type: 'array', + required: false, + visibility: 'user-only', + description: 'Zero or more vault ids for MCP tool auth.', + }, + vaultsAck: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Acknowledgement that the author may use the attached vaults.', + }, + memoryStoreId: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Optional Agent Memory Store id.', + }, + memoryAccess: { + type: 'string', + required: false, + visibility: 'user-only', + description: "Memory store access mode: 'read_write' (default) or 'read_only'.", + }, + memoryInstructions: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Per-attachment guidance for how the agent should use the memory store.', + }, + files: { + type: 'array', + required: false, + visibility: 'user-only', + description: 'File attachments (cloud envs only), as [{fileId, mountPath?}].', + }, + sessionParameters: { + type: 'object', + required: false, + visibility: 'user-only', + description: 'Key/value session metadata forwarded to the session.', + }, + }, + + request: UNUSED_REQUEST, + + directExecution: async (params, signal): Promise => { + const apiKey = params.accessToken + if (!apiKey) { + return { + success: false, + output: { sessionId: '', started: false }, + error: 'No Claude Platform credential is selected, or it could not be resolved.', + } + } + + const agentId = params.agent?.trim() + const environmentId = params.environment?.trim() + if (!agentId || !environmentId) { + return { + success: false, + output: { sessionId: '', started: false }, + error: 'An agent and an environment are required.', + } + } + + const vaultIds = normalizeStringList(params.vaults) + if (vaultIds.length > 0 && !isTruthyAck(params.vaultsAck)) { + return { + success: false, + output: { sessionId: '', started: false }, + error: + 'Vault authorization is required — check the "I am authorized to use these vaults" acknowledgement on the block, or remove the selected vault(s).', + } + } + + const files = normalizeFiles(params.files) + const sessionParameters = normalizeSessionParameters(params.sessionParameters) + const memoryStoreId = params.memoryStoreId?.trim() || undefined + const memoryAccess = normalizeMemoryAccess(params.memoryAccess) + const memoryInstructions = params.memoryInstructions?.trim() || undefined + const initialMessage = (params.userMessage ?? '').toString().trim() || undefined + + const workflowId = params._context?.workflowId?.trim() + const title = workflowId ? `Sim workflow ${workflowId}` : undefined + + // Self-hosted environments reject `resources`, so the payload must know the + // execution model. The API is authoritative; the block's hint is a fallback. + const hinted = + params.environmentType === 'self_hosted' || params.environmentType === 'cloud' + ? params.environmentType + : undefined + const environmentType = + (await getEnvironmentType({ apiKey, environmentId, ...(signal ? { signal } : {}) })) ?? hinted + + const createInput: CreateSessionInput = { + apiKey, + agentId, + environmentId, + ...(environmentType ? { environmentType } : {}), + ...(title ? { title } : {}), + ...(vaultIds.length > 0 ? { vaultIds } : {}), + ...(memoryStoreId ? { memoryStoreId } : {}), + ...(memoryStoreId && memoryAccess ? { memoryAccess } : {}), + ...(memoryStoreId && memoryInstructions ? { memoryInstructions } : {}), + ...(files.length > 0 ? { files } : {}), + ...(sessionParameters ? { sessionParameters } : {}), + ...(initialMessage ? { initialMessage } : {}), + ...(signal ? { signal } : {}), + } + + try { + const session = await createSession(createInput) + return { + success: true, + output: { sessionId: session.id, started: Boolean(initialMessage) }, + } + } catch (error) { + return { + success: false, + output: { sessionId: '', started: false }, + error: getErrorMessage(error, 'Failed to create Managed Agent session'), + } + } + }, + + outputs: { + sessionId: { type: 'string', description: 'Anthropic session id (sesn_...).' }, + started: { + type: 'boolean', + description: 'True when a first message was seeded, so the agent is already running.', + }, + }, +} diff --git a/apps/sim/tools/managed_agent/delete_session.ts b/apps/sim/tools/managed_agent/delete_session.ts new file mode 100644 index 00000000000..d50fed72302 --- /dev/null +++ b/apps/sim/tools/managed_agent/delete_session.ts @@ -0,0 +1,68 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { deleteSession } from '@/lib/managed-agents/session-client' +import { + ACCESS_TOKEN_PARAM, + CREDENTIAL_PARAM, + resolveSessionTarget, + SESSION_ID_PARAM, + UNUSED_REQUEST, +} from '@/tools/managed_agent/shared' +import type { + ManagedAgentDeleteSessionParams, + ManagedAgentDeleteSessionResponse, +} from '@/tools/managed_agent/types' +import type { ToolConfig } from '@/tools/types' + +/** + * Permanently deletes a session, its event history, and its sandbox. + * + * Files, memory stores, vaults, skills, environments, and agents are separate + * resources and are NOT affected. A `running` session is rejected — interrupt + * it first. Prefer archiving when the transcript still has value; this is the + * right choice when the session held sensitive input that should not persist. + */ +export const managedAgentDeleteSessionTool: ToolConfig< + ManagedAgentDeleteSessionParams, + ManagedAgentDeleteSessionResponse +> = { + id: 'managed_agent_delete_session', + name: 'Managed Agent Delete Session', + description: + 'Permanently delete a Managed Agent session, its events, and its sandbox. Not reversible.', + version: '1.0.0', + + params: { + credential: CREDENTIAL_PARAM, + accessToken: ACCESS_TOKEN_PARAM, + sessionId: SESSION_ID_PARAM, + }, + + request: UNUSED_REQUEST, + + directExecution: async (params, signal): Promise => { + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: { sessionId: '', deleted: false }, error: target.error } + } + + try { + await deleteSession({ + apiKey: target.apiKey, + sessionId: target.sessionId, + ...(signal ? { signal } : {}), + }) + return { success: true, output: { sessionId: target.sessionId, deleted: true } } + } catch (error) { + return { + success: false, + output: { sessionId: target.sessionId, deleted: false }, + error: getErrorMessage(error, 'Failed to delete Managed Agent session'), + } + } + }, + + outputs: { + sessionId: { type: 'string', description: 'The session that was deleted.' }, + deleted: { type: 'boolean', description: 'True when the delete was accepted.' }, + }, +} diff --git a/apps/sim/tools/managed_agent/get_session.ts b/apps/sim/tools/managed_agent/get_session.ts new file mode 100644 index 00000000000..ebca831595f --- /dev/null +++ b/apps/sim/tools/managed_agent/get_session.ts @@ -0,0 +1,157 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { resolvePendingToolGates, retrieveSession } from '@/lib/managed-agents/session-client' +import { + ACCESS_TOKEN_PARAM, + CREDENTIAL_PARAM, + resolveSessionTarget, + SESSION_ID_PARAM, + UNUSED_REQUEST, +} from '@/tools/managed_agent/shared' +import type { + ManagedAgentGetSessionParams, + ManagedAgentGetSessionResponse, + ManagedAgentPendingTool, +} from '@/tools/managed_agent/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ManagedAgentGetSession') + +/** `stop_reason.type` meaning the session is parked awaiting a client response. */ +const REQUIRES_ACTION = 'requires_action' + +/** + * Reads a Managed Agent session's current state, and — when it is blocked on an + * `always_ask` permission gate — resolves the blocking tool calls to their + * names and inputs. + * + * That enrichment is the point of this tool. A session that stops with + * `stop_reason.type === 'requires_action'` waits INDEFINITELY for a + * `user.tool_confirmation`, so a workflow that cannot see which tools are + * pending has no way to build an approval prompt and the session hangs. The + * blocking event ids come straight from `stop_reason.event_ids`; the tool + * names are cross-referenced from the session's tool-use events. + */ +export const managedAgentGetSessionTool: ToolConfig< + ManagedAgentGetSessionParams, + ManagedAgentGetSessionResponse +> = { + id: 'managed_agent_get_session', + name: 'Managed Agent Get Session', + description: + 'Read a Managed Agent session: status, stop reason, token usage, metadata, and any tool calls awaiting approval.', + version: '1.0.0', + + params: { + credential: CREDENTIAL_PARAM, + accessToken: ACCESS_TOKEN_PARAM, + sessionId: SESSION_ID_PARAM, + }, + + request: UNUSED_REQUEST, + + directExecution: async (params, signal): Promise => { + const emptyOutput = { + sessionId: '', + status: '', + requiresAction: false, + pendingTools: [] as ManagedAgentPendingTool[], + } + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: emptyOutput, error: target.error } + } + + try { + const snapshot = await retrieveSession({ + apiKey: target.apiKey, + sessionId: target.sessionId, + ...(signal ? { signal } : {}), + }) + + const requiresAction = snapshot.stopReason?.type === REQUIRES_ACTION + const eventIds = snapshot.stopReason?.eventIds ?? [] + // Only pay for the events call when the session is actually blocked. + const pendingTools = + requiresAction && eventIds.length > 0 + ? await resolvePendingToolGates({ + apiKey: target.apiKey, + sessionId: target.sessionId, + eventIds, + ...(signal ? { signal } : {}), + }) + : [] + + // A blocked session that names no blocking events is an anomaly: it waits + // indefinitely, but nothing here can say for what. `requiresAction` stays + // true because that is the truth — reporting false would tell a workflow + // the session is fine while it is parked forever — so log it instead, so + // the dead end is visible rather than silent. + if (requiresAction && pendingTools.length === 0) { + logger.warn('Managed Agent session requires action but reported no blocking event ids', { + sessionId: target.sessionId, + }) + } + + return { + success: true, + output: { + sessionId: target.sessionId, + status: snapshot.status ?? '', + ...(snapshot.stopReason?.type ? { stopReason: snapshot.stopReason.type } : {}), + requiresAction, + pendingTools, + ...(snapshot.metadata ? { metadata: snapshot.metadata } : {}), + ...(snapshot.title ? { title: snapshot.title } : {}), + ...(snapshot.usage?.inputTokens !== undefined + ? { inputTokens: snapshot.usage.inputTokens } + : {}), + ...(snapshot.usage?.outputTokens !== undefined + ? { outputTokens: snapshot.usage.outputTokens } + : {}), + }, + } + } catch (error) { + return { + success: false, + output: { ...emptyOutput, sessionId: target.sessionId }, + error: getErrorMessage(error, 'Failed to read Managed Agent session'), + } + } + }, + + outputs: { + sessionId: { type: 'string', description: 'The session that was read.' }, + status: { + type: 'string', + description: "Session status — 'idle', 'running', 'rescheduling', or 'terminated'.", + }, + stopReason: { + type: 'string', + description: "Why the session last stopped, e.g. 'end_turn' or 'requires_action'.", + optional: true, + }, + requiresAction: { + type: 'boolean', + description: + 'True when the session is waiting on a tool confirmation or custom tool result. If this is true while pendingTools is empty, the session is blocked but the API named no blocking events — surface it rather than treating the session as done.', + }, + pendingTools: { + type: 'json', + description: + "Blocking tool calls — [{id, eventType, kind, name, input}]. Route by kind: 'confirmation' ids go to Respond To Tool Confirmation, 'custom_tool_result' ids go to Respond To Custom Tool.", + }, + metadata: { type: 'json', description: 'Session metadata.', optional: true }, + title: { type: 'string', description: 'Session title.', optional: true }, + inputTokens: { + type: 'number', + description: 'Cumulative input tokens.', + optional: true, + }, + outputTokens: { + type: 'number', + description: 'Cumulative output tokens.', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/managed_agent/index.ts b/apps/sim/tools/managed_agent/index.ts index 2b2a3212ada..a4e62fad19f 100644 --- a/apps/sim/tools/managed_agent/index.ts +++ b/apps/sim/tools/managed_agent/index.ts @@ -1,2 +1,12 @@ +export { managedAgentArchiveSessionTool } from './archive_session' +export { managedAgentCreateSessionTool } from './create_session' +export { managedAgentDeleteSessionTool } from './delete_session' +export { managedAgentGetSessionTool } from './get_session' +export { managedAgentInterruptSessionTool } from './interrupt_session' +export { managedAgentListEventsTool } from './list_events' +export { managedAgentRespondCustomToolTool } from './respond_custom_tool' +export { managedAgentRespondToolConfirmationTool } from './respond_tool_confirmation' export { managedAgentRunSessionTool } from './run_session' +export { managedAgentSendMessageTool } from './send_message' export * from './types' +export { managedAgentUpdateSessionTool } from './update_session' diff --git a/apps/sim/tools/managed_agent/interrupt_session.ts b/apps/sim/tools/managed_agent/interrupt_session.ts new file mode 100644 index 00000000000..0fd536e8225 --- /dev/null +++ b/apps/sim/tools/managed_agent/interrupt_session.ts @@ -0,0 +1,80 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { sendSessionEvents } from '@/lib/managed-agents/session-client' +import { + ACCESS_TOKEN_PARAM, + CREDENTIAL_PARAM, + resolveSessionTarget, + SESSION_ID_PARAM, + UNUSED_REQUEST, +} from '@/tools/managed_agent/shared' +import type { + ManagedAgentInterruptSessionParams, + ManagedAgentInterruptSessionResponse, +} from '@/tools/managed_agent/types' +import type { ToolConfig } from '@/tools/types' + +/** + * Upper bound on the interrupt request itself. Interrupting is the prerequisite + * for archiving or deleting a running session, so it must fail fast and visibly + * rather than hang on a stalled connection. + */ +const INTERRUPT_TIMEOUT_MS = 15_000 + +/** + * Stops a running session at its next safe boundary. + * + * The interrupt jumps ahead of any queued user events, and the session stays + * usable afterwards — send another message to carry on. This is also the + * prerequisite for archiving or deleting a session that is still `running`, + * since both of those reject a running session. + */ +export const managedAgentInterruptSessionTool: ToolConfig< + ManagedAgentInterruptSessionParams, + ManagedAgentInterruptSessionResponse +> = { + id: 'managed_agent_interrupt_session', + name: 'Managed Agent Interrupt Session', + description: 'Stop a running Managed Agent session; it stays usable afterwards.', + version: '1.0.0', + + params: { + credential: CREDENTIAL_PARAM, + accessToken: ACCESS_TOKEN_PARAM, + sessionId: SESSION_ID_PARAM, + }, + + request: UNUSED_REQUEST, + + directExecution: async (params, signal): Promise => { + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: { sessionId: '', interrupted: false }, error: target.error } + } + + try { + await sendSessionEvents({ + apiKey: target.apiKey, + sessionId: target.sessionId, + events: [{ type: 'user.interrupt' }], + // Bounded so a stalled connection can't hang the operation. The + // workflow's own signal still cancels earlier when present; `any` + // resolves on whichever fires first. + signal: signal + ? AbortSignal.any([signal, AbortSignal.timeout(INTERRUPT_TIMEOUT_MS)]) + : AbortSignal.timeout(INTERRUPT_TIMEOUT_MS), + }) + return { success: true, output: { sessionId: target.sessionId, interrupted: true } } + } catch (error) { + return { + success: false, + output: { sessionId: target.sessionId, interrupted: false }, + error: getErrorMessage(error, 'Failed to interrupt Managed Agent session'), + } + } + }, + + outputs: { + sessionId: { type: 'string', description: 'The session that was interrupted.' }, + interrupted: { type: 'boolean', description: 'True when the interrupt was accepted.' }, + }, +} diff --git a/apps/sim/tools/managed_agent/list_events.test.ts b/apps/sim/tools/managed_agent/list_events.test.ts new file mode 100644 index 00000000000..a0f9d88e0d7 --- /dev/null +++ b/apps/sim/tools/managed_agent/list_events.test.ts @@ -0,0 +1,62 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { managedAgentListEventsTool } from '@/tools/managed_agent/list_events' + +const originalFetch = global.fetch +afterEach(() => { + global.fetch = originalFetch +}) + +/** One page of `count` events, no next page. */ +const historyOf = (count: number) => + vi.fn(async () => + Response.json({ + data: Array.from({ length: count }, (_, i) => ({ + id: `e${i}`, + type: 'agent.message', + processed_at: new Date(Date.UTC(2026, 0, 1) + i * 1000).toISOString(), + content: [{ type: 'text', text: `m${i}` }], + })), + next_page: null, + }) + ) as unknown as typeof fetch + +const run = (limit: unknown) => + managedAgentListEventsTool.directExecution!( + { credential: 'c', accessToken: 'sk-ant-fake', sessionId: 'sesn_1', limit } as never, + undefined + ) + +describe('managed_agent_list_events — limit handling', () => { + it.each([0.5, 0, -1, Number.NaN, 'abc', null, undefined])( + 'falls back to the 500 default for the invalid limit %p rather than reading unbounded', + async (limit) => { + global.fetch = historyOf(600) + const res = (await run(limit)) as { output: { count: number; truncated: boolean } } + expect(res.output.count).toBe(500) + expect(res.output.truncated).toBe(true) + } + ) + + it('honors a valid limit and keeps the newest events', async () => { + global.fetch = historyOf(50) + const res = (await run(10)) as { + output: { count: number; truncated: boolean; assistantText: string } + } + expect(res.output.count).toBe(10) + expect(res.output.truncated).toBe(true) + // Newest ten are m40..m49, concatenated in chronological order. + expect(res.output.assistantText).toBe( + Array.from({ length: 10 }, (_, i) => `m${40 + i}`).join('') + ) + }) + + it('reports a complete history as not truncated even at exactly the limit', async () => { + global.fetch = historyOf(10) + const res = (await run(10)) as { output: { count: number; truncated: boolean } } + expect(res.output.count).toBe(10) + expect(res.output.truncated).toBe(false) + }) +}) diff --git a/apps/sim/tools/managed_agent/list_events.ts b/apps/sim/tools/managed_agent/list_events.ts new file mode 100644 index 00000000000..d2858ddbd75 --- /dev/null +++ b/apps/sim/tools/managed_agent/list_events.ts @@ -0,0 +1,143 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { listSessionEventsPage } from '@/lib/managed-agents/session-client' +import { normalizeStringList } from '@/tools/managed_agent/normalizers' +import { + ACCESS_TOKEN_PARAM, + CREDENTIAL_PARAM, + resolveSessionTarget, + SESSION_ID_PARAM, + UNUSED_REQUEST, +} from '@/tools/managed_agent/shared' +import type { + ManagedAgentListEventsParams, + ManagedAgentListEventsResponse, +} from '@/tools/managed_agent/types' +import type { ToolConfig } from '@/tools/types' + +/** + * Default cap on how many events land in the workflow output. + * + * A long-running agent session accumulates thousands of events, and tool inputs + * and results can each be large — returning the whole history unbounded would + * put an unpredictable payload into the workflow. Callers that genuinely need + * more can raise it. + */ +const DEFAULT_EVENT_LIMIT = 500 + +/** + * Reads a session's event history, oldest first. + * + * `assistantText` is precomputed because reading the agent's reply is the + * overwhelmingly common reason to call this, and doing it correctly is fiddly: + * only persisted (id-bearing) `agent.message` events count, since stream-only + * previews are never deduped and would double the text. + */ +export const managedAgentListEventsTool: ToolConfig< + ManagedAgentListEventsParams, + ManagedAgentListEventsResponse +> = { + id: 'managed_agent_list_events', + name: 'Managed Agent List Events', + description: "Read a Managed Agent session's event history and the agent's reply text.", + version: '1.0.0', + + params: { + credential: CREDENTIAL_PARAM, + accessToken: ACCESS_TOKEN_PARAM, + sessionId: SESSION_ID_PARAM, + eventTypes: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: + "Optional event-type filter, e.g. ['agent.message']. Omit to return every event.", + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + // Written out rather than interpolated: the docs generator extracts this + // string statically, so a template literal ships as a raw `${...}` to + // readers. `DEFAULT_EVENT_LIMIT` is asserted against this in tests. + description: 'Maximum events to return, keeping the most recent (default 500).', + }, + }, + + request: UNUSED_REQUEST, + + directExecution: async (params, signal): Promise => { + const emptyOutput = { + sessionId: '', + events: [] as unknown[], + count: 0, + assistantText: '', + truncated: false, + } + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: emptyOutput, error: target.error } + } + + const types = normalizeStringList(params.eventTypes) + // Floor BEFORE the positivity check: a fractional limit like 0.5 would pass + // `> 0` and then floor to 0, which reads as "no cap" downstream and returns + // the whole history. Anything that does not floor to a positive integer + // falls back to the default rather than silently becoming unbounded. + const requested = Math.floor(Number(params.limit)) + const maxItems = Number.isFinite(requested) && requested > 0 ? requested : DEFAULT_EVENT_LIMIT + + try { + const { events, total } = await listSessionEventsPage({ + apiKey: target.apiKey, + sessionId: target.sessionId, + maxItems, + ...(types.length > 0 ? { types } : {}), + ...(signal ? { signal } : {}), + }) + + let assistantText = '' + for (const event of events) { + // Skip idless events: those are stream-only previews, and the persisted + // copy carrying the same text arrives separately. + if (event.type !== 'agent.message' || !event.id || !Array.isArray(event.content)) continue + for (const block of event.content) { + if (block?.type === 'text' && typeof block.text === 'string') assistantText += block.text + } + } + + return { + success: true, + output: { + sessionId: target.sessionId, + events, + count: events.length, + assistantText, + // Compared against the untrimmed history size, not the limit: a + // session holding exactly `maxItems` events dropped nothing and must + // not be reported as a partial read. + truncated: total > events.length, + }, + } + } catch (error) { + return { + success: false, + output: { ...emptyOutput, sessionId: target.sessionId }, + error: getErrorMessage(error, 'Failed to list Managed Agent session events'), + } + } + }, + + outputs: { + sessionId: { type: 'string', description: 'The session that was read.' }, + events: { type: 'json', description: 'Session events, oldest first.' }, + count: { type: 'number', description: 'Number of events returned.' }, + assistantText: { + type: 'string', + description: 'Concatenated text of every persisted agent.message, in order.', + }, + truncated: { + type: 'boolean', + description: 'True when the limit was hit and older events were dropped.', + }, + }, +} diff --git a/apps/sim/tools/managed_agent/respond_custom_tool.ts b/apps/sim/tools/managed_agent/respond_custom_tool.ts new file mode 100644 index 00000000000..0f96d269cd6 --- /dev/null +++ b/apps/sim/tools/managed_agent/respond_custom_tool.ts @@ -0,0 +1,117 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { sendCustomToolResults } from '@/lib/managed-agents/session-client' +import { isTruthyAck } from '@/tools/managed_agent/normalizers' +import { + ACCESS_TOKEN_PARAM, + CREDENTIAL_PARAM, + resolveSessionTarget, + SESSION_ID_PARAM, + UNUSED_REQUEST, +} from '@/tools/managed_agent/shared' +import type { + ManagedAgentCustomToolResultParams, + ManagedAgentCustomToolResultResponse, +} from '@/tools/managed_agent/types' +import type { ToolConfig } from '@/tools/types' + +/** + * Returns the result of a client-side custom tool the agent invoked. + * + * Custom tools run in the caller's application, not Anthropic's sandbox, so the + * agent parks on `agent.custom_tool_use` until the caller supplies the output. + * A permission confirmation does NOT unblock these — that is a different event + * for a different kind of gate. `managed_agent_get_session` labels each pending + * gate with `kind`, so a workflow can route to the right operation. + * + * Deliberately answers ONE call per invocation. Each pending custom tool has + * its own output, so accepting a list here would force every one of them to + * share a single result — silently wrong whenever more than one is pending. + * Answer several by iterating this operation over `pendingTools`. + */ +export const managedAgentRespondCustomToolTool: ToolConfig< + ManagedAgentCustomToolResultParams, + ManagedAgentCustomToolResultResponse +> = { + id: 'managed_agent_respond_custom_tool', + name: 'Managed Agent Respond To Custom Tool', + description: + 'Return the result of a custom tool a Managed Agent session is waiting on so it can continue.', + version: '1.0.0', + + params: { + credential: CREDENTIAL_PARAM, + accessToken: ACCESS_TOKEN_PARAM, + sessionId: SESSION_ID_PARAM, + customToolUseId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + "The custom tool-use EVENT id being answered, from Get Session pendingTools[].id where kind is 'custom_tool_result'.", + }, + result: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "The tool's output, returned to the agent as text.", + }, + isError: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Mark the result as a failure so the agent can adjust its approach.', + }, + }, + + request: UNUSED_REQUEST, + + directExecution: async (params, signal): Promise => { + const emptyOutput = { sessionId: '', answeredToolUseId: '' } + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: emptyOutput, error: target.error } + } + + const customToolUseId = params.customToolUseId?.trim() + if (!customToolUseId) { + return { + success: false, + output: { ...emptyOutput, sessionId: target.sessionId }, + error: + 'A custom tool-use event id is required. Read it from Get Session pendingTools[].id.', + } + } + + // The result may legitimately be empty (a tool that returns nothing), so + // only the id is required — an absent result is sent as an empty string. + const result = (params.result ?? '').toString() + const isError = isTruthyAck(params.isError) + + try { + await sendCustomToolResults({ + apiKey: target.apiKey, + sessionId: target.sessionId, + results: [{ customToolUseId, content: result, isError }], + ...(signal ? { signal } : {}), + }) + return { + success: true, + output: { sessionId: target.sessionId, answeredToolUseId: customToolUseId }, + } + } catch (error) { + return { + success: false, + output: { ...emptyOutput, sessionId: target.sessionId }, + error: getErrorMessage(error, 'Failed to send custom tool result'), + } + } + }, + + outputs: { + sessionId: { type: 'string', description: 'The session that was answered.' }, + answeredToolUseId: { + type: 'string', + description: 'The custom tool-use event id that was answered.', + }, + }, +} diff --git a/apps/sim/tools/managed_agent/respond_tool_confirmation.ts b/apps/sim/tools/managed_agent/respond_tool_confirmation.ts new file mode 100644 index 00000000000..5156a74af19 --- /dev/null +++ b/apps/sim/tools/managed_agent/respond_tool_confirmation.ts @@ -0,0 +1,125 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { sendToolConfirmations } from '@/lib/managed-agents/session-client' +import { normalizeStringList } from '@/tools/managed_agent/normalizers' +import { + ACCESS_TOKEN_PARAM, + CREDENTIAL_PARAM, + resolveSessionTarget, + SESSION_ID_PARAM, + UNUSED_REQUEST, +} from '@/tools/managed_agent/shared' +import type { + ManagedAgentToolConfirmationParams, + ManagedAgentToolConfirmationResponse, +} from '@/tools/managed_agent/types' +import type { ToolConfig } from '@/tools/types' + +/** + * Answers the `always_ask` permission gates blocking a session. + * + * Without this, an agent configured with an `always_ask` policy on any tool + * parks forever: it emits `agent.tool_use`, the session idles with + * `stop_reason.type === 'requires_action'`, and it waits indefinitely for a + * `user.tool_confirmation`. Pair with `managed_agent_get_session`, which + * surfaces the blocking ids in `pendingTools`. + * + * All ids are answered in a single request: resolving only some of a turn's + * gates leaves the session parked on the rest. + */ +export const managedAgentRespondToolConfirmationTool: ToolConfig< + ManagedAgentToolConfirmationParams, + ManagedAgentToolConfirmationResponse +> = { + id: 'managed_agent_respond_tool_confirmation', + name: 'Managed Agent Respond To Tool Confirmation', + description: + 'Allow or deny the tool calls a Managed Agent session is waiting on before it can continue.', + version: '1.0.0', + + params: { + credential: CREDENTIAL_PARAM, + accessToken: ACCESS_TOKEN_PARAM, + sessionId: SESSION_ID_PARAM, + toolUseIds: { + type: 'array', + required: true, + visibility: 'user-or-llm', + description: + "Blocking tool-use EVENT ids, from Get Session pendingTools[].id where kind is 'confirmation' (not toolu_ ids).", + }, + decision: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "'allow' to let the tools run, or 'deny' to reject them.", + }, + denyMessage: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Reason surfaced to the agent. Only sent when the decision is deny.', + }, + }, + + request: UNUSED_REQUEST, + + directExecution: async (params, signal): Promise => { + const emptyOutput = { sessionId: '', decision: '', confirmedToolUseIds: [] as string[] } + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: emptyOutput, error: target.error } + } + + const decision = (params.decision ?? '').toString().trim().toLowerCase() + if (decision !== 'allow' && decision !== 'deny') { + return { + success: false, + output: { ...emptyOutput, sessionId: target.sessionId }, + error: "Decision must be 'allow' or 'deny'.", + } + } + + const toolUseIds = normalizeStringList(params.toolUseIds) + if (toolUseIds.length === 0) { + return { + success: false, + output: { ...emptyOutput, sessionId: target.sessionId, decision }, + error: + 'At least one tool-use event id is required. Read them from Get Session pendingTools[].id.', + } + } + + const denyMessage = params.denyMessage?.trim() + try { + await sendToolConfirmations({ + apiKey: target.apiKey, + sessionId: target.sessionId, + confirmations: toolUseIds.map((toolUseId) => ({ + toolUseId, + result: decision, + ...(denyMessage ? { denyMessage } : {}), + })), + ...(signal ? { signal } : {}), + }) + return { + success: true, + output: { sessionId: target.sessionId, decision, confirmedToolUseIds: toolUseIds }, + } + } catch (error) { + return { + success: false, + output: { sessionId: target.sessionId, decision, confirmedToolUseIds: [] }, + error: getErrorMessage(error, 'Failed to send tool confirmation'), + } + } + }, + + outputs: { + sessionId: { type: 'string', description: 'The session that was answered.' }, + decision: { type: 'string', description: "The decision applied — 'allow' or 'deny'." }, + confirmedToolUseIds: { + type: 'json', + description: 'The tool-use event ids that were answered.', + }, + }, +} diff --git a/apps/sim/tools/managed_agent/send_message.ts b/apps/sim/tools/managed_agent/send_message.ts new file mode 100644 index 00000000000..cd853a2de91 --- /dev/null +++ b/apps/sim/tools/managed_agent/send_message.ts @@ -0,0 +1,87 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { sendUserMessage } from '@/lib/managed-agents/session-client' +import { + ACCESS_TOKEN_PARAM, + CREDENTIAL_PARAM, + resolveSessionTarget, + SESSION_ID_PARAM, + UNUSED_REQUEST, +} from '@/tools/managed_agent/shared' +import type { + ManagedAgentSendMessageParams, + ManagedAgentSendMessageResponse, +} from '@/tools/managed_agent/types' +import type { ToolConfig } from '@/tools/types' + +/** + * Sends a user turn into an EXISTING Managed Agent session and returns as soon + * as the event is accepted. + * + * This is what makes a session multi-turn across workflow runs: the session id + * comes from the caller (a thread lookup, a webhook payload, a prior + * `managed_agent_create_session`), so a conversation can span many executions + * instead of being trapped inside one blocking block. + * + * Events are queued server-side and processed in order, so there is no need to + * wait for the agent to go idle before sending the next one. + */ +export const managedAgentSendMessageTool: ToolConfig< + ManagedAgentSendMessageParams, + ManagedAgentSendMessageResponse +> = { + id: 'managed_agent_send_message', + name: 'Managed Agent Send Message', + description: 'Send a user message to an existing Claude Platform Managed Agent session.', + version: '1.0.0', + + params: { + credential: CREDENTIAL_PARAM, + accessToken: ACCESS_TOKEN_PARAM, + sessionId: SESSION_ID_PARAM, + userMessage: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The user message to send to the session.', + }, + }, + + request: UNUSED_REQUEST, + + directExecution: async (params, signal): Promise => { + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: { sessionId: '', sent: false }, error: target.error } + } + + const text = (params.userMessage ?? '').toString().trim() + if (!text) { + return { + success: false, + output: { sessionId: target.sessionId, sent: false }, + error: 'A user message is required.', + } + } + + try { + await sendUserMessage({ + apiKey: target.apiKey, + sessionId: target.sessionId, + text, + ...(signal ? { signal } : {}), + }) + return { success: true, output: { sessionId: target.sessionId, sent: true } } + } catch (error) { + return { + success: false, + output: { sessionId: target.sessionId, sent: false }, + error: getErrorMessage(error, 'Failed to send message to Managed Agent session'), + } + } + }, + + outputs: { + sessionId: { type: 'string', description: 'The session the message was sent to.' }, + sent: { type: 'boolean', description: 'True when the event was accepted by the API.' }, + }, +} diff --git a/apps/sim/tools/managed_agent/shared.ts b/apps/sim/tools/managed_agent/shared.ts new file mode 100644 index 00000000000..ea045474071 --- /dev/null +++ b/apps/sim/tools/managed_agent/shared.ts @@ -0,0 +1,87 @@ +/** + * Shared scaffolding for the Managed Agent session-operation tools. + * + * Every operation authenticates the same way (a Claude Platform + * service-account credential the executor resolves into `accessToken`) and + * most address an existing session by id, so those parameter definitions and + * the guard that reads them live here rather than being restated per tool. + */ + +import type { ToolConfig } from '@/tools/types' + +/** Credential picker value; the executor swaps it for `accessToken` at run time. */ +export const CREDENTIAL_PARAM = { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Claude Platform credential (Anthropic workspace API key) to act with.', +} as const + +/** Decrypted workspace API key injected by the executor. Never set by the author. */ +export const ACCESS_TOKEN_PARAM = { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Workspace API key injected by the executor from the selected credential.', +} as const + +export const SESSION_ID_PARAM = { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Anthropic session id (sesn_...) to act on.', +} as const + +/** + * `ToolConfig` requires a `request` shape even when `directExecution` + * short-circuits the HTTP path, so every session-operation tool reuses this + * inert stub instead of repeating it. + */ +export const UNUSED_REQUEST: ToolConfig['request'] = { + url: () => '', + method: 'POST', + headers: () => ({}), +} + +/** Params common to every session-operation tool. */ +export interface ManagedAgentSessionParams { + credential: string + accessToken?: string + sessionId?: string +} + +/** + * Validates the two things every session operation needs. Returns a message on + * failure so each tool can surface it as a normal tool error rather than + * throwing — a missing credential is an author mistake, not an exception. + */ +export function resolveSessionTarget( + params: ManagedAgentSessionParams +): { ok: true; apiKey: string; sessionId: string } | { ok: false; error: string } { + const apiKey = params.accessToken + if (!apiKey) { + return { + ok: false, + error: 'No Claude Platform credential is selected, or it could not be resolved.', + } + } + const sessionId = params.sessionId?.trim() + if (!sessionId) { + return { ok: false, error: 'A session id is required.' } + } + return { ok: true, apiKey, sessionId } +} + +/** Validates the credential alone, for operations that do not target a session. */ +export function resolveApiKey( + params: Pick +): { ok: true; apiKey: string } | { ok: false; error: string } { + const apiKey = params.accessToken + if (!apiKey) { + return { + ok: false, + error: 'No Claude Platform credential is selected, or it could not be resolved.', + } + } + return { ok: true, apiKey } +} diff --git a/apps/sim/tools/managed_agent/types.ts b/apps/sim/tools/managed_agent/types.ts index 39dd27be591..d35e39d6ffc 100644 --- a/apps/sim/tools/managed_agent/types.ts +++ b/apps/sim/tools/managed_agent/types.ts @@ -37,6 +37,141 @@ export interface ManagedAgentRunSessionParams { _context?: { workflowId?: string } } +/** Shape shared by the session-operation tools' params. */ +interface ManagedAgentSessionOpParams { + credential: string + accessToken?: string + sessionId?: string +} + +export interface ManagedAgentCreateSessionParams + extends Omit { + /** Optional first turn, seeded via `initial_events` so create+send is one call. */ + userMessage?: string +} + +export interface ManagedAgentCreateSessionResponse extends ToolResponse { + output: { + sessionId: string + /** True when a first message was seeded and the agent loop already started. */ + started: boolean + } +} + +export interface ManagedAgentSendMessageParams extends ManagedAgentSessionOpParams { + userMessage: string +} + +export interface ManagedAgentSendMessageResponse extends ToolResponse { + output: { sessionId: string; sent: boolean } +} + +export interface ManagedAgentGetSessionParams extends ManagedAgentSessionOpParams {} + +/** One tool call blocking a session. */ +export interface ManagedAgentPendingTool { + id: string + eventType?: string + /** + * Which operation unblocks this gate: `confirmation` (Respond To Tool + * Confirmation) or `custom_tool_result` (Respond To Custom Tool). + */ + kind?: 'confirmation' | 'custom_tool_result' + name?: string + input?: unknown +} + +export interface ManagedAgentGetSessionResponse extends ToolResponse { + output: { + sessionId: string + status: string + /** `stop_reason.type` from the session, when it has stopped at least once. */ + stopReason?: string + /** True when the session is parked awaiting a tool confirmation or result. */ + requiresAction: boolean + /** Blocking tool calls, resolved to names where possible. Empty unless `requiresAction`. */ + pendingTools: ManagedAgentPendingTool[] + metadata?: Record + title?: string + inputTokens?: number + outputTokens?: number + } +} + +export interface ManagedAgentListEventsParams extends ManagedAgentSessionOpParams { + /** Optional `types[]` filter (array, JSON string, or comma list). */ + eventTypes?: unknown + /** Cap on events returned; guards against an unbounded history read. */ + limit?: unknown +} + +export interface ManagedAgentListEventsResponse extends ToolResponse { + output: { + sessionId: string + events: unknown[] + count: number + /** Concatenated text of every `agent.message`, in order — the usual thing callers want. */ + assistantText: string + /** True when `limit` was reached and older events were dropped. */ + truncated: boolean + } +} + +export interface ManagedAgentUpdateSessionParams extends ManagedAgentSessionOpParams { + title?: string + sessionParameters?: unknown + /** Explicitly removes all stored metadata; an empty map cannot express this. */ + clearMetadata?: boolean | string +} + +export interface ManagedAgentUpdateSessionResponse extends ToolResponse { + output: { sessionId: string; updated: boolean; metadata?: Record; title?: string } +} + +export interface ManagedAgentInterruptSessionParams extends ManagedAgentSessionOpParams {} + +export interface ManagedAgentInterruptSessionResponse extends ToolResponse { + output: { sessionId: string; interrupted: boolean } +} + +export interface ManagedAgentToolConfirmationParams extends ManagedAgentSessionOpParams { + /** Blocking tool-use EVENT ids (array, JSON string, or comma list). */ + toolUseIds?: unknown + /** `allow` or `deny`. */ + decision?: string + /** Reason surfaced to the agent; only sent on a denial. */ + denyMessage?: string +} + +export interface ManagedAgentToolConfirmationResponse extends ToolResponse { + output: { sessionId: string; decision: string; confirmedToolUseIds: string[] } +} + +export interface ManagedAgentCustomToolResultParams extends ManagedAgentSessionOpParams { + /** The blocking custom tool-use EVENT id being answered. */ + customToolUseId?: string + /** The tool's output, returned to the agent. */ + result?: string + /** Marks the result as a failure. */ + isError?: boolean | string +} + +export interface ManagedAgentCustomToolResultResponse extends ToolResponse { + output: { sessionId: string; answeredToolUseId: string } +} + +export interface ManagedAgentArchiveSessionParams extends ManagedAgentSessionOpParams {} + +export interface ManagedAgentArchiveSessionResponse extends ToolResponse { + output: { sessionId: string; archived: boolean } +} + +export interface ManagedAgentDeleteSessionParams extends ManagedAgentSessionOpParams {} + +export interface ManagedAgentDeleteSessionResponse extends ToolResponse { + output: { sessionId: string; deleted: boolean } +} + export interface ManagedAgentRunSessionResponse extends ToolResponse { output: { /** Final assistant text from the Managed Agent session. */ diff --git a/apps/sim/tools/managed_agent/update_session.test.ts b/apps/sim/tools/managed_agent/update_session.test.ts new file mode 100644 index 00000000000..ddb8486106d --- /dev/null +++ b/apps/sim/tools/managed_agent/update_session.test.ts @@ -0,0 +1,63 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { managedAgentUpdateSessionTool } from '@/tools/managed_agent/update_session' + +const originalFetch = global.fetch +afterEach(() => { + global.fetch = originalFetch +}) + +const capture = () => { + const spy = vi.fn(async () => Response.json({ status: 'idle' })) as unknown as typeof fetch + global.fetch = spy + return spy as unknown as ReturnType +} + +const run = (params: Record) => + managedAgentUpdateSessionTool.directExecution!( + { credential: 'c', accessToken: 'sk-ant-fake', sessionId: 'sesn_1', ...params } as never, + undefined + ) + +const bodyOf = (spy: ReturnType) => + JSON.parse((spy.mock.calls[0] as [string, RequestInit])[1].body as string) + +describe('managed_agent_update_session — metadata clearing', () => { + it('does not touch metadata on a title-only update', async () => { + const spy = capture() + await run({ title: 'renamed' }) + expect(bodyOf(spy)).toEqual({ title: 'renamed' }) + }) + + it.each([[[]], [{}], [undefined]])( + 'leaves stored metadata alone for the empty value %p rather than wiping it', + async (sessionParameters) => { + // An untouched metadata table is also empty, so emptiness cannot mean + // "clear" — inferring it would wipe metadata on every title-only update. + const spy = capture() + await run({ title: 'renamed', sessionParameters }) + expect(bodyOf(spy).metadata).toBeUndefined() + } + ) + + it('clears metadata only when explicitly asked', async () => { + const spy = capture() + await run({ clearMetadata: true }) + expect(bodyOf(spy)).toEqual({ metadata: {} }) + }) + + it('lets an explicit clear win over a supplied map', async () => { + const spy = capture() + await run({ clearMetadata: true, sessionParameters: { a: 'b' } }) + expect(bodyOf(spy).metadata).toEqual({}) + }) + + it('still refuses a no-op update', async () => { + capture() + const res = (await run({})) as { success: boolean; error?: string } + expect(res.success).toBe(false) + expect(res.error).toMatch(/Clear metadata/) + }) +}) diff --git a/apps/sim/tools/managed_agent/update_session.ts b/apps/sim/tools/managed_agent/update_session.ts new file mode 100644 index 00000000000..2520405148a --- /dev/null +++ b/apps/sim/tools/managed_agent/update_session.ts @@ -0,0 +1,125 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { updateSession } from '@/lib/managed-agents/session-client' +import { isTruthyAck, normalizeSessionParameters } from '@/tools/managed_agent/normalizers' +import { + ACCESS_TOKEN_PARAM, + CREDENTIAL_PARAM, + resolveSessionTarget, + SESSION_ID_PARAM, + UNUSED_REQUEST, +} from '@/tools/managed_agent/shared' +import type { + ManagedAgentUpdateSessionParams, + ManagedAgentUpdateSessionResponse, +} from '@/tools/managed_agent/types' +import type { ToolConfig } from '@/tools/types' + +/** + * Updates an existing session's title and/or metadata. + * + * Metadata is settable at create time too, but some of what you want to store + * only exists AFTER the session does — the canonical case is writing back the + * id of a message/thread that was posted to announce the session. This closes + * that ordering gap. + * + * Metadata is a FULL REPLACEMENT of the stored map, matching the API. To add + * one key, read the session first and send the merged map. Removing metadata + * entirely takes an explicit `clearMetadata`, because an empty map is + * indistinguishable from a field the author never filled in. + */ +export const managedAgentUpdateSessionTool: ToolConfig< + ManagedAgentUpdateSessionParams, + ManagedAgentUpdateSessionResponse +> = { + id: 'managed_agent_update_session', + name: 'Managed Agent Update Session', + description: "Update a Managed Agent session's title or metadata.", + version: '1.0.0', + + params: { + credential: CREDENTIAL_PARAM, + accessToken: ACCESS_TOKEN_PARAM, + sessionId: SESSION_ID_PARAM, + title: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New session title.', + }, + sessionParameters: { + type: 'object', + required: false, + visibility: 'user-or-llm', + description: + 'Replacement metadata map (replaces all stored metadata, not merged). Leaving it empty leaves the stored metadata unchanged — use clearMetadata to remove it.', + }, + clearMetadata: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + "Removes all of the session's stored metadata. Overrides any map supplied above.", + }, + }, + + request: UNUSED_REQUEST, + + directExecution: async (params, signal): Promise => { + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: { sessionId: '', updated: false }, error: target.error } + } + + // A whitespace-only title is treated as "not provided", not as a request to + // blank the session's title — otherwise a stray space in the field would + // both slip past the guard below and silently clear an existing title. + const trimmedTitle = params.title?.trim() + const title = trimmedTitle ? trimmedTitle : undefined + + // Clearing metadata needs its own explicit signal. An empty metadata table + // cannot mean "clear": a table the author never touched is also empty, so + // inferring intent from emptiness would wipe a session's metadata on every + // title-only update. `{}` is only sent when the author asks for it. + const clearMetadata = isTruthyAck(params.clearMetadata) + const metadata = clearMetadata ? {} : normalizeSessionParameters(params.sessionParameters) + if (title === undefined && metadata === undefined) { + return { + success: false, + output: { sessionId: target.sessionId, updated: false }, + error: 'Provide a title or metadata to update, or check "Clear metadata".', + } + } + + try { + const snapshot = await updateSession({ + apiKey: target.apiKey, + sessionId: target.sessionId, + ...(title !== undefined ? { title } : {}), + ...(metadata !== undefined ? { metadata } : {}), + ...(signal ? { signal } : {}), + }) + return { + success: true, + output: { + sessionId: target.sessionId, + updated: true, + ...(snapshot.metadata ? { metadata: snapshot.metadata } : {}), + ...(snapshot.title ? { title: snapshot.title } : {}), + }, + } + } catch (error) { + return { + success: false, + output: { sessionId: target.sessionId, updated: false }, + error: getErrorMessage(error, 'Failed to update Managed Agent session'), + } + } + }, + + outputs: { + sessionId: { type: 'string', description: 'The session that was updated.' }, + updated: { type: 'boolean', description: 'True when the update was accepted.' }, + metadata: { type: 'json', description: 'Metadata after the update.', optional: true }, + title: { type: 'string', description: 'Title after the update.', optional: true }, + }, +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 06fdf3eb441..329a51f793c 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -2477,7 +2477,19 @@ import { mailgunListMessagesTool, mailgunSendMessageTool, } from '@/tools/mailgun' -import { managedAgentRunSessionTool } from '@/tools/managed_agent' +import { + managedAgentArchiveSessionTool, + managedAgentCreateSessionTool, + managedAgentDeleteSessionTool, + managedAgentGetSessionTool, + managedAgentInterruptSessionTool, + managedAgentListEventsTool, + managedAgentRespondCustomToolTool, + managedAgentRespondToolConfirmationTool, + managedAgentRunSessionTool, + managedAgentSendMessageTool, + managedAgentUpdateSessionTool, +} from '@/tools/managed_agent' import { mem0AddMemoriesTool, mem0GetMemoriesTool, mem0SearchMemoriesTool } from '@/tools/mem0' import { memoryAddTool, memoryDeleteTool, memoryGetAllTool, memoryGetTool } from '@/tools/memory' import { @@ -5511,7 +5523,17 @@ export const tools: Record = { mailgun_add_list_member: mailgunAddListMemberTool, mailgun_list_domains: mailgunListDomainsTool, mailgun_get_domain: mailgunGetDomainTool, + managed_agent_archive_session: managedAgentArchiveSessionTool, + managed_agent_create_session: managedAgentCreateSessionTool, + managed_agent_delete_session: managedAgentDeleteSessionTool, + managed_agent_get_session: managedAgentGetSessionTool, + managed_agent_interrupt_session: managedAgentInterruptSessionTool, + managed_agent_list_events: managedAgentListEventsTool, + managed_agent_respond_custom_tool: managedAgentRespondCustomToolTool, + managed_agent_respond_tool_confirmation: managedAgentRespondToolConfirmationTool, managed_agent_run_session: managedAgentRunSessionTool, + managed_agent_send_message: managedAgentSendMessageTool, + managed_agent_update_session: managedAgentUpdateSessionTool, sms_send: smsSendTool, jira_retrieve: jiraRetrieveTool, jira_update: jiraUpdateTool,