Skip to content

Commit d4046c2

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

7 files changed

Lines changed: 124 additions & 12 deletions

File tree

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,8 @@ Update a Managed Agent session's title or metadata.
161161
| Parameter | Type | Required | Description |
162162
| --------- | ---- | -------- | ----------- |
163163
| `title` | string | No | New session title. |
164-
| `sessionParameters` | object | No | Replacement metadata map \(replaces all stored metadata, not merged\). |
164+
| `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. |
165+
| `clearMetadata` | boolean | No | Removes all of the session's stored metadata. Overrides any map supplied above. |
165166

166167
#### Output
167168

432 Bytes
Binary file not shown.

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,32 @@ describe('listSessionEvents — bounded reads', () => {
651651
expect(negative.events).toHaveLength(0)
652652
})
653653

654+
it.each([0.5, 0.99, 0, -0.5, -5])(
655+
'never returns the whole history for the sub-integer cap %p',
656+
async (maxItems) => {
657+
// `slice` truncates its index toward zero, so any cap under 1 becomes
658+
// `slice(-0)` — the entire array — unless it is floored first.
659+
global.fetch = pagedFetch(1)
660+
const res = await listSessionEventsPage({
661+
apiKey: 'sk-ant-fake',
662+
sessionId: 'sesn_1',
663+
maxItems,
664+
})
665+
expect(res.events).toHaveLength(0)
666+
expect(res.total).toBe(100)
667+
}
668+
)
669+
670+
it('floors a fractional cap above 1 rather than widening it', async () => {
671+
global.fetch = pagedFetch(1)
672+
const res = await listSessionEventsPage({
673+
apiKey: 'sk-ant-fake',
674+
sessionId: 'sesn_1',
675+
maxItems: 10.9,
676+
})
677+
expect(res.events).toHaveLength(10)
678+
})
679+
654680
it('returns the whole history when uncapped', async () => {
655681
global.fetch = pagedFetch(3)
656682
const events = await listSessionEvents({ apiKey: 'sk-ant-fake', sessionId: 'sesn_1' })

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

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -617,15 +617,19 @@ export async function listSessionEventsPage(
617617
(a, b) => parseProcessedAt(a.processed_at) - parseProcessedAt(b.processed_at)
618618
)
619619
const total = ordered.length
620-
const maxItems = input.maxItems
621-
if (maxItems === undefined || Number.isNaN(maxItems) || total <= maxItems) {
620+
if (input.maxItems === undefined || Number.isNaN(input.maxItems)) {
622621
return { events: ordered, total }
623622
}
623+
// Floor first: `slice` truncates its index toward zero, so a cap between 0
624+
// and 1 would become `slice(-0)` — i.e. `slice(0)` — and hand back the ENTIRE
625+
// history for what the caller asked to be the tightest possible bound. Doing
626+
// it here means no caller can hit that, whatever it passes.
627+
const maxItems = Math.floor(input.maxItems)
628+
if (maxItems <= 0) return { events: [], total }
629+
if (total <= maxItems) return { events: ordered, total }
624630
// Slice AFTER ordering so the cap is "the newest N", independent of the order
625-
// the API returned pages in. A zero or negative cap short-circuits because
626-
// `slice(-0)` is `slice(0)` — it would hand back the ENTIRE history for what
627-
// the caller asked to be the tightest possible bound.
628-
return { events: maxItems <= 0 ? [] : ordered.slice(-maxItems), total }
631+
// the API returned pages in.
632+
return { events: ordered.slice(-maxItems), total }
629633
}
630634

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

apps/sim/tools/managed_agent/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ export interface ManagedAgentListEventsResponse extends ToolResponse {
120120
export interface ManagedAgentUpdateSessionParams extends ManagedAgentSessionOpParams {
121121
title?: string
122122
sessionParameters?: unknown
123+
/** Explicitly removes all stored metadata; an empty map cannot express this. */
124+
clearMetadata?: boolean | string
123125
}
124126

125127
export interface ManagedAgentUpdateSessionResponse extends ToolResponse {
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { afterEach, describe, expect, it, vi } from 'vitest'
5+
import { managedAgentUpdateSessionTool } from '@/tools/managed_agent/update_session'
6+
7+
const originalFetch = global.fetch
8+
afterEach(() => {
9+
global.fetch = originalFetch
10+
})
11+
12+
const capture = () => {
13+
const spy = vi.fn(async () => Response.json({ status: 'idle' })) as unknown as typeof fetch
14+
global.fetch = spy
15+
return spy as unknown as ReturnType<typeof vi.fn>
16+
}
17+
18+
const run = (params: Record<string, unknown>) =>
19+
managedAgentUpdateSessionTool.directExecution!(
20+
{ credential: 'c', accessToken: 'sk-ant-fake', sessionId: 'sesn_1', ...params } as never,
21+
undefined
22+
)
23+
24+
const bodyOf = (spy: ReturnType<typeof vi.fn>) =>
25+
JSON.parse((spy.mock.calls[0] as [string, RequestInit])[1].body as string)
26+
27+
describe('managed_agent_update_session — metadata clearing', () => {
28+
it('does not touch metadata on a title-only update', async () => {
29+
const spy = capture()
30+
await run({ title: 'renamed' })
31+
expect(bodyOf(spy)).toEqual({ title: 'renamed' })
32+
})
33+
34+
it.each([[[]], [{}], [undefined]])(
35+
'leaves stored metadata alone for the empty value %p rather than wiping it',
36+
async (sessionParameters) => {
37+
// An untouched metadata table is also empty, so emptiness cannot mean
38+
// "clear" — inferring it would wipe metadata on every title-only update.
39+
const spy = capture()
40+
await run({ title: 'renamed', sessionParameters })
41+
expect(bodyOf(spy).metadata).toBeUndefined()
42+
}
43+
)
44+
45+
it('clears metadata only when explicitly asked', async () => {
46+
const spy = capture()
47+
await run({ clearMetadata: true })
48+
expect(bodyOf(spy)).toEqual({ metadata: {} })
49+
})
50+
51+
it('lets an explicit clear win over a supplied map', async () => {
52+
const spy = capture()
53+
await run({ clearMetadata: true, sessionParameters: { a: 'b' } })
54+
expect(bodyOf(spy).metadata).toEqual({})
55+
})
56+
57+
it('still refuses a no-op update', async () => {
58+
capture()
59+
const res = (await run({})) as { success: boolean; error?: string }
60+
expect(res.success).toBe(false)
61+
expect(res.error).toMatch(/Clear metadata/)
62+
})
63+
})

apps/sim/tools/managed_agent/update_session.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { getErrorMessage } from '@sim/utils/errors'
22
import { updateSession } from '@/lib/managed-agents/session-client'
3-
import { normalizeSessionParameters } from '@/tools/managed_agent/normalizers'
3+
import { isTruthyAck, normalizeSessionParameters } from '@/tools/managed_agent/normalizers'
44
import {
55
ACCESS_TOKEN_PARAM,
66
CREDENTIAL_PARAM,
@@ -23,7 +23,9 @@ import type { ToolConfig } from '@/tools/types'
2323
* that ordering gap.
2424
*
2525
* Metadata is a FULL REPLACEMENT of the stored map, matching the API. To add
26-
* one key, read the session first and send the merged map.
26+
* one key, read the session first and send the merged map. Removing metadata
27+
* entirely takes an explicit `clearMetadata`, because an empty map is
28+
* indistinguishable from a field the author never filled in.
2729
*/
2830
export const managedAgentUpdateSessionTool: ToolConfig<
2931
ManagedAgentUpdateSessionParams,
@@ -48,7 +50,15 @@ export const managedAgentUpdateSessionTool: ToolConfig<
4850
type: 'object',
4951
required: false,
5052
visibility: 'user-or-llm',
51-
description: 'Replacement metadata map (replaces all stored metadata, not merged).',
53+
description:
54+
'Replacement metadata map (replaces all stored metadata, not merged). Leaving it empty leaves the stored metadata unchanged — use clearMetadata to remove it.',
55+
},
56+
clearMetadata: {
57+
type: 'boolean',
58+
required: false,
59+
visibility: 'user-or-llm',
60+
description:
61+
"Removes all of the session's stored metadata. Overrides any map supplied above.",
5262
},
5363
},
5464

@@ -65,12 +75,18 @@ export const managedAgentUpdateSessionTool: ToolConfig<
6575
// both slip past the guard below and silently clear an existing title.
6676
const trimmedTitle = params.title?.trim()
6777
const title = trimmedTitle ? trimmedTitle : undefined
68-
const metadata = normalizeSessionParameters(params.sessionParameters)
78+
79+
// Clearing metadata needs its own explicit signal. An empty metadata table
80+
// cannot mean "clear": a table the author never touched is also empty, so
81+
// inferring intent from emptiness would wipe a session's metadata on every
82+
// title-only update. `{}` is only sent when the author asks for it.
83+
const clearMetadata = isTruthyAck(params.clearMetadata)
84+
const metadata = clearMetadata ? {} : normalizeSessionParameters(params.sessionParameters)
6985
if (title === undefined && metadata === undefined) {
7086
return {
7187
success: false,
7288
output: { sessionId: target.sessionId, updated: false },
73-
error: 'Provide a title or metadata to update.',
89+
error: 'Provide a title or metadata to update, or check "Clear metadata".',
7490
}
7591
}
7692

0 commit comments

Comments
 (0)