Skip to content

Commit ebfde34

Browse files
committed
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.
1 parent 2b9243f commit ebfde34

4 files changed

Lines changed: 51 additions & 2 deletions

File tree

apps/docs/content/docs/en/integrations/managed_agent.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ Read a Managed Agent session: status, stop reason, token usage, metadata, and an
124124
| `sessionId` | string | The session that was read. |
125125
| `status` | string | Session status — 'idle', 'running', 'rescheduling', or 'terminated'. |
126126
| `stopReason` | string | Why the session last stopped, e.g. 'end_turn' or 'requires_action'. |
127-
| `requiresAction` | boolean | True when the session is waiting on a tool confirmation or custom tool result. |
127+
| `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. |
128128
| `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. |
129129
| `metadata` | json | Session metadata. |
130130
| `title` | string | Session title. |

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,28 @@ describe('resolvePendingToolGates', () => {
528528
expect(gates[0]?.name).toBe('target')
529529
})
530530

531+
it('stops paging as soon as every wanted id is found', async () => {
532+
// The filter keeps `collected` tiny, so no cap can ever trip — without an
533+
// explicit stop the walk runs to the end of the tool history for nothing.
534+
let page = 0
535+
const spy = vi.fn(async () => {
536+
page += 1
537+
return Response.json({
538+
data: [{ id: page === 1 ? 'want' : `other${page}`, type: 'agent.tool_use', name: 'x' }],
539+
next_page: `c${page}`, // never null: only the stop condition ends this
540+
})
541+
}) as unknown as typeof fetch
542+
global.fetch = spy
543+
544+
const gates = await resolvePendingToolGates({
545+
apiKey: 'sk-ant-fake',
546+
sessionId: 'sesn_1',
547+
eventIds: ['want'],
548+
})
549+
expect(gates).toHaveLength(1)
550+
expect((spy as unknown as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(1)
551+
})
552+
531553
it('short-circuits with no ids', async () => {
532554
const spy = vi.fn() as unknown as typeof fetch
533555
global.fetch = spy

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,10 @@ export async function resolvePendingToolGates(
408408
// cap would drop. Filtering instead bounds memory to the id count while
409409
// staying correct however the API orders its pages.
410410
filter: (event) => Boolean(event.id && wanted.has(event.id)),
411+
// Every wanted id is found at most once, so once the count matches there
412+
// is nothing left to look for. Without this the filtered total never
413+
// reaches any cap and the walk runs to the end of the tool history.
414+
stopWhen: (found) => found.length >= wanted.size,
411415
})
412416
} catch {
413417
// Enrichment is best-effort — fall through to bare ids below.
@@ -503,6 +507,13 @@ async function listPaginated<T>(
503507
* entries on a chronological endpoint.
504508
*/
505509
filter?: (item: T) => boolean
510+
/**
511+
* Checked after each page against everything collected so far. Lets a
512+
* filtered read stop as soon as it has what it came for — otherwise
513+
* `maxItems` never trips (the filtered total stays small) and the walk runs
514+
* to the end of the history for nothing.
515+
*/
516+
stopWhen?: (collected: T[]) => boolean
506517
}
507518
): Promise<T[]> {
508519
const collected: T[] = []
@@ -528,6 +539,7 @@ async function listPaginated<T>(
528539
const body = (await resp.json()) as AnthropicListPage<T>
529540
const items = Array.isArray(body.data) ? body.data : []
530541
collected.push(...(input.filter ? items.filter(input.filter) : items))
542+
if (input.stopWhen?.(collected)) break
531543
// Paging continues on the RAW page, not the filtered result: a page whose
532544
// every item was filtered out is not the end of the list.
533545
if (!body.next_page || items.length === 0) break

apps/sim/tools/managed_agent/get_session.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createLogger } from '@sim/logger'
12
import { getErrorMessage } from '@sim/utils/errors'
23
import { resolvePendingToolGates, retrieveSession } from '@/lib/managed-agents/session-client'
34
import {
@@ -14,6 +15,8 @@ import type {
1415
} from '@/tools/managed_agent/types'
1516
import type { ToolConfig } from '@/tools/types'
1617

18+
const logger = createLogger('ManagedAgentGetSession')
19+
1720
/** `stop_reason.type` meaning the session is parked awaiting a client response. */
1821
const REQUIRES_ACTION = 'requires_action'
1922

@@ -79,6 +82,17 @@ export const managedAgentGetSessionTool: ToolConfig<
7982
})
8083
: []
8184

85+
// A blocked session that names no blocking events is an anomaly: it waits
86+
// indefinitely, but nothing here can say for what. `requiresAction` stays
87+
// true because that is the truth — reporting false would tell a workflow
88+
// the session is fine while it is parked forever — so log it instead, so
89+
// the dead end is visible rather than silent.
90+
if (requiresAction && pendingTools.length === 0) {
91+
logger.warn('Managed Agent session requires action but reported no blocking event ids', {
92+
sessionId: target.sessionId,
93+
})
94+
}
95+
8296
return {
8397
success: true,
8498
output: {
@@ -119,7 +133,8 @@ export const managedAgentGetSessionTool: ToolConfig<
119133
},
120134
requiresAction: {
121135
type: 'boolean',
122-
description: 'True when the session is waiting on a tool confirmation or custom tool result.',
136+
description:
137+
'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.',
123138
},
124139
pendingTools: {
125140
type: 'json',

0 commit comments

Comments
 (0)