feat(managed-agent): add session lifecycle operations (#6140)

* 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.

* 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.

* fix(managed-agent): correct truncation flag, gate lookup, custom tool result

- `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.

* fix(managed-agent): stop fractional event limits reading unbounded

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.

* fix(managed-agent): stop gate lookup scanning the full tool history

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.

* fix(managed-agent): floor event cap and make metadata clearing explicit

- 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.

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