Skip to content

Commit b4807ca

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

4 files changed

Lines changed: 96 additions & 10 deletions

File tree

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,27 @@ describe('listSessionEvents — bounded reads', () => {
608608
expect(capped.total).toBe(300)
609609
})
610610

611+
it('never returns the whole history for a zero or negative cap', async () => {
612+
// `slice(-0)` is `slice(0)` — the entire array — so a zero cap must
613+
// short-circuit rather than silently become an unbounded read.
614+
global.fetch = pagedFetch(1)
615+
const zero = await listSessionEventsPage({
616+
apiKey: 'sk-ant-fake',
617+
sessionId: 'sesn_1',
618+
maxItems: 0,
619+
})
620+
expect(zero.events).toHaveLength(0)
621+
expect(zero.total).toBe(100)
622+
623+
global.fetch = pagedFetch(1)
624+
const negative = await listSessionEventsPage({
625+
apiKey: 'sk-ant-fake',
626+
sessionId: 'sesn_1',
627+
maxItems: -5,
628+
})
629+
expect(negative.events).toHaveLength(0)
630+
})
631+
611632
it('returns the whole history when uncapped', async () => {
612633
global.fetch = pagedFetch(3)
613634
const events = await listSessionEvents({ apiKey: 'sk-ant-fake', sessionId: 'sesn_1' })

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -606,12 +606,14 @@ export async function listSessionEventsPage(
606606
)
607607
const total = ordered.length
608608
const maxItems = input.maxItems
609-
// Slice AFTER ordering so the cap is "the newest N", independent of the order
610-
// the API returned pages in.
611-
return {
612-
events: maxItems !== undefined && total > maxItems ? ordered.slice(-maxItems) : ordered,
613-
total,
609+
if (maxItems === undefined || Number.isNaN(maxItems) || total <= maxItems) {
610+
return { events: ordered, total }
614611
}
612+
// Slice AFTER ordering so the cap is "the newest N", independent of the order
613+
// the API returned pages in. A zero or negative cap short-circuits because
614+
// `slice(-0)` is `slice(0)` — it would hand back the ENTIRE history for what
615+
// the caller asked to be the tightest possible bound.
616+
return { events: maxItems <= 0 ? [] : ordered.slice(-maxItems), total }
615617
}
616618

617619
/** Epoch millis for a `processed_at`, or +Infinity when absent/queued/unparseable (sorts last). */
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { afterEach, describe, expect, it, vi } from 'vitest'
5+
import { managedAgentListEventsTool } from '@/tools/managed_agent/list_events'
6+
7+
const originalFetch = global.fetch
8+
afterEach(() => {
9+
global.fetch = originalFetch
10+
})
11+
12+
/** One page of `count` events, no next page. */
13+
const historyOf = (count: number) =>
14+
vi.fn(async () =>
15+
Response.json({
16+
data: Array.from({ length: count }, (_, i) => ({
17+
id: `e${i}`,
18+
type: 'agent.message',
19+
processed_at: new Date(Date.UTC(2026, 0, 1) + i * 1000).toISOString(),
20+
content: [{ type: 'text', text: `m${i}` }],
21+
})),
22+
next_page: null,
23+
})
24+
) as unknown as typeof fetch
25+
26+
const run = (limit: unknown) =>
27+
managedAgentListEventsTool.directExecution!(
28+
{ credential: 'c', accessToken: 'sk-ant-fake', sessionId: 'sesn_1', limit } as never,
29+
undefined
30+
)
31+
32+
describe('managed_agent_list_events — limit handling', () => {
33+
it.each([0.5, 0, -1, Number.NaN, 'abc', null, undefined])(
34+
'falls back to the 500 default for the invalid limit %p rather than reading unbounded',
35+
async (limit) => {
36+
global.fetch = historyOf(600)
37+
const res = (await run(limit)) as { output: { count: number; truncated: boolean } }
38+
expect(res.output.count).toBe(500)
39+
expect(res.output.truncated).toBe(true)
40+
}
41+
)
42+
43+
it('honors a valid limit and keeps the newest events', async () => {
44+
global.fetch = historyOf(50)
45+
const res = (await run(10)) as {
46+
output: { count: number; truncated: boolean; assistantText: string }
47+
}
48+
expect(res.output.count).toBe(10)
49+
expect(res.output.truncated).toBe(true)
50+
// Newest ten are m40..m49, concatenated in chronological order.
51+
expect(res.output.assistantText).toBe(
52+
Array.from({ length: 10 }, (_, i) => `m${40 + i}`).join('')
53+
)
54+
})
55+
56+
it('reports a complete history as not truncated even at exactly the limit', async () => {
57+
global.fetch = historyOf(10)
58+
const res = (await run(10)) as { output: { count: number; truncated: boolean } }
59+
expect(res.output.count).toBe(10)
60+
expect(res.output.truncated).toBe(false)
61+
})
62+
})

apps/sim/tools/managed_agent/list_events.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,12 @@ export const managedAgentListEventsTool: ToolConfig<
7979
}
8080

8181
const types = normalizeStringList(params.eventTypes)
82-
// A non-numeric or non-positive limit falls back to the default rather than
83-
// silently becoming an unbounded (or empty) read.
84-
const requested = Number(params.limit)
85-
const maxItems =
86-
Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : DEFAULT_EVENT_LIMIT
82+
// Floor BEFORE the positivity check: a fractional limit like 0.5 would pass
83+
// `> 0` and then floor to 0, which reads as "no cap" downstream and returns
84+
// the whole history. Anything that does not floor to a positive integer
85+
// falls back to the default rather than silently becoming unbounded.
86+
const requested = Math.floor(Number(params.limit))
87+
const maxItems = Number.isFinite(requested) && requested > 0 ? requested : DEFAULT_EVENT_LIMIT
8788

8889
try {
8990
const { events, total } = await listSessionEventsPage({

0 commit comments

Comments
 (0)