From b31fa813cc63c426b79c0beb0bdf387b06ba8742 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 31 Jul 2026 16:24:04 -0700 Subject: [PATCH 1/7] feat(managed-agent): add session lifecycle operations Adds an operation selector to the Claude Managed Agents block, backed by nine new tools alongside the existing run-session behavior: - create session (non-blocking, seeds initial_events) - send message to an existing session - get session (surfaces tool calls awaiting approval) - list events - update session (title/metadata) - interrupt session - respond to tool confirmation (allow/deny) - archive session - delete session Run Session stays the default, so blocks saved before the selector existed keep their exact behavior and field layout. --- .../docs/en/integrations/managed_agent.mdx | 175 +++++++++ apps/sim/blocks/blocks/managed_agent.test.ts | 161 ++++++++ apps/sim/blocks/blocks/managed_agent.ts | Bin 8452 -> 18775 bytes apps/sim/lib/integrations/integrations.json | 47 ++- .../lib/managed-agents/session-client.test.ts | 310 ++++++++++++++- apps/sim/lib/managed-agents/session-client.ts | 352 ++++++++++++++++-- .../tools/managed_agent/archive_session.ts | 66 ++++ .../sim/tools/managed_agent/create_session.ts | 201 ++++++++++ .../sim/tools/managed_agent/delete_session.ts | 68 ++++ apps/sim/tools/managed_agent/get_session.ts | 142 +++++++ apps/sim/tools/managed_agent/index.ts | 9 + .../tools/managed_agent/interrupt_session.ts | 68 ++++ apps/sim/tools/managed_agent/list_events.ts | 125 +++++++ .../respond_tool_confirmation.ts | 125 +++++++ apps/sim/tools/managed_agent/send_message.ts | 87 +++++ apps/sim/tools/managed_agent/shared.ts | 87 +++++ apps/sim/tools/managed_agent/types.ts | 113 ++++++ .../sim/tools/managed_agent/update_session.ts | 105 ++++++ apps/sim/tools/registry.ts | 22 +- 19 files changed, 2235 insertions(+), 28 deletions(-) create mode 100644 apps/sim/blocks/blocks/managed_agent.test.ts create mode 100644 apps/sim/tools/managed_agent/archive_session.ts create mode 100644 apps/sim/tools/managed_agent/create_session.ts create mode 100644 apps/sim/tools/managed_agent/delete_session.ts create mode 100644 apps/sim/tools/managed_agent/get_session.ts create mode 100644 apps/sim/tools/managed_agent/interrupt_session.ts create mode 100644 apps/sim/tools/managed_agent/list_events.ts create mode 100644 apps/sim/tools/managed_agent/respond_tool_confirmation.ts create mode 100644 apps/sim/tools/managed_agent/send_message.ts create mode 100644 apps/sim/tools/managed_agent/shared.ts create mode 100644 apps/sim/tools/managed_agent/update_session.ts diff --git a/apps/docs/content/docs/en/integrations/managed_agent.mdx b/apps/docs/content/docs/en/integrations/managed_agent.mdx index 2a42532eabf..029c4ad55a7 100644 --- a/apps/docs/content/docs/en/integrations/managed_agent.mdx +++ b/apps/docs/content/docs/en/integrations/managed_agent.mdx @@ -64,4 +64,179 @@ 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. | +| `pendingTools` | json | Tool calls awaiting approval — \[\{id, eventType, name, input\}\]. Pass each id to Respond To Tool Confirmation. | +| `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 \(default $\{DEFAULT_EVENT_LIMIT\}\). | + +#### 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. | + +### `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\). | + +#### 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 \(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_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..b346515da63 --- /dev/null +++ b/apps/sim/blocks/blocks/managed_agent.test.ts @@ -0,0 +1,161 @@ +/** + * @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'], + ['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', + '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('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 894fdd4bc163017c28c636eba1981169b740c31d..7416da74e32cff1d3f65910ccdae8d000e9bcf90 100644 GIT binary patch literal 18775 zcmd5^+j1Pok=LBFWGZ4tEw;4GazMp!+MZ~>AGfRo;;bA<#e?!%G&&FPO_*;>{(po=7X7+ z#mY?nbQBl(oZGyvj#7N7CQnk={)sVjTgS`uD32C4nJv&r{&lggQ?xu7YjB$1rDc(? z&~{g|bGs_ahs(Ms?XG4&L`~L?(=IwoZ;w{GtV67gi?5p7+pLJ+9TvK}u#fs-ZL#@p z`SGO4=jq}=|2VgG)DeXAu8!EeBLp*R>g9Qn*aLHx*LG1x-1`;BY?ssT`sC<~FU-Z- zYSYTp%d|4m#Mt-iqO#S2u~EDtXlO zR#2bhnybHt{>;;IB_eWzMPt`z&mzcW~B zIJD}+6eAoxp>fsb`RSi$-@mxJzIb)|dUkbo@$%SgIqJbNyZ)iy_0`42i|e!Jm&fL{ zjf*n*QJrHmaW-@G50A9;wCTZ8yW&sJhqjIXdO`P zBqc7Ix@_!M_}g_^XHlAG51sZzI+#yoyGHTy^oP^e*XOgVlm9%ugrZLV#VMwO`(aMA zoXGkGB8uC;!tA+(l_};XG64TWg^IA00~1v++qx-p#n`B`%z2Z?T(97*9PK=cEw<|R zaiJ5?VCJdKl4EB@_*<~T{|zwCo7Jr?@gKK9KnvOZ&CJR&dibN--AIdXFbbsj?AUj~ z-%+06Z_c+l<7b%@>uB=C&QnN3W*M&FLq|crZPE;I7nz$N&KrS3Q!R^oerN?=@O*I+ z1X;5H)aNE3YsEvRf&{^$FF@Qo`i8H2mY{o4%d9RG51B2C1D$?(6ZoF z^v-fT^&_G?aPC+y+`jIm!k~FtEtPKtY1Pe5zg;3|cO&lem{WCDiZ z{mX_I`Cyi|gbNc-7Z+I;t=U(`0rY@Hv3JC=#Bh^CTI!MeZ!yT$JPyn|Yu6+$omH10 z6PBI?zBY<$9C{>f7BfDWBn3Py)6C)!QwVqg3gBkX?R|SxK_PDJ`?`z(Bpn+~bDjsx z5GGBzbQapK4bwow_&J;tHwDfSFG;Mh3NR}S3q{3=w5_-%i*Z11L7f!LB0cm;Ws3Wp z!vu9Vdv>~*nm8+(srF!4VO7Y3lXrH>Uk!1RNK zDD*tpKQ?=63Haz7ydug04EbG-1%I<|zW!#|QJuWNo*zJ(DYRgJFgC}4zE-uWo=wA| z1sSwa+>|)-S|}$`iL4X{xo;-B<9E>ABn zK^LztuV$~W&R%}kGDv#NH$8|tFp~i}drNO91F08nl@=2d6_W%o0d$7%CzYo4`c)!6 zNx`fGh4m}JLcTj4Sqfw^+2DGUA)%WLS=(fUmrcfL*JL-FnvBt<$p#@B$b|N<5wcfP zGn3F#al3V8;3C9EJ8b6Vm8EkVKg0k-$Xp|o=oWu<6qqcA^T8oA!aXpro7`NgRvZw$ z1mMbak>(L14F*9x5g!^IJ8Dix>^gBY3SB};7xMuPBV}qbo+=jH07w8F(1Ev^w8nhE zp{a5!OkHxccDc}TM3?Pk+*f3hrj%{rEQ$tP(Cd)0tu$$|n4{f`mQe*MK8X;qGth>6 zd%G-(cZW$S+<^z~f^3sp3l>6Wt=cT0T{IGqac2+-!W(XS4v$ydA2w@4zK7MtWdUuj zESM`{|Gp^S&C!D451a&}IY1V^0D`}O(pUGOaKLeKx$xKY6^?@79AOmA+MoZo1pv;r zSq`&8u$U>YshTxYEf#!{2iVDl2z+8aE?k|b8NzArKYPQ`EEE`oYKi~gV1jE@44%Y# z4gX8QtR#=3@Js6jw}Xd=Bi03V{MQ~PpC7`blXjJs1X|VX#vfpH1AbH^F|jX4mx+dY zrCuRfg0w&+qO9omz+UU6mkP5a@D^z-Crb^cYI3)tBhIc~Wl@cuE1qY<=1_q>NOzXk z%d%Lfv1kg;s#=o=%wC;=i9GD9VzzzYuko{ThpNaJ=2y0aUL95`0(S@q$IPMXbcH{m z9}+-7%oMsDO$ZhMveZIK__hSMwI#Q7^n{rjB+V3iyKd`S8so9%8BPSGU1UuMIgG+2 zP|KqgKa66Fi{z@fOeJlFc7%e+BTNupRrn7bW7yCe3krnykxow9P zU-$GZzboKs9vNMksh%{AW(s8DNsjc3;<#vX;1|yB9VG3X7e(GR5y>@*=F$-5a1Vel zjKDz0!6ZfPKu3UuG`pT!Ya}2#?Sg_NK?A3bb+o9aAV_NWJjMvV6}Fw%P!k{>R7}9_ zsbn-@*9#;bA!a75DiLG{a+udU9wz#xvorQhinw}#v~-sA06?hX_c;~QF_!F zrRwJ~|k2*G8A#d#S7zSDluT_?bu1Y|n= zKE6tqRQ95$*nT!ybZav#nilJEat{loU%?MKU7&Zsr3GZ~Bcznm0zQQ_tZ_T(k9i`) z_+foN9sH55m-L7}?dvAwYCV;u4g_CJ?u^M>*uG(2Y9XdYDU9E0Oi>}W$jOAYfgK7%$er6hf5l=(DBaCfvsRD z1IZ7_YTH$UMyiSghX@iSy4%PHc7dNOhk@}iiK zJlp{>!yv8^4q46JZ%pY4c!>+0y}R@48w0N?~Du6vy{{b-% zvqmP(=VgRUNE3sCDBCO%qgc;j?$Xvu{4std6y4DtUX>2n`mS>p`)m)-ovwtc?XP;I z$adWpx5fML`^T7qcfp>_iH6E{9fJ)V?$=-bH;jy_R-k>CmZ(S0-}mzE!G|+R>>5s~ zy2#;FyYwYA_`5VsZmuu%Di|jG*gZDVHzRzaV^U$G9)mjO)PDOcV(-lQkC8CaL!CJU zEdF1F-VvEQ8I}2%=o_%ebHF^by%n$1_3!bmB{Fy-@u*$F&S#ndX z(poZHNRB9@(xA{`bUPq#ykN^r6_g>NE%wKdL)omR`gLIB0W9yj5dQL_Bu6MI|FmYe zGe0>@+VwJw9{40KS@RRli=2`01_>cKI!Y1yZjn@yvK5jP^TWf zeCyzT>{x{=QsXrAqI7~mu?9i%B%N<+P`kal++KL`FdG9(9woSCiET?A`oamC!W@eD zVLjErn%%)|g`hXR^rhc1g2WErLdZ{0JoH&8UKnAEhde<#01(!xd0|jXDF!OfLi-oTwW5vX_c zceztDlk`TQRHT?v+(Bcl;DnGu01-vr4wBX48zdJZJESNdI?aNQ?1f$v9nEiMhZQ`2K;z=d zAGSF{E|$sc3Uy$90o(mU>xFuV+1I!qm%t%r(RlTNYt;wdatY}bOQIEgtkc-8j|ZJ= zwpJZ{NQ;{qk$Z?5`k;E*lq}Xr2$$_jVes_kOhG1pWsMeZHIOGS3na96siSCLD?Mk@ zUdIPtKgODO2)E~Uco8VWOER51ar`4JW``~fk|o~4#}r7js$o!XZJHt=jA}D!UQ~eFzBLTZ%z& z-veI{lV@$2h(QRr+*x2Z(QR>O{a(i8uXkvdEDIgUHEE@?CStSQRm$?^rvFKD5qq!SKlC{ovN&X>t zle-}3F2+{L(u+xfEsAV$oy*$vs2%Lw-}dm@k1DH1fRO?6xT(w17`Xl5!>!+duw z-h2tkFi7?;HY8sMnWPWFV?C067_WXGM+eo;S&U-K4y|-<$|)yqT6$kU4}cdwd!pA( zeV+81SLpw4?#mRZu02=B(B5z0>$QCA@TV@#lI{%CJ=fy-U&{|3BaM0M1>PrI(o_fc z4L;Y2tFVVq(4KcNL8;*EJ0B_HL=7iGJE<7m$g#BLgu~=!t2lj}Yj&1B-U*uf&K%i0;B$gj79_eP^f&V*(TRv0HVFWty99RMSS15ufNB`Xsc+ zlUT?nHZQ&lAuLNT)$fUO<3o}GJP**UyL72PMPX|wdH|3)xI1(>2W5)w8mS1@=*T}9 zkF^L|0ttl59rdgpP))HiAeo5Yzv=;7_Q-S5SU5>C`1^9Tfw`;%hZ>FN~s3*ap!z3j$F&n%{wc zm%SquePY%CI;RW^Hg&nSS;mDUx1NDOl&k`hY&R09tOIluaO|-YcgngO5fa9NSKv!K z1V^P^*!PF75?r?&@g;_<8p4(C3bid5+X)g?xALTmUKUkDen8E|GJ2|@*f}fRnSs(P zf_qe|pfF8cWq>c?tA1gyqXl2L;2$jS25C(Eo#+#*Das1#Y0XNz3YQqJ0RyhoDVM~QU0-}LereQucOVQJcR|gtBOS` zZY*Oqt@@3&YTqHS!q`u)X)kXajE18xrSDG3yak5(hf%|m&(b6acyOYU7RFgV0)P&Y^-(VK zbO@|ja8KsLXYZJASRynk<4@C?u3SHIPPIINd&5+LuOxYVi&xkRJAh_O?Fmhg+=&0n zZHlDYO=;4-GwfgGDsJF778GMN=cR0xQH-UM|m=88vl7IVzkoU?^N56?mdS zpci@ytweVmJ;r0}z&MG2rCs5nxctuIWxuv+8Li#_afg4kSlT&iypVO`&^p=;oM;Ue zd1DZdIXRpqH;kL3jOy`l=pLB!Z4Vz8b(uB^El`vD^ZMb&>qyXRZr>`NUSS^&HQmb{ z|0X58QgN?SJbMhwdvx$gcr^vVYM9RwY-(RtEXdFkL1n^yz!hP?S9k=(98l0Q&yM=5 zpPV$fX$3(*8IAOVK-p2I(DRgO+tF1_zhQXo_{jKBHvE5B?0%TVgphxag_l9ha|vXp zG4nWCw$dz4Q}W%0f5wC4vpi{;#`45vibTN?@bv?u5F9mb7WrVYLSDbpcVC?4$^QY| C*SZM+ delta 349 zcmcaUiLu3LLkZ(#b$)er&%Cny?9}4P0wRh=8p%2Nr6~&eMGD2KIcd5X`NbuvDVhq2 z#R`cE<@rU~X*v1jTnbtWdHE@+3W<3s3PqEjh$ty4_~)f6B<18MXDcM!vY)u{<`psmOq;*TSujriDkncNS8Vcp1^3DC)Kw<;$V*NBrl>!e!$@TE0&TX< ztXh%~HM{vHcWD2g?B&Y0`I3$n6Jzyc1AVEUtf0%b`G~$2<77$0 zuE`r+L^tm=e2U;?n&>bh^lj!bb7h>o(}ibptRv^-UFKGkH7(LO6O)rui;Jxkq9>oR WlxB>b%xaspS;^6kb+fa(2onGtymp-c diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index deadb0012bc..d68252cbd62 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,49 @@ "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": "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": 10, "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..e38e5fd4a84 100644 --- a/apps/sim/lib/managed-agents/session-client.test.ts +++ b/apps/sim/lib/managed-agents/session-client.test.ts @@ -2,7 +2,16 @@ * @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, + parseSessionSnapshot, + resolvePendingToolGates, + sendToolConfirmations, + updateSession, +} from '@/lib/managed-agents/session-client' const BASE = { apiKey: 'sk-ant-fake', @@ -189,3 +198,302 @@ 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', name: 'bash', input: { command: 'ls' } }, + { + id: 'sevt_2', + eventType: 'agent.mcp_tool_use', + 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('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 + }) + + const pagedFetch = () => + vi.fn(async () => + Response.json({ + data: Array.from({ length: 100 }, (_, i) => ({ + id: `e${i}`, + type: 'agent.message', + processed_at: '2026-01-01T00:00:00Z', + })), + next_page: 'cursor', + }) + ) as unknown as typeof fetch + + it('stops paging once maxItems is reached', async () => { + const spy = pagedFetch() + global.fetch = spy + const events = await listSessionEvents({ + apiKey: 'sk-ant-fake', + sessionId: 'sesn_1', + maxItems: 250, + }) + // 3 pages of 100 covers 250; a 4th would exceed the cap. + expect((spy as unknown as ReturnType).mock.calls).toHaveLength(3) + 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']) + }) +}) diff --git a/apps/sim/lib/managed-agents/session-client.ts b/apps/sim/lib/managed-agents/session-client.ts index e03a6d3827e..c665d83ff36 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,104 @@ 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, + }) +} + +/** A tool call blocking an `always_ask` session, resolved to its name/input. */ +export interface PendingToolGate { + /** Event id — pass this as `tool_use_id` on the confirmation. */ + id: string + /** `agent.tool_use`, `agent.mcp_tool_use`, or `agent.custom_tool_use`. */ + eventType?: string + name?: string + input?: unknown +} + +/** Upper bound on tool-use events scanned when naming pending gates. */ +const MAX_GATE_LOOKUP_EVENTS = 2000 + +/** 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]), + // Bounded: this only needs to find a handful of ids, and an unbounded read + // of a long session's tool history is a needless memory cost. An id that + // falls outside the window still comes back unenriched, which is usable. + maxItems: MAX_GATE_LOOKUP_EVENTS, + }) + } 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) + return { + id, + ...(event?.type ? { eventType: event.type } : {}), + ...(event?.name ? { name: event.name } : {}), + ...(event?.input !== undefined ? { input: event.input } : {}), + } + }) +} + /** GET /v1/sessions/{id}/events/stream — opens the SSE response. */ export async function openSessionStream( input: SessionAuth & { sessionId: string } @@ -285,7 +441,13 @@ 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]> + } ): Promise { const collected: T[] = [] const maxItems = input.maxItems ?? 2000 @@ -295,6 +457,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', @@ -322,13 +486,27 @@ 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 pulled into memory. Defaults to unbounded, which + * is what the run loop's catch-up needs — it must reach the tail to see the + * terminal event. Callers that surface events to a workflow should pass an + * explicit bound instead: a long session's history can be very large. + */ + 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`, - maxItems: Number.POSITIVE_INFINITY, + ...(types.length > 0 + ? { searchParams: types.map((type): [string, string] => ['types[]', type.trim()]) } + : {}), + maxItems: input.maxItems ?? Number.POSITIVE_INFINITY, }) // The list endpoint's page order is not guaranteed chronological, so order by // the server-side `processed_at` timestamp before returning. The catch-up @@ -401,25 +579,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/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..6c427cf2baf --- /dev/null +++ b/apps/sim/tools/managed_agent/get_session.ts @@ -0,0 +1,142 @@ +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' + +/** `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 } : {}), + }) + : [] + + 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.', + }, + pendingTools: { + type: 'json', + description: + 'Tool calls awaiting approval — [{id, eventType, name, input}]. Pass each id to Respond To Tool Confirmation.', + }, + 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..bd59e309879 100644 --- a/apps/sim/tools/managed_agent/index.ts +++ b/apps/sim/tools/managed_agent/index.ts @@ -1,2 +1,11 @@ +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 { 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..a920c07c3cc --- /dev/null +++ b/apps/sim/tools/managed_agent/interrupt_session.ts @@ -0,0 +1,68 @@ +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' + +/** + * 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' }], + ...(signal ? { signal } : {}), + }) + 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.ts b/apps/sim/tools/managed_agent/list_events.ts new file mode 100644 index 00000000000..479fdcc3802 --- /dev/null +++ b/apps/sim/tools/managed_agent/list_events.ts @@ -0,0 +1,125 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { listSessionEvents } 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', + description: `Maximum events to return (default ${DEFAULT_EVENT_LIMIT}).`, + }, + }, + + request: UNUSED_REQUEST, + + directExecution: async (params, signal): Promise => { + const emptyOutput = { sessionId: '', events: [] as unknown[], count: 0, assistantText: '' } + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: emptyOutput, error: target.error } + } + + const types = normalizeStringList(params.eventTypes) + // A non-numeric or non-positive limit falls back to the default rather than + // silently becoming an unbounded (or empty) read. + const requested = Number(params.limit) + const maxItems = + Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : DEFAULT_EVENT_LIMIT + + try { + const events = await listSessionEvents({ + 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, + }, + } + } 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.', + }, + }, +} 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..b9064bcf082 --- /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 (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..0610b45fd56 100644 --- a/apps/sim/tools/managed_agent/types.ts +++ b/apps/sim/tools/managed_agent/types.ts @@ -37,6 +37,119 @@ 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 an `always_ask` session. */ +export interface ManagedAgentPendingTool { + id: string + eventType?: string + 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 + } +} + +export interface ManagedAgentUpdateSessionParams extends ManagedAgentSessionOpParams { + title?: string + sessionParameters?: unknown +} + +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 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.ts b/apps/sim/tools/managed_agent/update_session.ts new file mode 100644 index 00000000000..846fcd66ae6 --- /dev/null +++ b/apps/sim/tools/managed_agent/update_session.ts @@ -0,0 +1,105 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { updateSession } from '@/lib/managed-agents/session-client' +import { 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. + */ +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).', + }, + }, + + 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 } + } + + const title = params.title?.trim() + const metadata = 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.', + } + } + + 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..1970d0c3597 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -2477,7 +2477,18 @@ import { mailgunListMessagesTool, mailgunSendMessageTool, } from '@/tools/mailgun' -import { managedAgentRunSessionTool } from '@/tools/managed_agent' +import { + managedAgentArchiveSessionTool, + managedAgentCreateSessionTool, + managedAgentDeleteSessionTool, + managedAgentGetSessionTool, + managedAgentInterruptSessionTool, + managedAgentListEventsTool, + 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 +5522,16 @@ 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_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, From 2fabf74780ae7f96fe9b3de9538560332dad32fa Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 31 Jul 2026 16:38:28 -0700 Subject: [PATCH 2/7] fix(managed-agent): address review findings on session lifecycle ops - List Events kept the OLDEST slice when capped, dropping the agent's most recent reply. Paging is exhaustive again and the cap now keeps the newest N after ordering, with a `truncated` flag so callers know it is a tail. - listPaginated returned whole pages past maxItems; it now trims to the exact cap. - A whitespace-only title passed the update guard and would have cleared an existing session title. Blank is now treated as not provided. - Interrupt had no request timeout and could hang; bounded at 15s while still honoring the workflow signal. - Custom-tool gates were surfaced by Get Session but could not be answered, since they need user.custom_tool_result rather than a confirmation. Adds a Respond To Custom Tool operation and a `kind` on each pending gate so a workflow routes to the right one. - Docs rendered a raw ${DEFAULT_EVENT_LIMIT} placeholder for the default. --- .../docs/en/integrations/managed_agent.mdx | 26 +++- apps/sim/blocks/blocks/managed_agent.test.ts | 13 ++ apps/sim/blocks/blocks/managed_agent.ts | Bin 18775 -> 20160 bytes apps/sim/lib/integrations/integrations.json | 6 +- .../lib/managed-agents/session-client.test.ts | 138 ++++++++++++++++-- apps/sim/lib/managed-agents/session-client.ts | 82 +++++++++-- apps/sim/tools/managed_agent/get_session.ts | 2 +- apps/sim/tools/managed_agent/index.ts | 1 + .../tools/managed_agent/interrupt_session.ts | 14 +- apps/sim/tools/managed_agent/list_events.ts | 20 ++- .../managed_agent/respond_custom_tool.ts | 116 +++++++++++++++ .../respond_tool_confirmation.ts | 2 +- apps/sim/tools/managed_agent/types.ts | 22 ++- .../sim/tools/managed_agent/update_session.ts | 6 +- apps/sim/tools/registry.ts | 2 + 15 files changed, 417 insertions(+), 33 deletions(-) create mode 100644 apps/sim/tools/managed_agent/respond_custom_tool.ts diff --git a/apps/docs/content/docs/en/integrations/managed_agent.mdx b/apps/docs/content/docs/en/integrations/managed_agent.mdx index 029c4ad55a7..7f832f05cf3 100644 --- a/apps/docs/content/docs/en/integrations/managed_agent.mdx +++ b/apps/docs/content/docs/en/integrations/managed_agent.mdx @@ -125,7 +125,7 @@ Read a Managed Agent session: status, stop reason, token usage, metadata, and an | `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. | -| `pendingTools` | json | Tool calls awaiting approval — \[\{id, eventType, name, input\}\]. Pass each id to Respond To Tool Confirmation. | +| `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. | @@ -140,7 +140,7 @@ Read a Managed Agent session's event history and the agent's reply text. | 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 \(default $\{DEFAULT_EVENT_LIMIT\}\). | +| `limit` | number | No | Maximum events to return, keeping the most recent \(default 500\). | #### Output @@ -150,6 +150,7 @@ Read a Managed Agent session's event history and the agent's reply text. | `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` @@ -195,7 +196,7 @@ Allow or deny the tool calls a Managed Agent session is waiting on before it can | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `toolUseIds` | array | Yes | Blocking tool-use EVENT ids, from Get Session pendingTools\[\].id \(not toolu_ ids\). | +| `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. | @@ -207,6 +208,25 @@ Allow or deny the tool calls a Managed Agent session is waiting on before it can | `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 | +| --------- | ---- | -------- | ----------- | +| `customToolUseIds` | array | Yes | Custom tool-use EVENT ids, 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. | +| `answeredToolUseIds` | json | The custom tool-use event ids that were answered. | + ### `managed_agent_archive_session` Archive a Managed Agent session, preserving its history. Not reversible. diff --git a/apps/sim/blocks/blocks/managed_agent.test.ts b/apps/sim/blocks/blocks/managed_agent.test.ts index b346515da63..7d565fd7866 100644 --- a/apps/sim/blocks/blocks/managed_agent.test.ts +++ b/apps/sim/blocks/blocks/managed_agent.test.ts @@ -85,6 +85,7 @@ describe('Managed Agent block — operation routing', () => { ['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) => { @@ -112,6 +113,7 @@ describe('Managed Agent block — per-operation field visibility', () => { 'update_session', 'interrupt_session', 'respond_tool_confirmation', + 'respond_custom_tool', 'archive_session', 'delete_session', ]) { @@ -153,6 +155,17 @@ describe('Managed Agent block — per-operation field visibility', () => { 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('customToolUseIds', { operation: 'respond_custom_tool' })).toBe(true) + expect(isVisible('customToolUseIds', { 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) diff --git a/apps/sim/blocks/blocks/managed_agent.ts b/apps/sim/blocks/blocks/managed_agent.ts index 7416da74e32cff1d3f65910ccdae8d000e9bcf90..a008ab0bce02eb8c1e153087891bceeec9d9cec8 100644 GIT binary patch delta 742 zcmb7CO-md>5Jg4N6?Kz{?xJG3i#u7FT|`851jA}_$Uz8Ua|}!Cl-fAXEZsedi0BW{ zgVtHWi^rhM$%Eh@@a!pp*+tCcH1i*gDDM*9<5w&EAq0l-jho4v)|89U%s~#qaoxqOyIT!MhY`!bHf44 z-w%{yd|ksoNqlch!^}W*6ju)F*DH}wP_#CA+Ny?}YHgKc+rLY``Qi7chj-f;mTb<&jh>R?Lsr(Q)MO z(NPtq3>%C)Xb0b4PranCR`(wnOwm+|@}#tpSho2N6WF|#EWC1+%oO%`C4nrv$?vRRi+nsaj^e?He{38}sOo0W7w P2yO<7vu*BnGvxpPU}zIL diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index d68252cbd62..8d4fb0149ed 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -2938,6 +2938,10 @@ "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." @@ -2947,7 +2951,7 @@ "description": "Permanently delete a Managed Agent session, its events, and its sandbox. Not reversible." } ], - "operationCount": 10, + "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 e38e5fd4a84..2a3c370b1ec 100644 --- a/apps/sim/lib/managed-agents/session-client.test.ts +++ b/apps/sim/lib/managed-agents/session-client.test.ts @@ -9,6 +9,7 @@ import { listSessionEvents, parseSessionSnapshot, resolvePendingToolGates, + sendCustomToolResults, sendToolConfirmations, updateSession, } from '@/lib/managed-agents/session-client' @@ -402,10 +403,17 @@ describe('resolvePendingToolGates', () => { }) expect(gates).toEqual([ - { id: 'sevt_1', eventType: 'agent.tool_use', name: 'bash', input: { command: 'ls' } }, + { + 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' }, }, @@ -442,6 +450,36 @@ describe('resolvePendingToolGates', () => { 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('short-circuits with no ids', async () => { const spy = vi.fn() as unknown as typeof fetch global.fetch = spy @@ -458,28 +496,51 @@ describe('listSessionEvents — bounded reads', () => { global.fetch = originalFetch }) - const pagedFetch = () => - vi.fn(async () => - Response.json({ + /** 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${i}`, + id: `e${offset + i}`, type: 'agent.message', - processed_at: '2026-01-01T00:00:00Z', + processed_at: new Date(Date.UTC(2026, 0, 1) + (offset + i) * 1000).toISOString(), })), - next_page: 'cursor', + next_page: page < pages ? `cursor-${page}` : null, }) - ) as unknown as typeof fetch + }) as unknown as typeof fetch + } - it('stops paging once maxItems is reached', async () => { - const spy = pagedFetch() - global.fetch = spy + 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, }) - // 3 pages of 100 covers 250; a 4th would exceed the cap. - expect((spy as unknown as ReturnType).mock.calls).toHaveLength(3) + 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('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) }) @@ -497,3 +558,54 @@ describe('listSessionEvents — bounded reads', () => { 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 c665d83ff36..b2148886114 100644 --- a/apps/sim/lib/managed-agents/session-client.ts +++ b/apps/sim/lib/managed-agents/session-client.ts @@ -345,16 +345,36 @@ export async function sendToolConfirmations( }) } -/** A tool call blocking an `always_ask` session, resolved to its name/input. */ +/** + * 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` on the confirmation. */ + /** 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 +} + /** Upper bound on tool-use events scanned when naming pending gates. */ const MAX_GATE_LOOKUP_EVENTS = 2000 @@ -400,15 +420,45 @@ export async function resolvePendingToolGates( // 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 } @@ -475,7 +525,10 @@ async function listPaginated( 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 } /** @@ -490,10 +543,15 @@ export async function listSessionEvents( sessionId: string types?: string[] /** - * Caps how many events are pulled into memory. Defaults to unbounded, which - * is what the run loop's catch-up needs — it must reach the tail to see the - * terminal event. Callers that surface events to a workflow should pass an - * explicit bound instead: a long session's history can be very large. + * 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 } @@ -506,14 +564,20 @@ export async function listSessionEvents( ...(types.length > 0 ? { searchParams: types.map((type): [string, string] => ['types[]', type.trim()]) } : {}), - maxItems: input.maxItems ?? Number.POSITIVE_INFINITY, + maxItems: Number.POSITIVE_INFINITY, }) // The list endpoint's page order is not guaranteed chronological, so order by // the server-side `processed_at` timestamp before returning. The catch-up // 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 maxItems = input.maxItems + // Slice AFTER ordering so the cap is "the newest N", independent of the order + // the API returned pages in. + return maxItems !== undefined && ordered.length > maxItems ? ordered.slice(-maxItems) : ordered } /** Epoch millis for a `processed_at`, or +Infinity when absent/queued/unparseable (sorts last). */ diff --git a/apps/sim/tools/managed_agent/get_session.ts b/apps/sim/tools/managed_agent/get_session.ts index 6c427cf2baf..1932a4040f6 100644 --- a/apps/sim/tools/managed_agent/get_session.ts +++ b/apps/sim/tools/managed_agent/get_session.ts @@ -124,7 +124,7 @@ export const managedAgentGetSessionTool: ToolConfig< pendingTools: { type: 'json', description: - 'Tool calls awaiting approval — [{id, eventType, name, input}]. Pass each id to Respond To Tool Confirmation.', + "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 }, diff --git a/apps/sim/tools/managed_agent/index.ts b/apps/sim/tools/managed_agent/index.ts index bd59e309879..a4e62fad19f 100644 --- a/apps/sim/tools/managed_agent/index.ts +++ b/apps/sim/tools/managed_agent/index.ts @@ -4,6 +4,7 @@ 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' diff --git a/apps/sim/tools/managed_agent/interrupt_session.ts b/apps/sim/tools/managed_agent/interrupt_session.ts index a920c07c3cc..0fd536e8225 100644 --- a/apps/sim/tools/managed_agent/interrupt_session.ts +++ b/apps/sim/tools/managed_agent/interrupt_session.ts @@ -13,6 +13,13 @@ import type { } 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. * @@ -49,7 +56,12 @@ export const managedAgentInterruptSessionTool: ToolConfig< apiKey: target.apiKey, sessionId: target.sessionId, events: [{ type: 'user.interrupt' }], - ...(signal ? { signal } : {}), + // 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) { diff --git a/apps/sim/tools/managed_agent/list_events.ts b/apps/sim/tools/managed_agent/list_events.ts index 479fdcc3802..63b1e71e854 100644 --- a/apps/sim/tools/managed_agent/list_events.ts +++ b/apps/sim/tools/managed_agent/list_events.ts @@ -56,14 +56,23 @@ export const managedAgentListEventsTool: ToolConfig< type: 'number', required: false, visibility: 'user-or-llm', - description: `Maximum events to return (default ${DEFAULT_EVENT_LIMIT}).`, + // 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: '' } + 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 } @@ -102,6 +111,9 @@ export const managedAgentListEventsTool: ToolConfig< events, count: events.length, assistantText, + // Signals that older events were dropped, so a caller reading history + // knows this is a tail rather than the whole session. + truncated: events.length >= maxItems, }, } } catch (error) { @@ -121,5 +133,9 @@ export const managedAgentListEventsTool: ToolConfig< 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..1fcbd570181 --- /dev/null +++ b/apps/sim/tools/managed_agent/respond_custom_tool.ts @@ -0,0 +1,116 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { sendCustomToolResults } from '@/lib/managed-agents/session-client' +import { isTruthyAck, 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 { + 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. + */ +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, + customToolUseIds: { + type: 'array', + required: true, + visibility: 'user-or-llm', + description: + "Custom tool-use EVENT ids, 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: '', answeredToolUseIds: [] as string[] } + const target = resolveSessionTarget(params) + if (!target.ok) { + return { success: false, output: emptyOutput, error: target.error } + } + + const customToolUseIds = normalizeStringList(params.customToolUseIds) + if (customToolUseIds.length === 0) { + return { + success: false, + output: { ...emptyOutput, sessionId: target.sessionId }, + error: + 'At least one custom tool-use event id is required. Read them from Get Session pendingTools[].id.', + } + } + + // The result may legitimately be empty (a tool that returns nothing), so + // only the id list 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: customToolUseIds.map((customToolUseId) => ({ + customToolUseId, + content: result, + isError, + })), + ...(signal ? { signal } : {}), + }) + return { + success: true, + output: { sessionId: target.sessionId, answeredToolUseIds: customToolUseIds }, + } + } 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.' }, + answeredToolUseIds: { + type: 'json', + description: 'The custom tool-use event ids that were answered.', + }, + }, +} diff --git a/apps/sim/tools/managed_agent/respond_tool_confirmation.ts b/apps/sim/tools/managed_agent/respond_tool_confirmation.ts index b9064bcf082..5156a74af19 100644 --- a/apps/sim/tools/managed_agent/respond_tool_confirmation.ts +++ b/apps/sim/tools/managed_agent/respond_tool_confirmation.ts @@ -45,7 +45,7 @@ export const managedAgentRespondToolConfirmationTool: ToolConfig< required: true, visibility: 'user-or-llm', description: - 'Blocking tool-use EVENT ids, from Get Session pendingTools[].id (not toolu_ ids).', + "Blocking tool-use EVENT ids, from Get Session pendingTools[].id where kind is 'confirmation' (not toolu_ ids).", }, decision: { type: 'string', diff --git a/apps/sim/tools/managed_agent/types.ts b/apps/sim/tools/managed_agent/types.ts index 0610b45fd56..50b846c90b7 100644 --- a/apps/sim/tools/managed_agent/types.ts +++ b/apps/sim/tools/managed_agent/types.ts @@ -68,10 +68,15 @@ export interface ManagedAgentSendMessageResponse extends ToolResponse { export interface ManagedAgentGetSessionParams extends ManagedAgentSessionOpParams {} -/** One tool call blocking an `always_ask` session. */ +/** 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 } @@ -107,6 +112,8 @@ export interface ManagedAgentListEventsResponse extends ToolResponse { 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 } } @@ -138,6 +145,19 @@ export interface ManagedAgentToolConfirmationResponse extends ToolResponse { output: { sessionId: string; decision: string; confirmedToolUseIds: string[] } } +export interface ManagedAgentCustomToolResultParams extends ManagedAgentSessionOpParams { + /** Blocking custom tool-use EVENT ids (array, JSON string, or comma list). */ + customToolUseIds?: unknown + /** 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; answeredToolUseIds: string[] } +} + export interface ManagedAgentArchiveSessionParams extends ManagedAgentSessionOpParams {} export interface ManagedAgentArchiveSessionResponse extends ToolResponse { diff --git a/apps/sim/tools/managed_agent/update_session.ts b/apps/sim/tools/managed_agent/update_session.ts index 846fcd66ae6..99737877047 100644 --- a/apps/sim/tools/managed_agent/update_session.ts +++ b/apps/sim/tools/managed_agent/update_session.ts @@ -60,7 +60,11 @@ export const managedAgentUpdateSessionTool: ToolConfig< return { success: false, output: { sessionId: '', updated: false }, error: target.error } } - const title = params.title?.trim() + // 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 const metadata = normalizeSessionParameters(params.sessionParameters) if (title === undefined && metadata === undefined) { return { diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 1970d0c3597..329a51f793c 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -2484,6 +2484,7 @@ import { managedAgentGetSessionTool, managedAgentInterruptSessionTool, managedAgentListEventsTool, + managedAgentRespondCustomToolTool, managedAgentRespondToolConfirmationTool, managedAgentRunSessionTool, managedAgentSendMessageTool, @@ -5528,6 +5529,7 @@ export const tools: Record = { 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, From cac2c595195811790cb30750f3082bcbd2aad644 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 31 Jul 2026 16:54:07 -0700 Subject: [PATCH 3/7] fix(managed-agent): correct truncation flag, gate lookup, custom tool result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `truncated` was true whenever the history size equalled the limit, even though nothing was dropped. Event reads now report the untrimmed total and the flag compares against that. - Pending-gate enrichment capped its read, which keeps the OLDEST events in page order — the opposite of where blocking gates live. It now filters to the ids being looked up as pages arrive, which is both correct regardless of page order and bounded by the id count. Paging continues on the raw page so a fully-filtered page is not mistaken for the end of the list. - Respond To Custom Tool applied one result to every id, so multiple pending tools would all receive the same output. It now answers a single call per invocation. --- .../docs/en/integrations/managed_agent.mdx | 4 +- apps/sim/blocks/blocks/managed_agent.test.ts | 4 +- apps/sim/blocks/blocks/managed_agent.ts | Bin 20160 -> 20199 bytes .../lib/managed-agents/session-client.test.ts | 70 ++++++++++++++++++ apps/sim/lib/managed-agents/session-client.ts | 52 ++++++++++--- apps/sim/tools/managed_agent/list_events.ts | 11 +-- .../managed_agent/respond_custom_tool.ts | 37 ++++----- apps/sim/tools/managed_agent/types.ts | 6 +- 8 files changed, 145 insertions(+), 39 deletions(-) diff --git a/apps/docs/content/docs/en/integrations/managed_agent.mdx b/apps/docs/content/docs/en/integrations/managed_agent.mdx index 7f832f05cf3..83cc4e4d175 100644 --- a/apps/docs/content/docs/en/integrations/managed_agent.mdx +++ b/apps/docs/content/docs/en/integrations/managed_agent.mdx @@ -216,7 +216,7 @@ Return the result of a custom tool a Managed Agent session is waiting on so it c | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `customToolUseIds` | array | Yes | Custom tool-use EVENT ids, from Get Session pendingTools\[\].id where kind is 'custom_tool_result'. | +| `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. | @@ -225,7 +225,7 @@ Return the result of a custom tool a Managed Agent session is waiting on so it c | Parameter | Type | Description | | --------- | ---- | ----------- | | `sessionId` | string | The session that was answered. | -| `answeredToolUseIds` | json | The custom tool-use event ids that were answered. | +| `answeredToolUseId` | string | The custom tool-use event id that was answered. | ### `managed_agent_archive_session` diff --git a/apps/sim/blocks/blocks/managed_agent.test.ts b/apps/sim/blocks/blocks/managed_agent.test.ts index 7d565fd7866..26f5a1e2140 100644 --- a/apps/sim/blocks/blocks/managed_agent.test.ts +++ b/apps/sim/blocks/blocks/managed_agent.test.ts @@ -160,8 +160,8 @@ describe('Managed Agent block — per-operation field visibility', () => { // 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('customToolUseIds', { operation: 'respond_custom_tool' })).toBe(true) - expect(isVisible('customToolUseIds', { operation: 'respond_tool_confirmation' })).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) }) diff --git a/apps/sim/blocks/blocks/managed_agent.ts b/apps/sim/blocks/blocks/managed_agent.ts index a008ab0bce02eb8c1e153087891bceeec9d9cec8..39f91e942a0def681510b109c20113ff0f9456d8 100644 GIT binary patch delta 130 zcmV-|0Db?!odM^a0kGsLljSKXlam`tlUXWW22XBfAZcWi`z9=t_9`MnAVpzgXdrN9 zZe(e0XCQQMZ)_lFVRIm9baNnYcWxkZWpj0GbSxlqZy;fAb9ZHOAarPDZ6I%MWgua6 kAYmYMX>Dbb;UyfiGb=3`lVL?J15{{blb%Hxv$#bF3Fd?_B>(^b delta 89 zcmV-f0H*)vodLj|0kGsLlLjUdll3VWlPM;BlP@e3lkO}IlfN1Ylh`H@18HP)le;QT vlj0>CvuG { 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('short-circuits with no ids', async () => { const spy = vi.fn() as unknown as typeof fetch global.fetch = spy @@ -538,6 +586,28 @@ describe('listSessionEvents — bounded reads', () => { 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('returns the whole history when uncapped', async () => { global.fetch = pagedFetch(3) const events = await listSessionEvents({ apiKey: 'sk-ant-fake', sessionId: 'sesn_1' }) diff --git a/apps/sim/lib/managed-agents/session-client.ts b/apps/sim/lib/managed-agents/session-client.ts index b2148886114..5e85a59725b 100644 --- a/apps/sim/lib/managed-agents/session-client.ts +++ b/apps/sim/lib/managed-agents/session-client.ts @@ -375,9 +375,6 @@ function gateKindFor(eventType: string | undefined): PendingToolGateKind | undef return undefined } -/** Upper bound on tool-use events scanned when naming pending gates. */ -const MAX_GATE_LOOKUP_EVENTS = 2000 - /** Event types that can block a session pending a client response. */ const TOOL_USE_EVENT_TYPES = [ 'agent.tool_use', @@ -405,10 +402,12 @@ export async function resolvePendingToolGates( ...(input.signal ? { signal: input.signal } : {}), path: `/v1/sessions/${input.sessionId}/events`, searchParams: TOOL_USE_EVENT_TYPES.map((type): [string, string] => ['types[]', type]), - // Bounded: this only needs to find a handful of ids, and an unbounded read - // of a long session's tool history is a needless memory cost. An id that - // falls outside the window still comes back unenriched, which is usable. - maxItems: MAX_GATE_LOOKUP_EVENTS, + // 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)), }) } catch { // Enrichment is best-effort — fall through to bare ids below. @@ -497,6 +496,13 @@ async function listPaginated( 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 } ): Promise { const collected: T[] = [] @@ -521,7 +527,9 @@ 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)) + // 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 } @@ -556,6 +564,28 @@ export async function listSessionEvents( 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, @@ -574,10 +604,14 @@ export async function listSessionEvents( const ordered = events.sort( (a, b) => parseProcessedAt(a.processed_at) - parseProcessedAt(b.processed_at) ) + const total = ordered.length const maxItems = input.maxItems // Slice AFTER ordering so the cap is "the newest N", independent of the order // the API returned pages in. - return maxItems !== undefined && ordered.length > maxItems ? ordered.slice(-maxItems) : ordered + return { + events: maxItems !== undefined && total > maxItems ? ordered.slice(-maxItems) : ordered, + total, + } } /** Epoch millis for a `processed_at`, or +Infinity when absent/queued/unparseable (sorts last). */ diff --git a/apps/sim/tools/managed_agent/list_events.ts b/apps/sim/tools/managed_agent/list_events.ts index 63b1e71e854..1f73ff28246 100644 --- a/apps/sim/tools/managed_agent/list_events.ts +++ b/apps/sim/tools/managed_agent/list_events.ts @@ -1,5 +1,5 @@ import { getErrorMessage } from '@sim/utils/errors' -import { listSessionEvents } from '@/lib/managed-agents/session-client' +import { listSessionEventsPage } from '@/lib/managed-agents/session-client' import { normalizeStringList } from '@/tools/managed_agent/normalizers' import { ACCESS_TOKEN_PARAM, @@ -86,7 +86,7 @@ export const managedAgentListEventsTool: ToolConfig< Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : DEFAULT_EVENT_LIMIT try { - const events = await listSessionEvents({ + const { events, total } = await listSessionEventsPage({ apiKey: target.apiKey, sessionId: target.sessionId, maxItems, @@ -111,9 +111,10 @@ export const managedAgentListEventsTool: ToolConfig< events, count: events.length, assistantText, - // Signals that older events were dropped, so a caller reading history - // knows this is a tail rather than the whole session. - truncated: events.length >= maxItems, + // 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) { diff --git a/apps/sim/tools/managed_agent/respond_custom_tool.ts b/apps/sim/tools/managed_agent/respond_custom_tool.ts index 1fcbd570181..0f96d269cd6 100644 --- a/apps/sim/tools/managed_agent/respond_custom_tool.ts +++ b/apps/sim/tools/managed_agent/respond_custom_tool.ts @@ -1,6 +1,6 @@ import { getErrorMessage } from '@sim/utils/errors' import { sendCustomToolResults } from '@/lib/managed-agents/session-client' -import { isTruthyAck, normalizeStringList } from '@/tools/managed_agent/normalizers' +import { isTruthyAck } from '@/tools/managed_agent/normalizers' import { ACCESS_TOKEN_PARAM, CREDENTIAL_PARAM, @@ -22,6 +22,11 @@ import type { ToolConfig } from '@/tools/types' * 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, @@ -37,12 +42,12 @@ export const managedAgentRespondCustomToolTool: ToolConfig< credential: CREDENTIAL_PARAM, accessToken: ACCESS_TOKEN_PARAM, sessionId: SESSION_ID_PARAM, - customToolUseIds: { - type: 'array', + customToolUseId: { + type: 'string', required: true, visibility: 'user-or-llm', description: - "Custom tool-use EVENT ids, from Get Session pendingTools[].id where kind is 'custom_tool_result'.", + "The custom tool-use EVENT id being answered, from Get Session pendingTools[].id where kind is 'custom_tool_result'.", }, result: { type: 'string', @@ -61,24 +66,24 @@ export const managedAgentRespondCustomToolTool: ToolConfig< request: UNUSED_REQUEST, directExecution: async (params, signal): Promise => { - const emptyOutput = { sessionId: '', answeredToolUseIds: [] as string[] } + const emptyOutput = { sessionId: '', answeredToolUseId: '' } const target = resolveSessionTarget(params) if (!target.ok) { return { success: false, output: emptyOutput, error: target.error } } - const customToolUseIds = normalizeStringList(params.customToolUseIds) - if (customToolUseIds.length === 0) { + const customToolUseId = params.customToolUseId?.trim() + if (!customToolUseId) { return { success: false, output: { ...emptyOutput, sessionId: target.sessionId }, error: - 'At least one custom tool-use event id is required. Read them from Get Session pendingTools[].id.', + '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 list is required — an absent result is sent as an empty string. + // only the id is required — an absent result is sent as an empty string. const result = (params.result ?? '').toString() const isError = isTruthyAck(params.isError) @@ -86,16 +91,12 @@ export const managedAgentRespondCustomToolTool: ToolConfig< await sendCustomToolResults({ apiKey: target.apiKey, sessionId: target.sessionId, - results: customToolUseIds.map((customToolUseId) => ({ - customToolUseId, - content: result, - isError, - })), + results: [{ customToolUseId, content: result, isError }], ...(signal ? { signal } : {}), }) return { success: true, - output: { sessionId: target.sessionId, answeredToolUseIds: customToolUseIds }, + output: { sessionId: target.sessionId, answeredToolUseId: customToolUseId }, } } catch (error) { return { @@ -108,9 +109,9 @@ export const managedAgentRespondCustomToolTool: ToolConfig< outputs: { sessionId: { type: 'string', description: 'The session that was answered.' }, - answeredToolUseIds: { - type: 'json', - description: 'The custom tool-use event ids that were answered.', + answeredToolUseId: { + type: 'string', + description: 'The custom tool-use event id that was answered.', }, }, } diff --git a/apps/sim/tools/managed_agent/types.ts b/apps/sim/tools/managed_agent/types.ts index 50b846c90b7..ba2f5da2706 100644 --- a/apps/sim/tools/managed_agent/types.ts +++ b/apps/sim/tools/managed_agent/types.ts @@ -146,8 +146,8 @@ export interface ManagedAgentToolConfirmationResponse extends ToolResponse { } export interface ManagedAgentCustomToolResultParams extends ManagedAgentSessionOpParams { - /** Blocking custom tool-use EVENT ids (array, JSON string, or comma list). */ - customToolUseIds?: unknown + /** 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. */ @@ -155,7 +155,7 @@ export interface ManagedAgentCustomToolResultParams extends ManagedAgentSessionO } export interface ManagedAgentCustomToolResultResponse extends ToolResponse { - output: { sessionId: string; answeredToolUseIds: string[] } + output: { sessionId: string; answeredToolUseId: string } } export interface ManagedAgentArchiveSessionParams extends ManagedAgentSessionOpParams {} From 2b9243f2b288906ed42877aec2fd4470c3a16df1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 31 Jul 2026 17:05:30 -0700 Subject: [PATCH 4/7] fix(managed-agent): stop fractional event limits reading unbounded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A limit below 1 passed the positivity check and then floored to 0, which made `slice(-0)` hand back the ENTIRE history flagged as complete — the opposite of the requested bound. The limit is now floored before it is validated, so anything that does not resolve to a positive integer falls back to the default. Also hardened the library: a zero or negative cap short-circuits to an empty result instead of falling through to `slice(-0)`, so no future caller can hit the same trap. --- .../lib/managed-agents/session-client.test.ts | 21 +++++++ apps/sim/lib/managed-agents/session-client.ts | 12 ++-- .../tools/managed_agent/list_events.test.ts | 62 +++++++++++++++++++ apps/sim/tools/managed_agent/list_events.ts | 11 ++-- 4 files changed, 96 insertions(+), 10 deletions(-) create mode 100644 apps/sim/tools/managed_agent/list_events.test.ts diff --git a/apps/sim/lib/managed-agents/session-client.test.ts b/apps/sim/lib/managed-agents/session-client.test.ts index f1197403598..9ab47810d58 100644 --- a/apps/sim/lib/managed-agents/session-client.test.ts +++ b/apps/sim/lib/managed-agents/session-client.test.ts @@ -608,6 +608,27 @@ describe('listSessionEvents — bounded reads', () => { 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('returns the whole history when uncapped', async () => { global.fetch = pagedFetch(3) const events = await listSessionEvents({ apiKey: 'sk-ant-fake', sessionId: 'sesn_1' }) diff --git a/apps/sim/lib/managed-agents/session-client.ts b/apps/sim/lib/managed-agents/session-client.ts index 5e85a59725b..1b2b6eba837 100644 --- a/apps/sim/lib/managed-agents/session-client.ts +++ b/apps/sim/lib/managed-agents/session-client.ts @@ -606,12 +606,14 @@ export async function listSessionEventsPage( ) const total = ordered.length const maxItems = input.maxItems - // Slice AFTER ordering so the cap is "the newest N", independent of the order - // the API returned pages in. - return { - events: maxItems !== undefined && total > maxItems ? ordered.slice(-maxItems) : ordered, - total, + if (maxItems === undefined || Number.isNaN(maxItems) || 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. A zero or negative cap short-circuits because + // `slice(-0)` is `slice(0)` — it would hand back the ENTIRE history for what + // the caller asked to be the tightest possible bound. + return { events: maxItems <= 0 ? [] : ordered.slice(-maxItems), total } } /** Epoch millis for a `processed_at`, or +Infinity when absent/queued/unparseable (sorts last). */ 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 index 1f73ff28246..d2858ddbd75 100644 --- a/apps/sim/tools/managed_agent/list_events.ts +++ b/apps/sim/tools/managed_agent/list_events.ts @@ -79,11 +79,12 @@ export const managedAgentListEventsTool: ToolConfig< } const types = normalizeStringList(params.eventTypes) - // A non-numeric or non-positive limit falls back to the default rather than - // silently becoming an unbounded (or empty) read. - const requested = Number(params.limit) - const maxItems = - Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : DEFAULT_EVENT_LIMIT + // 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({ From ebfde346f443ad498c9f92beca2c2ecb63b9ae41 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 31 Jul 2026 17:18:16 -0700 Subject: [PATCH 5/7] fix(managed-agent): stop gate lookup scanning the full tool history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The id filter keeps the collected array tiny, so `maxItems` never trips and the walk continued to the end of a session's tool history even after every blocking id had been found. `listPaginated` now takes a `stopWhen` predicate and the gate lookup ends as soon as it has all the ids it came for. Also makes a blocked-but-unnamed session observable: when a session reports `requires_action` with no blocking event ids, `requiresAction` stays true — reporting false would tell a workflow the session is fine while it is parked indefinitely — and the dead end is logged and documented instead. --- .../docs/en/integrations/managed_agent.mdx | 2 +- .../lib/managed-agents/session-client.test.ts | 22 +++++++++++++++++++ apps/sim/lib/managed-agents/session-client.ts | 12 ++++++++++ apps/sim/tools/managed_agent/get_session.ts | 17 +++++++++++++- 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/apps/docs/content/docs/en/integrations/managed_agent.mdx b/apps/docs/content/docs/en/integrations/managed_agent.mdx index 83cc4e4d175..353f7c363f1 100644 --- a/apps/docs/content/docs/en/integrations/managed_agent.mdx +++ b/apps/docs/content/docs/en/integrations/managed_agent.mdx @@ -124,7 +124,7 @@ Read a Managed Agent session: status, stop reason, token usage, metadata, and an | `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. | +| `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. | diff --git a/apps/sim/lib/managed-agents/session-client.test.ts b/apps/sim/lib/managed-agents/session-client.test.ts index 9ab47810d58..ef124ae2b65 100644 --- a/apps/sim/lib/managed-agents/session-client.test.ts +++ b/apps/sim/lib/managed-agents/session-client.test.ts @@ -528,6 +528,28 @@ describe('resolvePendingToolGates', () => { 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 diff --git a/apps/sim/lib/managed-agents/session-client.ts b/apps/sim/lib/managed-agents/session-client.ts index 1b2b6eba837..c8608a55378 100644 --- a/apps/sim/lib/managed-agents/session-client.ts +++ b/apps/sim/lib/managed-agents/session-client.ts @@ -408,6 +408,10 @@ export async function resolvePendingToolGates( // 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. @@ -503,6 +507,13 @@ async function listPaginated( * 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[] = [] @@ -528,6 +539,7 @@ async function listPaginated( const body = (await resp.json()) as AnthropicListPage const items = Array.isArray(body.data) ? body.data : [] 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 diff --git a/apps/sim/tools/managed_agent/get_session.ts b/apps/sim/tools/managed_agent/get_session.ts index 1932a4040f6..ebca831595f 100644 --- a/apps/sim/tools/managed_agent/get_session.ts +++ b/apps/sim/tools/managed_agent/get_session.ts @@ -1,3 +1,4 @@ +import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { resolvePendingToolGates, retrieveSession } from '@/lib/managed-agents/session-client' import { @@ -14,6 +15,8 @@ import type { } 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' @@ -79,6 +82,17 @@ export const managedAgentGetSessionTool: ToolConfig< }) : [] + // 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: { @@ -119,7 +133,8 @@ export const managedAgentGetSessionTool: ToolConfig< }, requiresAction: { type: 'boolean', - description: 'True when the session is waiting on a tool confirmation or custom tool result.', + 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', From d4046c2f9d030dc0300857694ed0e405ed46a35d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 31 Jul 2026 17:29:10 -0700 Subject: [PATCH 6/7] fix(managed-agent): floor event cap and make metadata clearing explicit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A `maxItems` between 0 and 1 slipped past the zero guard and became `slice(-0)` — the whole history — because slice truncates its index toward zero. The cap is now floored at the library boundary, so no caller can hit it whatever they pass. - Update Session documented full metadata replacement but could not express a clear: an empty map normalizes to "absent". Inferring the clear from emptiness would be worse, since an untouched table is also empty and would wipe metadata on every title-only update. Adds an explicit `clearMetadata` instead, and corrects the parameter's documentation. --- .../docs/en/integrations/managed_agent.mdx | 3 +- apps/sim/blocks/blocks/managed_agent.ts | Bin 20199 -> 20631 bytes .../lib/managed-agents/session-client.test.ts | 26 ++++++++ apps/sim/lib/managed-agents/session-client.ts | 16 +++-- apps/sim/tools/managed_agent/types.ts | 2 + .../managed_agent/update_session.test.ts | 63 ++++++++++++++++++ .../sim/tools/managed_agent/update_session.ts | 26 ++++++-- 7 files changed, 124 insertions(+), 12 deletions(-) create mode 100644 apps/sim/tools/managed_agent/update_session.test.ts diff --git a/apps/docs/content/docs/en/integrations/managed_agent.mdx b/apps/docs/content/docs/en/integrations/managed_agent.mdx index 353f7c363f1..dd652b99989 100644 --- a/apps/docs/content/docs/en/integrations/managed_agent.mdx +++ b/apps/docs/content/docs/en/integrations/managed_agent.mdx @@ -161,7 +161,8 @@ Update a Managed Agent session's title or metadata. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `title` | string | No | New session title. | -| `sessionParameters` | object | No | Replacement metadata map \(replaces all stored metadata, not merged\). | +| `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 diff --git a/apps/sim/blocks/blocks/managed_agent.ts b/apps/sim/blocks/blocks/managed_agent.ts index 39f91e942a0def681510b109c20113ff0f9456d8..9088719af5e423a1125c195e003648696b881c26 100644 GIT binary patch delta 267 zcmaDpmvQ<+#tq@RI>|YyiABDtC5b7CC5h@fTnb=Nl39|IYNep=43bsIg-TCu)Cru- zt0zABjo5OVtBzTbBIN8@z f&y6I@74q{GLJNTYOH~MlI8aXs?9t72u9=(w@FraB delta 19 bcmbQfkn#Ck#tq@Ro9pxpBsQD7CvXA)QUnJy diff --git a/apps/sim/lib/managed-agents/session-client.test.ts b/apps/sim/lib/managed-agents/session-client.test.ts index ef124ae2b65..4cf6184c9d5 100644 --- a/apps/sim/lib/managed-agents/session-client.test.ts +++ b/apps/sim/lib/managed-agents/session-client.test.ts @@ -651,6 +651,32 @@ describe('listSessionEvents — bounded reads', () => { 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' }) diff --git a/apps/sim/lib/managed-agents/session-client.ts b/apps/sim/lib/managed-agents/session-client.ts index c8608a55378..aa3a6c7643e 100644 --- a/apps/sim/lib/managed-agents/session-client.ts +++ b/apps/sim/lib/managed-agents/session-client.ts @@ -617,15 +617,19 @@ export async function listSessionEventsPage( (a, b) => parseProcessedAt(a.processed_at) - parseProcessedAt(b.processed_at) ) const total = ordered.length - const maxItems = input.maxItems - if (maxItems === undefined || Number.isNaN(maxItems) || total <= maxItems) { + 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. A zero or negative cap short-circuits because - // `slice(-0)` is `slice(0)` — it would hand back the ENTIRE history for what - // the caller asked to be the tightest possible bound. - return { events: maxItems <= 0 ? [] : ordered.slice(-maxItems), total } + // 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). */ diff --git a/apps/sim/tools/managed_agent/types.ts b/apps/sim/tools/managed_agent/types.ts index ba2f5da2706..d35e39d6ffc 100644 --- a/apps/sim/tools/managed_agent/types.ts +++ b/apps/sim/tools/managed_agent/types.ts @@ -120,6 +120,8 @@ export interface ManagedAgentListEventsResponse extends ToolResponse { 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 { 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 index 99737877047..2520405148a 100644 --- a/apps/sim/tools/managed_agent/update_session.ts +++ b/apps/sim/tools/managed_agent/update_session.ts @@ -1,6 +1,6 @@ import { getErrorMessage } from '@sim/utils/errors' import { updateSession } from '@/lib/managed-agents/session-client' -import { normalizeSessionParameters } from '@/tools/managed_agent/normalizers' +import { isTruthyAck, normalizeSessionParameters } from '@/tools/managed_agent/normalizers' import { ACCESS_TOKEN_PARAM, CREDENTIAL_PARAM, @@ -23,7 +23,9 @@ import type { ToolConfig } from '@/tools/types' * 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. + * 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, @@ -48,7 +50,15 @@ export const managedAgentUpdateSessionTool: ToolConfig< type: 'object', required: false, visibility: 'user-or-llm', - description: 'Replacement metadata map (replaces all stored metadata, not merged).', + 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.", }, }, @@ -65,12 +75,18 @@ export const managedAgentUpdateSessionTool: ToolConfig< // both slip past the guard below and silently clear an existing title. const trimmedTitle = params.title?.trim() const title = trimmedTitle ? trimmedTitle : undefined - const metadata = normalizeSessionParameters(params.sessionParameters) + + // 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.', + error: 'Provide a title or metadata to update, or check "Clear metadata".', } } From 30788ec03eb704de368ba1462646361484ca0aa1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 31 Jul 2026 17:50:57 -0700 Subject: [PATCH 7/7] test(managed-agent): pin the HTTP shape of every session endpoint Method, URL, and beta header for all 11 calls, plus the SSE accept header, the separate memory-store beta (combining the two is a documented 400), and content-type only on requests that carry a body. These are the details types cannot catch and that break silently when a path is "tidied". --- .../lib/managed-agents/wire-shapes.test.ts | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 apps/sim/lib/managed-agents/wire-shapes.test.ts 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() + }) +})