Skip to content

Commit 81ae4b1

Browse files
committed
fix(managed-agents): propagate cancellation, fix reconnect ordering, classify advanced fields
- Thread the executor abort signal into directExecution (additive ToolConfig change) so a cancelled workflow stops the session immediately; best-effort user.interrupt releases the Anthropic session past cancel/wall-clock cap - Recompute the requires_action pending state from the chronological history so an older agent.message recovered on catch-up can't clear a newer pause - Retry a custom-tool error reply that failed to send instead of stranding the session (mark the event seen only once handled) - Mark optional fields (vaults, memory, files, metadata) mode: advanced - Title the session with the workflow id for Claude-console traceability
1 parent 6898f40 commit 81ae4b1

8 files changed

Lines changed: 199 additions & 17 deletions

File tree

apps/sim/blocks/blocks/managed_agent.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export const ManagedAgentBlock: BlockConfig = {
7777
title: 'Credential vaults',
7878
type: 'dropdown',
7979
required: false,
80+
mode: 'advanced',
8081
placeholder: 'Optional — pick zero or more OAuth vaults',
8182
searchable: true,
8283
multiSelect: true,
@@ -90,13 +91,15 @@ export const ManagedAgentBlock: BlockConfig = {
9091
'I own or am authorized to use these vaults. I understand this means this agent can assume the identity granted by them.',
9192
type: 'switch',
9293
required: false,
94+
mode: 'advanced',
9395
description: 'Required when at least one vault is selected above.',
9496
},
9597
{
9698
id: 'memoryStoreId',
9799
title: 'Memory store',
98100
type: 'combobox',
99101
required: false,
102+
mode: 'advanced',
100103
placeholder: 'Optional — pick a memory store',
101104
commandSearchable: true,
102105
options: [],
@@ -108,6 +111,7 @@ export const ManagedAgentBlock: BlockConfig = {
108111
title: 'Memory access',
109112
type: 'dropdown',
110113
required: false,
114+
mode: 'advanced',
111115
options: [
112116
{ label: 'Read + write (default)', id: 'read_write' },
113117
{ label: 'Read only', id: 'read_only' },
@@ -121,6 +125,7 @@ export const ManagedAgentBlock: BlockConfig = {
121125
title: 'Memory instructions',
122126
type: 'long-input',
123127
required: false,
128+
mode: 'advanced',
124129
placeholder: 'Optional — how the agent should use this memory store',
125130
condition: { field: 'memoryStoreId', value: '', not: true },
126131
description: 'Per-attachment guidance rendered into the memory section of the system prompt.',
@@ -130,6 +135,7 @@ export const ManagedAgentBlock: BlockConfig = {
130135
title: 'Files',
131136
type: 'table',
132137
required: false,
138+
mode: 'advanced',
133139
columns: ['File ID', 'Mount path'],
134140
description:
135141
'Files-API file ids (file_...) to attach as file resources (cloud environments). Mount path is optional.',
@@ -139,6 +145,7 @@ export const ManagedAgentBlock: BlockConfig = {
139145
title: 'Metadata',
140146
type: 'table',
141147
required: false,
148+
mode: 'advanced',
142149
columns: ['Key', 'Value'],
143150
description:
144151
'Optional key/value metadata forwarded on the session. On self-hosted environments each key is exposed to the agent as an env var.',

apps/sim/lib/managed-agents/run-session.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const { mocks } = vi.hoisted(() => ({
1212
openSessionStream: vi.fn(),
1313
listSessionEvents: vi.fn(),
1414
getSession: vi.fn(),
15+
interruptSession: vi.fn(),
1516
readSSEEvents: vi.fn(),
1617
sleep: vi.fn(),
1718
},
@@ -24,6 +25,7 @@ vi.mock('@/lib/managed-agents/session-client', () => ({
2425
openSessionStream: mocks.openSessionStream,
2526
listSessionEvents: mocks.listSessionEvents,
2627
getSession: mocks.getSession,
28+
interruptSession: mocks.interruptSession,
2729
}))
2830
vi.mock('@/lib/core/utils/sse', () => ({ readSSEEvents: mocks.readSSEEvents }))
2931
vi.mock('@sim/utils/helpers', () => ({ sleep: mocks.sleep }))
@@ -66,9 +68,17 @@ beforeEach(() => {
6668
vi.clearAllMocks()
6769
mocks.createSession.mockResolvedValue({ id: 'sess_1' })
6870
mocks.sendUserMessage.mockResolvedValue(undefined)
71+
mocks.sendSessionEvents.mockResolvedValue(undefined)
6972
mocks.openSessionStream.mockResolvedValue({})
7073
mocks.listSessionEvents.mockResolvedValue([])
7174
mocks.getSession.mockResolvedValue(null)
75+
mocks.interruptSession.mockResolvedValue(undefined)
76+
})
77+
78+
const customToolUse = (id: string, name: string): AnthropicSessionEvent => ({
79+
id,
80+
type: 'agent.custom_tool_use',
81+
name,
7282
})
7383

7484
describe('runManagedAgentSession', () => {
@@ -163,6 +173,66 @@ describe('runManagedAgentSession', () => {
163173
expect(result.sessionId).toBe('sess_1')
164174
})
165175

176+
it('keeps requires_action pending when catch-up recovers an older message (ordering)', async () => {
177+
// Live stream sees a message then a requires_action pause. Catch-up then
178+
// recovers an EARLIER, unseen agent.message — processing it would clear the
179+
// pending flag, but the history's latest lifecycle event is still
180+
// requires_action, so the session must NOT be reported complete.
181+
scriptStreamBatches([
182+
[msg('e1', 'hi '), idle('r1', 'requires_action')],
183+
[idle('e2', 'end_turn')],
184+
])
185+
mocks.listSessionEvents.mockResolvedValueOnce([
186+
msg('e0', 'earlier'), // unseen, older than r1
187+
idle('r1', 'requires_action'), // latest lifecycle event in history
188+
])
189+
mocks.getSession.mockResolvedValue({ status: 'idle' })
190+
191+
const result = await runManagedAgentSession({ ...BASE })
192+
193+
expect(result.ok).toBe(true)
194+
// Reopened rather than completing early on the idle snapshot.
195+
expect(mocks.openSessionStream).toHaveBeenCalledTimes(2)
196+
})
197+
198+
it('retries a custom-tool reply that failed to send instead of stranding the session', async () => {
199+
// Stream 1: a custom tool call whose error reply fails to send — the event
200+
// must stay unseen. Reconnect (status running) → reopen. Stream 2: the same
201+
// tool call is retried (reply succeeds), then end_turn completes.
202+
scriptStreamBatches([
203+
[customToolUse('t1', 'foo')],
204+
[customToolUse('t1', 'foo'), idle('e2', 'end_turn')],
205+
])
206+
mocks.sendSessionEvents.mockRejectedValueOnce(new Error('network')).mockResolvedValue(undefined)
207+
mocks.getSession
208+
.mockResolvedValueOnce({ status: 'running' })
209+
.mockResolvedValue({ status: 'idle' })
210+
211+
const result = await runManagedAgentSession({ ...BASE })
212+
213+
expect(result.ok).toBe(true)
214+
// Reply attempted twice: the failed send was retried on reconnect.
215+
expect(mocks.sendSessionEvents).toHaveBeenCalledTimes(2)
216+
})
217+
218+
it('interrupts the session and reports aborted when the workflow is cancelled', async () => {
219+
const controller = new AbortController()
220+
// Cancel right after the session is created, before the stream loop runs.
221+
mocks.sendUserMessage.mockImplementation(async () => {
222+
controller.abort()
223+
})
224+
scriptStreamBatches([[]])
225+
226+
const result = await runManagedAgentSession({ ...BASE, signal: controller.signal })
227+
228+
expect(result.ok).toBe(false)
229+
expect(result.error).toBe('aborted')
230+
expect(mocks.interruptSession).toHaveBeenCalledWith({
231+
apiKey: BASE.apiKey,
232+
sessionId: 'sess_1',
233+
})
234+
})
235+
166236
it('rejects an empty user message before creating a session', async () => {
167237
const result = await runManagedAgentSession({ ...BASE, userMessage: ' ' })
168238
expect(result.ok).toBe(false)

apps/sim/lib/managed-agents/run-session.ts

Lines changed: 79 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
type CreateSessionInput,
88
createSession,
99
getSession,
10+
interruptSession,
1011
listSessionEvents,
1112
openSessionStream,
1213
sendSessionEvents,
@@ -59,6 +60,39 @@ export interface RunManagedAgentResult {
5960
}
6061

6162
type Terminal = { status: 'complete' | 'error'; reason?: string }
63+
/** Result of handling one event: an optional terminal signal, plus whether the event must be retried. */
64+
type HandleResult = { terminal?: Terminal; retry?: boolean }
65+
66+
/**
67+
* The most recent lifecycle event (`session.status_*` / `session.error`) in a
68+
* chronological history, or `undefined`. The events list is authoritative and
69+
* ordered, so the last such event reflects the session's current state — used
70+
* to recompute the pending-action state without depending on the order events
71+
* happened to arrive across the live stream and catch-up.
72+
*/
73+
function findLastLifecycleEvent(
74+
history: AnthropicSessionEvent[]
75+
): AnthropicSessionEvent | undefined {
76+
for (let i = history.length - 1; i >= 0; i--) {
77+
const type = history[i].type
78+
if (type && (type.startsWith('session.status_') || type === 'session.error')) {
79+
return history[i]
80+
}
81+
}
82+
return undefined
83+
}
84+
85+
/** Best-effort `user.interrupt` for a session Sim is abandoning (cancel / cap). Never throws. */
86+
async function interruptQuietly(apiKey: string, sessionId: string): Promise<void> {
87+
try {
88+
await interruptSession({ apiKey, sessionId })
89+
} catch (err) {
90+
logger.warn('Failed to interrupt managed agent session on cancel', {
91+
sessionId,
92+
error: getErrorMessage(err),
93+
})
94+
}
95+
}
6296

6397
export async function runManagedAgentSession(
6498
input: RunManagedAgentInput
@@ -128,7 +162,6 @@ export async function runManagedAgentSession(
128162
let terminal: Terminal | null = null
129163

130164
const process = async (event: AnthropicSessionEvent): Promise<boolean> => {
131-
if (event.id) seenIds.add(event.id)
132165
if (
133166
event.type === 'agent.message' ||
134167
event.type === 'agent.custom_tool_use' ||
@@ -143,8 +176,12 @@ export async function runManagedAgentSession(
143176
requiresActionOutstanding = true
144177
}
145178
const outcome = await handleEvent({ event, assistantText, apiKey, sessionId, signal })
146-
if (outcome) {
147-
terminal = outcome
179+
// Mark the event seen only once fully handled. A custom-tool reply that
180+
// failed to send stays unseen so the next catch-up retries it instead of
181+
// stranding the session on an unanswered requires_action pause.
182+
if (event.id && !outcome.retry) seenIds.add(event.id)
183+
if (outcome.terminal) {
184+
terminal = outcome.terminal
148185
return true
149186
}
150187
return false
@@ -154,6 +191,9 @@ export async function runManagedAgentSession(
154191
for (let iteration = 0; iteration < MAX_RECONNECT_ITERATIONS && !terminal; iteration++) {
155192
if (signal?.aborted) break
156193
if (Date.now() - startedAt > MAX_SESSION_MS) {
194+
// Giving up on a session that may still be running — stop it so it
195+
// does not keep consuming the workspace key past our cap.
196+
await interruptQuietly(apiKey, sessionId)
157197
terminal = {
158198
status: 'error',
159199
reason: `Session did not reach a terminal state within ${Math.floor(MAX_SESSION_MS / 1000)}s.`,
@@ -191,6 +231,20 @@ export async function runManagedAgentSession(
191231
}
192232
if (terminal || signal?.aborted) break
193233

234+
// Recompute the pending-action state from the chronological history,
235+
// which is authoritative and ordered. This overrides any out-of-order
236+
// flag flip made while processing catch-up events — an older
237+
// `agent.message` recovered after the live stream must not clear a newer
238+
// `requires_action` pause and let a still-waiting session report done.
239+
// Falls back to the stream-tracked flag when the history carries no
240+
// lifecycle events (e.g. status changes not persisted to the list).
241+
const lastLifecycle = findLastLifecycleEvent(history)
242+
if (lastLifecycle) {
243+
requiresActionOutstanding =
244+
lastLifecycle.type === 'session.status_idle' &&
245+
lastLifecycle.stop_reason?.type === 'requires_action'
246+
}
247+
194248
// Still no terminal event. Consult the authoritative session status: a
195249
// finished session reports `idle`/`terminated`; a working one reports
196250
// `running`. `idle` counts as complete only once the agent has actually
@@ -216,6 +270,7 @@ export async function runManagedAgentSession(
216270
}
217271
} catch (error) {
218272
if (signal?.aborted) {
273+
await interruptQuietly(apiKey, sessionId)
219274
return { ok: false, content: assistantText.value, sessionId, error: 'aborted' }
220275
}
221276
logger.error('Managed agent stream failed', { sessionId, error: getErrorMessage(error) })
@@ -228,6 +283,7 @@ export async function runManagedAgentSession(
228283
}
229284

230285
if (signal?.aborted) {
286+
await interruptQuietly(apiKey, sessionId)
231287
return { ok: false, content: assistantText.value, sessionId, error: 'aborted' }
232288
}
233289
if (!terminal || terminal.status === 'error') {
@@ -260,7 +316,7 @@ async function handleEvent(args: {
260316
apiKey: string
261317
sessionId: string
262318
signal?: AbortSignal
263-
}): Promise<Terminal | null> {
319+
}): Promise<HandleResult> {
264320
const { event, assistantText, apiKey, sessionId, signal } = args
265321

266322
if (event.type === 'agent.message') {
@@ -271,7 +327,7 @@ async function handleEvent(args: {
271327
}
272328
}
273329
}
274-
return null
330+
return {}
275331
}
276332

277333
if (event.type === 'agent.custom_tool_use') {
@@ -281,7 +337,7 @@ async function handleEvent(args: {
281337
logger.warn('Managed Agent custom_tool_use arrived without an id — skipping reply', {
282338
sessionId,
283339
})
284-
return null
340+
return {}
285341
}
286342
logger.warn(
287343
`Managed Agent invoked a custom tool "${event.name ?? '<unknown>'}" that Sim does not provide — replying with error`
@@ -310,27 +366,37 @@ async function handleEvent(args: {
310366
sessionId,
311367
error: getErrorMessage(err),
312368
})
369+
// Leave the event unseen so the next catch-up retries the reply rather
370+
// than stranding the session on this unanswered tool call.
371+
return { retry: true }
313372
}
314-
return null
373+
return {}
315374
}
316375

317376
if (event.type === 'session.status_terminated') {
318-
return { status: 'error', reason: event.error?.message ?? 'session_terminated' }
377+
return { terminal: { status: 'error', reason: event.error?.message ?? 'session_terminated' } }
319378
}
320379

321380
if (event.type === 'session.status_idle') {
322381
const stop = event.stop_reason?.type
323382
// `requires_action` is not terminal — the session is paused for a pending
324383
// tool call and will emit a terminal event once it resolves.
325-
if (stop === 'requires_action') return null
326-
if (stop === 'retries_exhausted') return { status: 'error', reason: 'retries_exhausted' }
384+
if (stop === 'requires_action') return {}
385+
if (stop === 'retries_exhausted') {
386+
return { terminal: { status: 'error', reason: 'retries_exhausted' } }
387+
}
327388
// `end_turn` (or an unspecified idle) means the agent finished its turn.
328-
return { status: 'complete' }
389+
return { terminal: { status: 'complete' } }
329390
}
330391

331392
if (event.type === 'session.error') {
332-
return { status: 'error', reason: event.error?.message ?? event.message ?? 'session_error' }
393+
return {
394+
terminal: {
395+
status: 'error',
396+
reason: event.error?.message ?? event.message ?? 'session_error',
397+
},
398+
}
333399
}
334400

335-
return null
401+
return {}
336402
}

apps/sim/lib/managed-agents/session-client.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,12 @@ interface UserCustomToolResultEvent {
176176
is_error: boolean
177177
}
178178

179-
export type OutboundSessionEvent = UserMessageEvent | UserCustomToolResultEvent
179+
/** Stops a running session mid-execution; the session stays usable afterward. */
180+
interface UserInterruptEvent {
181+
type: 'user.interrupt'
182+
}
183+
184+
export type OutboundSessionEvent = UserMessageEvent | UserCustomToolResultEvent | UserInterruptEvent
180185

181186
/** POST /v1/sessions/{id}/events with a single `user.message`. */
182187
export async function sendUserMessage(
@@ -207,6 +212,28 @@ export async function sendSessionEvents(
207212
}
208213
}
209214

215+
/** Best-effort timeout for the fire-on-cancel interrupt (its own, since the run signal is already aborted). */
216+
const INTERRUPT_TIMEOUT_MS = 5000
217+
218+
/**
219+
* POST /v1/sessions/{id}/events with a `user.interrupt` — stops a session that
220+
* is still running so it stops consuming the workspace API key once Sim has
221+
* given up on it (workflow cancelled or wall-clock cap hit). Deliberately uses
222+
* its OWN short timeout rather than the run's abort signal, which is already
223+
* aborted by the time this fires.
224+
*/
225+
export async function interruptSession(input: {
226+
apiKey: string
227+
sessionId: string
228+
}): Promise<void> {
229+
await sendSessionEvents({
230+
apiKey: input.apiKey,
231+
sessionId: input.sessionId,
232+
events: [{ type: 'user.interrupt' }],
233+
signal: AbortSignal.timeout(INTERRUPT_TIMEOUT_MS),
234+
})
235+
}
236+
210237
/** GET /v1/sessions/{id}/events/stream — opens the SSE response. */
211238
export async function openSessionStream(
212239
input: SessionAuth & { sessionId: string }

apps/sim/tools/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1240,7 +1240,7 @@ export async function executeTool(
12401240
// Check for direct execution (no HTTP request needed)
12411241
if (tool.directExecution) {
12421242
logger.info(`[${requestId}] Using directExecution for ${toolId}`)
1243-
const result = await tool.directExecution(contextParams)
1243+
const result = await tool.directExecution(contextParams, effectiveSignal)
12441244

12451245
// Apply post-processing if available and not skipped
12461246
let finalResult = result

0 commit comments

Comments
 (0)