Skip to content

Commit 4e991d0

Browse files
committed
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.
1 parent ef7dd3d commit 4e991d0

8 files changed

Lines changed: 145 additions & 39 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ Return the result of a custom tool a Managed Agent session is waiting on so it c
216216

217217
| Parameter | Type | Required | Description |
218218
| --------- | ---- | -------- | ----------- |
219-
| `customToolUseIds` | array | Yes | Custom tool-use EVENT ids, from Get Session pendingTools\[\].id where kind is 'custom_tool_result'. |
219+
| `customToolUseId` | string | Yes | The custom tool-use EVENT id being answered, from Get Session pendingTools\[\].id where kind is 'custom_tool_result'. |
220220
| `result` | string | Yes | The tool's output, returned to the agent as text. |
221221
| `isError` | boolean | No | Mark the result as a failure so the agent can adjust its approach. |
222222

@@ -225,7 +225,7 @@ Return the result of a custom tool a Managed Agent session is waiting on so it c
225225
| Parameter | Type | Description |
226226
| --------- | ---- | ----------- |
227227
| `sessionId` | string | The session that was answered. |
228-
| `answeredToolUseIds` | json | The custom tool-use event ids that were answered. |
228+
| `answeredToolUseId` | string | The custom tool-use event id that was answered. |
229229

230230
### `managed_agent_archive_session`
231231

apps/sim/blocks/blocks/managed_agent.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,8 @@ describe('Managed Agent block — per-operation field visibility', () => {
160160
// not share inputs — otherwise a workflow can silently answer the wrong way.
161161
expect(isVisible('toolUseIds', { operation: 'respond_tool_confirmation' })).toBe(true)
162162
expect(isVisible('toolUseIds', { operation: 'respond_custom_tool' })).toBe(false)
163-
expect(isVisible('customToolUseIds', { operation: 'respond_custom_tool' })).toBe(true)
164-
expect(isVisible('customToolUseIds', { operation: 'respond_tool_confirmation' })).toBe(false)
163+
expect(isVisible('customToolUseId', { operation: 'respond_custom_tool' })).toBe(true)
164+
expect(isVisible('customToolUseId', { operation: 'respond_tool_confirmation' })).toBe(false)
165165
expect(isVisible('result', { operation: 'respond_custom_tool' })).toBe(true)
166166
expect(isVisible('decision', { operation: 'respond_custom_tool' })).toBe(false)
167167
})
39 Bytes
Binary file not shown.

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
buildSessionCreatePayload,
88
deleteSession,
99
listSessionEvents,
10+
listSessionEventsPage,
1011
parseSessionSnapshot,
1112
resolvePendingToolGates,
1213
sendCustomToolResults,
@@ -480,6 +481,53 @@ describe('resolvePendingToolGates', () => {
480481
expect(gates[0]).toEqual({ id: 'sevt_1' })
481482
})
482483

484+
it('finds a gate that lives past the first page', async () => {
485+
// Gates are the most RECENT tool calls. A read that capped instead of
486+
// filtering would keep page 1 and miss exactly the events that matter.
487+
let page = 0
488+
global.fetch = vi.fn(async () => {
489+
const offset = page * 100
490+
page += 1
491+
const data = Array.from({ length: 100 }, (_, i) => ({
492+
id: `t${offset + i}`,
493+
type: 'agent.tool_use',
494+
name: `tool_${offset + i}`,
495+
}))
496+
return Response.json({ data, next_page: page < 4 ? `c${page}` : null })
497+
}) as unknown as typeof fetch
498+
499+
const gates = await resolvePendingToolGates({
500+
apiKey: 'sk-ant-fake',
501+
sessionId: 'sesn_1',
502+
eventIds: ['t399'],
503+
})
504+
expect(gates).toEqual([
505+
{ id: 't399', eventType: 'agent.tool_use', kind: 'confirmation', name: 'tool_399' },
506+
])
507+
})
508+
509+
it('keeps paging when an entire page filters out', async () => {
510+
let page = 0
511+
const spy = vi.fn(async () => {
512+
page += 1
513+
// Page 1 holds nothing wanted; the target is only on page 2.
514+
const data =
515+
page === 1
516+
? [{ id: 'other', type: 'agent.tool_use', name: 'noise' }]
517+
: [{ id: 'want', type: 'agent.tool_use', name: 'target' }]
518+
return Response.json({ data, next_page: page < 2 ? 'c1' : null })
519+
}) as unknown as typeof fetch
520+
global.fetch = spy
521+
522+
const gates = await resolvePendingToolGates({
523+
apiKey: 'sk-ant-fake',
524+
sessionId: 'sesn_1',
525+
eventIds: ['want'],
526+
})
527+
expect((spy as unknown as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(2)
528+
expect(gates[0]?.name).toBe('target')
529+
})
530+
483531
it('short-circuits with no ids', async () => {
484532
const spy = vi.fn() as unknown as typeof fetch
485533
global.fetch = spy
@@ -538,6 +586,28 @@ describe('listSessionEvents — bounded reads', () => {
538586
expect(events.at(-1)?.id).toBe('e299')
539587
})
540588

589+
it('reports the untrimmed total so a full history is not mistaken for a tail', async () => {
590+
// A history of exactly `maxItems` dropped nothing — `total === events.length`
591+
// is what lets the caller tell that apart from a genuinely capped read.
592+
global.fetch = pagedFetch(3)
593+
const exact = await listSessionEventsPage({
594+
apiKey: 'sk-ant-fake',
595+
sessionId: 'sesn_1',
596+
maxItems: 300,
597+
})
598+
expect(exact.events).toHaveLength(300)
599+
expect(exact.total).toBe(300)
600+
601+
global.fetch = pagedFetch(3)
602+
const capped = await listSessionEventsPage({
603+
apiKey: 'sk-ant-fake',
604+
sessionId: 'sesn_1',
605+
maxItems: 120,
606+
})
607+
expect(capped.events).toHaveLength(120)
608+
expect(capped.total).toBe(300)
609+
})
610+
541611
it('returns the whole history when uncapped', async () => {
542612
global.fetch = pagedFetch(3)
543613
const events = await listSessionEvents({ apiKey: 'sk-ant-fake', sessionId: 'sesn_1' })

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

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -375,9 +375,6 @@ function gateKindFor(eventType: string | undefined): PendingToolGateKind | undef
375375
return undefined
376376
}
377377

378-
/** Upper bound on tool-use events scanned when naming pending gates. */
379-
const MAX_GATE_LOOKUP_EVENTS = 2000
380-
381378
/** Event types that can block a session pending a client response. */
382379
const TOOL_USE_EVENT_TYPES = [
383380
'agent.tool_use',
@@ -405,10 +402,12 @@ export async function resolvePendingToolGates(
405402
...(input.signal ? { signal: input.signal } : {}),
406403
path: `/v1/sessions/${input.sessionId}/events`,
407404
searchParams: TOOL_USE_EVENT_TYPES.map((type): [string, string] => ['types[]', type]),
408-
// Bounded: this only needs to find a handful of ids, and an unbounded read
409-
// of a long session's tool history is a needless memory cost. An id that
410-
// falls outside the window still comes back unenriched, which is usable.
411-
maxItems: MAX_GATE_LOOKUP_EVENTS,
405+
// Keep only the events actually being looked up rather than capping the
406+
// read. A cap would retain the OLDEST page-order events, and blocking
407+
// gates are by definition the most recent tool calls — exactly the ones a
408+
// cap would drop. Filtering instead bounds memory to the id count while
409+
// staying correct however the API orders its pages.
410+
filter: (event) => Boolean(event.id && wanted.has(event.id)),
412411
})
413412
} catch {
414413
// Enrichment is best-effort — fall through to bare ids below.
@@ -497,6 +496,13 @@ async function listPaginated<T>(
497496
maxItems?: number
498497
/** Extra repeatable query pairs (e.g. `types[]` filters). */
499498
searchParams?: Array<[string, string]>
499+
/**
500+
* Applied per item as pages arrive, so only matches are retained. Use this
501+
* instead of `maxItems` when the caller needs specific items rather than a
502+
* prefix — a cap keeps whatever the API returned first, which is the oldest
503+
* entries on a chronological endpoint.
504+
*/
505+
filter?: (item: T) => boolean
500506
}
501507
): Promise<T[]> {
502508
const collected: T[] = []
@@ -521,7 +527,9 @@ async function listPaginated<T>(
521527
}
522528
const body = (await resp.json()) as AnthropicListPage<T>
523529
const items = Array.isArray(body.data) ? body.data : []
524-
collected.push(...items)
530+
collected.push(...(input.filter ? items.filter(input.filter) : items))
531+
// Paging continues on the RAW page, not the filtered result: a page whose
532+
// every item was filtered out is not the end of the list.
525533
if (!body.next_page || items.length === 0) break
526534
page = body.next_page
527535
}
@@ -556,6 +564,28 @@ export async function listSessionEvents(
556564
maxItems?: number
557565
}
558566
): Promise<AnthropicSessionEvent[]> {
567+
return (await listSessionEventsPage(input)).events
568+
}
569+
570+
/** An event read plus the size of the history it was taken from. */
571+
export interface SessionEventPage {
572+
events: AnthropicSessionEvent[]
573+
/**
574+
* How many events the session actually has, before any cap. Compare against
575+
* `events.length` to tell a capped read from a complete one — a history that
576+
* happens to be exactly `maxItems` long has dropped nothing.
577+
*/
578+
total: number
579+
}
580+
581+
/**
582+
* Same read as {@link listSessionEvents}, but also reports the untrimmed
583+
* history size so callers can distinguish "this is a tail" from "this is
584+
* everything, and it happens to be exactly the cap".
585+
*/
586+
export async function listSessionEventsPage(
587+
input: SessionAuth & { sessionId: string; types?: string[]; maxItems?: number }
588+
): Promise<SessionEventPage> {
559589
const types = (input.types ?? []).filter((type) => type.trim().length > 0)
560590
const events = await listPaginated<AnthropicSessionEvent>({
561591
apiKey: input.apiKey,
@@ -574,10 +604,14 @@ export async function listSessionEvents(
574604
const ordered = events.sort(
575605
(a, b) => parseProcessedAt(a.processed_at) - parseProcessedAt(b.processed_at)
576606
)
607+
const total = ordered.length
577608
const maxItems = input.maxItems
578609
// Slice AFTER ordering so the cap is "the newest N", independent of the order
579610
// the API returned pages in.
580-
return maxItems !== undefined && ordered.length > maxItems ? ordered.slice(-maxItems) : ordered
611+
return {
612+
events: maxItems !== undefined && total > maxItems ? ordered.slice(-maxItems) : ordered,
613+
total,
614+
}
581615
}
582616

583617
/** Epoch millis for a `processed_at`, or +Infinity when absent/queued/unparseable (sorts last). */

apps/sim/tools/managed_agent/list_events.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getErrorMessage } from '@sim/utils/errors'
2-
import { listSessionEvents } from '@/lib/managed-agents/session-client'
2+
import { listSessionEventsPage } from '@/lib/managed-agents/session-client'
33
import { normalizeStringList } from '@/tools/managed_agent/normalizers'
44
import {
55
ACCESS_TOKEN_PARAM,
@@ -86,7 +86,7 @@ export const managedAgentListEventsTool: ToolConfig<
8686
Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : DEFAULT_EVENT_LIMIT
8787

8888
try {
89-
const events = await listSessionEvents({
89+
const { events, total } = await listSessionEventsPage({
9090
apiKey: target.apiKey,
9191
sessionId: target.sessionId,
9292
maxItems,
@@ -111,9 +111,10 @@ export const managedAgentListEventsTool: ToolConfig<
111111
events,
112112
count: events.length,
113113
assistantText,
114-
// Signals that older events were dropped, so a caller reading history
115-
// knows this is a tail rather than the whole session.
116-
truncated: events.length >= maxItems,
114+
// Compared against the untrimmed history size, not the limit: a
115+
// session holding exactly `maxItems` events dropped nothing and must
116+
// not be reported as a partial read.
117+
truncated: total > events.length,
117118
},
118119
}
119120
} catch (error) {

apps/sim/tools/managed_agent/respond_custom_tool.ts

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { getErrorMessage } from '@sim/utils/errors'
22
import { sendCustomToolResults } from '@/lib/managed-agents/session-client'
3-
import { isTruthyAck, normalizeStringList } from '@/tools/managed_agent/normalizers'
3+
import { isTruthyAck } from '@/tools/managed_agent/normalizers'
44
import {
55
ACCESS_TOKEN_PARAM,
66
CREDENTIAL_PARAM,
@@ -22,6 +22,11 @@ import type { ToolConfig } from '@/tools/types'
2222
* A permission confirmation does NOT unblock these — that is a different event
2323
* for a different kind of gate. `managed_agent_get_session` labels each pending
2424
* gate with `kind`, so a workflow can route to the right operation.
25+
*
26+
* Deliberately answers ONE call per invocation. Each pending custom tool has
27+
* its own output, so accepting a list here would force every one of them to
28+
* share a single result — silently wrong whenever more than one is pending.
29+
* Answer several by iterating this operation over `pendingTools`.
2530
*/
2631
export const managedAgentRespondCustomToolTool: ToolConfig<
2732
ManagedAgentCustomToolResultParams,
@@ -37,12 +42,12 @@ export const managedAgentRespondCustomToolTool: ToolConfig<
3742
credential: CREDENTIAL_PARAM,
3843
accessToken: ACCESS_TOKEN_PARAM,
3944
sessionId: SESSION_ID_PARAM,
40-
customToolUseIds: {
41-
type: 'array',
45+
customToolUseId: {
46+
type: 'string',
4247
required: true,
4348
visibility: 'user-or-llm',
4449
description:
45-
"Custom tool-use EVENT ids, from Get Session pendingTools[].id where kind is 'custom_tool_result'.",
50+
"The custom tool-use EVENT id being answered, from Get Session pendingTools[].id where kind is 'custom_tool_result'.",
4651
},
4752
result: {
4853
type: 'string',
@@ -61,41 +66,37 @@ export const managedAgentRespondCustomToolTool: ToolConfig<
6166
request: UNUSED_REQUEST,
6267

6368
directExecution: async (params, signal): Promise<ManagedAgentCustomToolResultResponse> => {
64-
const emptyOutput = { sessionId: '', answeredToolUseIds: [] as string[] }
69+
const emptyOutput = { sessionId: '', answeredToolUseId: '' }
6570
const target = resolveSessionTarget(params)
6671
if (!target.ok) {
6772
return { success: false, output: emptyOutput, error: target.error }
6873
}
6974

70-
const customToolUseIds = normalizeStringList(params.customToolUseIds)
71-
if (customToolUseIds.length === 0) {
75+
const customToolUseId = params.customToolUseId?.trim()
76+
if (!customToolUseId) {
7277
return {
7378
success: false,
7479
output: { ...emptyOutput, sessionId: target.sessionId },
7580
error:
76-
'At least one custom tool-use event id is required. Read them from Get Session pendingTools[].id.',
81+
'A custom tool-use event id is required. Read it from Get Session pendingTools[].id.',
7782
}
7883
}
7984

8085
// The result may legitimately be empty (a tool that returns nothing), so
81-
// only the id list is required — an absent result is sent as an empty string.
86+
// only the id is required — an absent result is sent as an empty string.
8287
const result = (params.result ?? '').toString()
8388
const isError = isTruthyAck(params.isError)
8489

8590
try {
8691
await sendCustomToolResults({
8792
apiKey: target.apiKey,
8893
sessionId: target.sessionId,
89-
results: customToolUseIds.map((customToolUseId) => ({
90-
customToolUseId,
91-
content: result,
92-
isError,
93-
})),
94+
results: [{ customToolUseId, content: result, isError }],
9495
...(signal ? { signal } : {}),
9596
})
9697
return {
9798
success: true,
98-
output: { sessionId: target.sessionId, answeredToolUseIds: customToolUseIds },
99+
output: { sessionId: target.sessionId, answeredToolUseId: customToolUseId },
99100
}
100101
} catch (error) {
101102
return {
@@ -108,9 +109,9 @@ export const managedAgentRespondCustomToolTool: ToolConfig<
108109

109110
outputs: {
110111
sessionId: { type: 'string', description: 'The session that was answered.' },
111-
answeredToolUseIds: {
112-
type: 'json',
113-
description: 'The custom tool-use event ids that were answered.',
112+
answeredToolUseId: {
113+
type: 'string',
114+
description: 'The custom tool-use event id that was answered.',
114115
},
115116
},
116117
}

apps/sim/tools/managed_agent/types.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,16 +146,16 @@ export interface ManagedAgentToolConfirmationResponse extends ToolResponse {
146146
}
147147

148148
export interface ManagedAgentCustomToolResultParams extends ManagedAgentSessionOpParams {
149-
/** Blocking custom tool-use EVENT ids (array, JSON string, or comma list). */
150-
customToolUseIds?: unknown
149+
/** The blocking custom tool-use EVENT id being answered. */
150+
customToolUseId?: string
151151
/** The tool's output, returned to the agent. */
152152
result?: string
153153
/** Marks the result as a failure. */
154154
isError?: boolean | string
155155
}
156156

157157
export interface ManagedAgentCustomToolResultResponse extends ToolResponse {
158-
output: { sessionId: string; answeredToolUseIds: string[] }
158+
output: { sessionId: string; answeredToolUseId: string }
159159
}
160160

161161
export interface ManagedAgentArchiveSessionParams extends ManagedAgentSessionOpParams {}

0 commit comments

Comments
 (0)