diff --git a/client/src/components/Chat/Subagents/SubagentConversation.test.tsx b/client/src/components/Chat/Subagents/SubagentConversation.test.tsx index 8fe9e566cf..8c20c728bb 100644 --- a/client/src/components/Chat/Subagents/SubagentConversation.test.tsx +++ b/client/src/components/Chat/Subagents/SubagentConversation.test.tsx @@ -107,7 +107,7 @@ describe('SubagentConversation', () => { ); expect(screen.getAllByText('com_ui_subagent_trigger_parent_dispatch')).toHaveLength(2); - expect(screen.getAllByText('com_ui_subagent_trigger_external_event')).toHaveLength(1); + expect(screen.getAllByText('com_ui_subagent_trigger_external_event')).toHaveLength(2); expect(screen.getByText('Investigate the release.')).toBeInTheDocument(); expect(screen.getByText('Checked the constraints.')).toBeInTheDocument(); expect(screen.getByText('search')).toBeInTheDocument(); @@ -115,8 +115,8 @@ describe('SubagentConversation', () => { expect(screen.queryByText('com_ui_subagent_thread_status_completed')).not.toBeInTheDocument(); expect(screen.queryByText('com_ui_subagent_thread_status_running')).not.toBeInTheDocument(); expect(screen.getByTestId('thinking-cursor')).toBeInTheDocument(); - expect(container.querySelectorAll('.message-render')).toHaveLength(3); - expect(container.querySelectorAll('.user-turn')).toHaveLength(1); + expect(container.querySelectorAll('.message-render')).toHaveLength(4); + expect(container.querySelectorAll('.user-turn')).toHaveLength(2); expect(container.querySelectorAll('.agent-turn')).toHaveLength(2); expect(container.querySelector('[data-subagent-conversation]')).toBeInTheDocument(); expect(screen.queryByText('com_ui_prompt')).not.toBeInTheDocument(); diff --git a/client/src/components/Chat/Subagents/SubagentConversation.tsx b/client/src/components/Chat/Subagents/SubagentConversation.tsx index 26025a6da4..c6a7f8b4d0 100644 --- a/client/src/components/Chat/Subagents/SubagentConversation.tsx +++ b/client/src/components/Chat/Subagents/SubagentConversation.tsx @@ -35,59 +35,82 @@ function TriggerIcon({ kind }: { kind: ChildConversationTurn['trigger']['kind'] ); } -function ExternalEventTrigger({ turn }: { turn: ChildConversationTurn }) { +function ExternalEventTrigger({ + turn, + fullWidth, +}: { + turn: ChildConversationTurn; + fullWidth: boolean; +}) { const localize = useLocalize(); const [expanded, setExpanded] = useState(false); const details = turn.trigger.externalEvent; const label = localize('com_ui_subagent_trigger_external_event'); + let body: ReactNode; if (details == null) { - return ( -
+ body = ( +
- {label} + {label}
); + } else { + body = ( + + + + + +
+
{localize('com_ui_subagent_event_type')}
+
{details.eventType}
+
{localize('com_ui_subagent_event_source')}
+
{details.sourceType}
+
{localize('com_ui_subagent_event_received')}
+
+ {new Date(details.occurredAt).toLocaleString()} +
+ {details.expectedActionToolName != null && ( + <> +
{localize('com_ui_subagent_event_expected_action')}
+
{details.expectedActionToolName}
+ + )} +
+
+
+ ); } return ( - - - - - -
-
{localize('com_ui_subagent_event_type')}
-
{details.eventType}
-
{localize('com_ui_subagent_event_source')}
-
{details.sourceType}
-
{localize('com_ui_subagent_event_received')}
-
- {new Date(details.occurredAt).toLocaleString()} -
- {details.expectedActionToolName != null && ( - <> -
{localize('com_ui_subagent_event_expected_action')}
-
{details.expectedActionToolName}
- - )} -
-
-
+ } + label={label} + footer={null} + timestamp={turn.trigger.createdAt ?? details?.occurredAt} + ariaLabel={label} + headerPrefix="" + isCreatedByUser={true} + fullWidth={fullWidth} + > + {body} + ); } @@ -107,7 +130,7 @@ function TriggerMessage({ turn, fullWidth }: { turn: ChildConversationTurn; full [turn.trigger.summary], ); if (turn.trigger.kind === 'external_event') { - return ; + return ; } return ( { 'parent-conversation', 'child-thread', 'task', - undefined, + { keepPreviousData: true }, ); expect(mockUseSubagentActivityStream).toHaveBeenCalledWith(selection, false); expect(screen.getByText('Research child')).toBeInTheDocument(); @@ -1264,7 +1264,7 @@ describe('SubagentThreadPanel', () => { 'parent-conversation', 'child-thread', 'task', - { refetchInterval: 2000 }, + { keepPreviousData: true, refetchInterval: 2000 }, ); expect(mockUseSubagentActivityStream).toHaveBeenLastCalledWith(eventSelection, true); expect( diff --git a/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx b/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx index 9548372649..b39f5fa375 100644 --- a/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx +++ b/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx @@ -190,24 +190,33 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu selectedEventActor?.actorId ?? eventSummary?.actorId ?? foregroundTitle; - const { data, isLoading, isError, isReadinessPending, refetch } = useSubagentThreadQuery( - selection.parentConversationId, - threadId, - taskId, - eventTaskRunning ? { refetchInterval: ACTIVE_THREAD_REFRESH_MS } : undefined, - ); + const { data, isLoading, isError, isPreviousData, isReadinessPending, refetch } = + useSubagentThreadQuery(selection.parentConversationId, threadId, taskId, { + /** A new delivery re-keys this query to its task. Keeping the previous + * thread view mounted while the fresh one loads stops the whole panel + * from flashing back to a loading dot on every incoming event. */ + keepPreviousData: true, + ...(eventTaskRunning ? { refetchInterval: ACTIVE_THREAD_REFRESH_MS } : {}), + }); + /** Retention crosses every key change, including actor switches. A retained + * view is only meaningful while it describes the SAME child thread; within + * that thread its turn/history rows stay valid across task re-keys, while + * task-scoped fields (selected activity, status, control receipts) must not + * be attributed to the newly selected task. */ + const threadView = data?.threadId === threadId ? data : undefined; + const taskView = isPreviousData ? undefined : threadView; const latestHistoryGeneration = JSON.stringify([ - data?.nextCursor ?? null, - ...(data?.turns?.map((turn) => turn.taskId) ?? []), + threadView?.nextCursor ?? null, + ...(threadView?.turns?.map((turn) => turn.taskId) ?? []), ]); const latestHistoryGenerationRef = useRef(latestHistoryGeneration); latestHistoryGenerationRef.current = latestHistoryGeneration; const durableTerminal = - subagentThreadHasTaskEvidence(data, taskId) && - (data?.status === 'completed' || - data?.status === 'failed' || - data?.status === 'interrupted' || - data?.status === 'cancelled'); + subagentThreadHasTaskEvidence(taskView, taskId) && + (taskView?.status === 'completed' || + taskView?.status === 'failed' || + taskView?.status === 'interrupted' || + taskView?.status === 'cancelled'); const priorTerminalRef = useRef(false); useSubagentActivityStream(selection, !durableTerminal || eventTaskRunning); @@ -290,6 +299,10 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu const activeThreadRef = useRef(threadId); const selectionThreadRef = useRef(threadId); const selectionGenerationRef = useRef(0); + /** Locally retained turn buffers reset in a passive effect; until it has run + * for the current thread they still hold the previous thread's turns and + * must not render. Stamped inside that reset effect. */ + const retainedTurnsGenerationRef = useRef(0); const turnDetailRequestsRef = useRef(new Set()); const historyRequestRef = useRef(null); const historyHasLoadedRef = useRef(false); @@ -320,6 +333,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu setMovingWindowTurns([]); setRebaseTurns([]); setPostRebaseTurns([]); + retainedTurnsGenerationRef.current = selectionGenerationRef.current; postRebaseTurnsRef.current = []; setHistoryRebaseActive(false); historyRebaseActiveRef.current = false; @@ -334,19 +348,13 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu useEffect(() => { if ( - data?.threadId === threadId && - (data.historyUnavailable === true || - (data.historyTruncated === true && data.nextCursor == null)) + threadView != null && + (threadView.historyUnavailable === true || + (threadView.historyTruncated === true && threadView.nextCursor == null)) ) { setHistoryBoundaryUnavailable(true); } - }, [ - data?.historyTruncated, - data?.historyUnavailable, - data?.nextCursor, - data?.threadId, - threadId, - ]); + }, [threadView]); const loadTurnDetails = useCallback( async (detailTaskId: string) => { @@ -410,7 +418,8 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu historyCursor !== undefined && historyCursorGeneration !== requestedGeneration; const recoveringRebase = startsRebase || historyRebaseActive; - const cursor = historyCursor === undefined || startsRebase ? data?.nextCursor : historyCursor; + const cursor = + historyCursor === undefined || startsRebase ? threadView?.nextCursor : historyCursor; const requestKey = `${requestedSelectionGeneration}\u0000${requestedThreadId}\u0000${cursor ?? ''}\u0000${requestedGeneration}`; if (cursor == null || historyState === 'loading' || historyRequestRef.current != null) { return; @@ -501,7 +510,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu if (historyRequestRef.current === requestKey) historyRequestRef.current = null; } }, [ - data?.nextCursor, + threadView?.nextCursor, historyCursor, historyCursorGeneration, historyRebaseActive, @@ -566,7 +575,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu useEffect(() => { if (transientControl == null) return; - const durableReceipt = data?.controlReceipts?.find( + const durableReceipt = taskView?.controlReceipts?.find( (receipt) => receipt.invocationId === transientControl.invocationId, ); if (durableReceipt == null) return; @@ -583,13 +592,13 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu } if (closesTaskControls(durableReceipt)) setControlsClosed(true); setControlState(null); - }, [data?.controlReceipts, retryControl, setControlState, transientControl]); + }, [retryControl, setControlState, taskView?.controlReceipts, transientControl]); useEffect(() => { - if (data?.controlReceipts?.some(closesTaskControls)) { + if (taskView?.controlReceipts?.some(closesTaskControls)) { setControlsClosed(true); } - }, [data?.controlReceipts]); + }, [taskView?.controlReceipts]); const submitControl = useCallback( (action: SubagentControlAction, controlId?: string, retry?: SubagentControlRequest) => { @@ -707,14 +716,14 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu ); const activity = useMemo(() => { if (selection.durable == null) return liveActivity; - if (data == null) { + if (taskView == null) { const activityWithoutData = progress == null ? { ...liveActivity, status: 'dispatched' as const } : liveActivity; return transientControl == null ? activityWithoutData : { ...activityWithoutData, controls: [transientControl] }; } - const durable = adaptDurableThreadActivity(data, selection.durable.taskId); + const durable = adaptDurableThreadActivity(taskView, selection.durable.taskId); const useLiveItems = (durable.status === 'running' || durable.status === 'dispatched') && liveActivity.items.length > 0; @@ -734,11 +743,11 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu return merged; } return { ...merged, controls: [...(merged.controls ?? []), transientControl] }; - }, [data, liveActivity, progress, selection.durable, transientControl]); + }, [liveActivity, progress, selection.durable, taskView, transientControl]); const panelTitle = selection.event == null ? activity.title : selectedEventActorName; const latestConversationTurns = useMemo( - () => (data == null ? [] : adaptDurableThreadConversation(data)), - [data], + () => (threadView == null ? [] : adaptDurableThreadConversation(threadView)), + [threadView], ); const previousLatestTurnsRef = useRef({ threadId, @@ -746,7 +755,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu turns: latestConversationTurns, }); useEffect(() => { - if (data == null) return; + if (threadView == null) return; const previous = previousLatestTurnsRef.current; if (previous.threadId === threadId) { const latestTaskIds = new Set(latestConversationTurns.map((turn) => turn.taskId)); @@ -766,15 +775,18 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu generation: latestHistoryGeneration, turns: latestConversationTurns, }; - }, [data, historyRebaseActive, latestConversationTurns, latestHistoryGeneration, threadId]); + }, [historyRebaseActive, latestConversationTurns, latestHistoryGeneration, threadId, threadView]); + const retainedTurnsValid = retainedTurnsGenerationRef.current === selectionGenerationRef.current; const conversationTurns = useMemo(() => { - const durableTurns = mergeChildConversationTurns( - olderTurns, - movingWindowTurns, - rebaseTurns, - postRebaseTurns, - latestConversationTurns, - ); + const durableTurns = retainedTurnsValid + ? mergeChildConversationTurns( + olderTurns, + movingWindowTurns, + rebaseTurns, + postRebaseTurns, + latestConversationTurns, + ) + : mergeChildConversationTurns(latestConversationTurns); if (durableTurns.length > 0) { const selectedTurnIndex = durableTurns.findIndex((turn) => turn.taskId === taskId); if (selectedTurnIndex >= 0) { @@ -824,6 +836,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu olderTurns, postRebaseTurns, rebaseTurns, + retainedTurnsValid, selection, taskId, turnDetailOverrides, @@ -836,19 +849,24 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu const historyCursorUsesLatest = !historyRebaseActive && (historyCursor === undefined || historyCursorGeneration !== latestHistoryGeneration); - const effectiveHistoryCursor = historyCursorUsesLatest ? data?.nextCursor : historyCursor; + const effectiveHistoryCursor = historyCursorUsesLatest ? threadView?.nextCursor : historyCursor; const showUnavailableHistoryBoundary = historyBoundaryUnavailable || - data?.historyUnavailable === true || - (historyCursorUsesLatest && data?.historyTruncated === true && data.nextCursor == null); + threadView?.historyUnavailable === true || + (historyCursorUsesLatest && + threadView?.historyTruncated === true && + threadView.nextCursor == null); /** During a rolling deployment an older API replica can omit `turns`. Keep * that response readable through the same deep activity renderer; every * current host otherwise enters the conversation-native rendering seam. */ const hasConversationProjection = - selection.durable == null || data == null || Array.isArray(data.turns); + selection.durable == null || threadView == null || Array.isArray(threadView.turns); const taskInaccessible = controlInaccessible || transientControl?.reason === 'task_inaccessible'; const controlAvailable = - selection.durable != null && data?.status === 'running' && !taskInaccessible && !controlsClosed; + selection.durable != null && + taskView?.status === 'running' && + !taskInaccessible && + !controlsClosed; const controlPending = controlTask.isLoading || transientControl?.status === 'submitted' || retryControl != null; const showControlFooter = @@ -856,11 +874,11 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu const canContinueAsChat = selection.host === 'conversation' && selection.durable != null && - data?.subagentKind === 'agent' && - data.agentId != null && - data.status === 'completed' && - subagentThreadHasTaskEvidence(data, taskId) && - data.messages.some((message) => message.messageId === `${taskId}:assistant`); + taskView?.subagentKind === 'agent' && + taskView.agentId != null && + taskView.status === 'completed' && + subagentThreadHasTaskEvidence(taskView, taskId) && + taskView.messages.some((message) => message.messageId === `${taskId}:assistant`); const continueAsChat = useCallback(() => { if (!canContinueAsChat || selection.durable == null) return; @@ -998,7 +1016,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu )} { ); }); + it('keeps the budgeted timeline a contiguous newest suffix past an oversized entry', async () => { + const conversationId = uuidv4(); + await saveMessage(mockCtx, { + messageId: 'task-suffix:assistant', + conversationId, + text: '', + user: 'user123', + content: [ + ...Array.from({ length: 5 }, (_, index) => ({ + type: 'text', + text: `older-${index}`, + })), + ...Array.from({ length: 8 }, (_, index) => ({ + type: 'text', + text: `newest-${index}-${'b'.repeat(8_400)}`, + })), + ], + }); + + const messages = await getMessagesForSubagentThreadView({ + user: 'user123', + conversationId, + limit: 1, + textCodePointLimit: 8_192, + }); + + const activity = messages[0].subagentActivity as Array<{ type: string; text: string }>; + expect(activity.length).toBeGreaterThan(0); + expect(activity.length).toBeLessThan(8); + expect(activity.every((item) => item.text.startsWith('newest-'))).toBe(true); + expect(activity[activity.length - 1].text).toContain('newest-7-'); + expect(JSON.stringify(activity)).not.toContain('older-'); + expect(messages[0].subagentActivityProjectionTruncated).toBe(true); + }); + + it('fits ordinary persisted child activity into the aggregate byte budget, newest first', async () => { + const conversationId = uuidv4(); + await saveMessage(mockCtx, { + messageId: 'task-budget:assistant', + conversationId, + text: '', + user: 'user123', + content: Array.from({ length: 12 }, (_, index) => ({ + type: 'text', + text: `chunk-${index}-${'z'.repeat(8_000)}`, + })), + }); + + const messages = await getMessagesForSubagentThreadView({ + user: 'user123', + conversationId, + limit: 1, + textCodePointLimit: 8_192, + }); + + const activity = messages[0].subagentActivity as Array<{ type: string; text: string }>; + expect(activity.length).toBeGreaterThan(0); + expect(activity.length).toBeLessThan(12); + expect(activity[activity.length - 1].text).toContain('chunk-11-'); + expect(activity[0].text).toContain(`chunk-${12 - activity.length}-`); + const totalBytes = activity.reduce((sum, item) => sum + item.text.length, 0); + expect(totalBytes).toBeLessThanOrEqual(64 * 1024); + expect(messages[0].subagentActivityProjectionTruncated).toBe(true); + }); + it('bounds ordinary persisted child activity before returning it to the API', async () => { const conversationId = uuidv4(); await saveMessage(mockCtx, { @@ -1949,8 +2014,8 @@ describe('Message Operations', () => { tool_call: { id: 'move-1', name: 'submit_move', - args: 'x'.repeat(2_000), - output: 'y'.repeat(4_000), + args: 'x'.repeat(3_000), + output: 'y'.repeat(5_000), progress: 1, inputValidationError: true, }, @@ -1990,8 +2055,8 @@ describe('Message Operations', () => { outputTruncated: true, }), ); - expect(retainedTool.input.length).toBeLessThan(2_000); - expect(retainedTool.output.length).toBeLessThan(4_000); + expect(retainedTool.input.length).toBeLessThan(3_000); + expect(retainedTool.output.length).toBeLessThan(5_000); const retainedLabel = messages[0].subagentActivity?.find( (activity) => (activity as { type?: string }).type === 'activity_label', ) as { agentIds: string[]; labelTruncated: boolean; toolCallIds: string[] }; @@ -1999,8 +2064,8 @@ describe('Message Operations', () => { expect(retainedLabel.agentIds).toHaveLength(8); expect(retainedLabel.labelTruncated).toBe(true); expect(JSON.stringify(messages[0])).not.toContain('activity-0'); - expect(JSON.stringify(messages[0])).not.toContain('x'.repeat(1_000)); - expect(JSON.stringify(messages[0])).not.toContain('y'.repeat(2_000)); + expect(JSON.stringify(messages[0])).not.toContain('x'.repeat(2_049)); + expect(JSON.stringify(messages[0])).not.toContain('y'.repeat(4_097)); }); it('bounds public control receipts before materializing the message page', async () => { diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index 14f1da3078..2806858847 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -347,13 +347,21 @@ const SUBAGENT_ACTIVITY_SOURCE_CANDIDATE_LIMIT = SUBAGENT_TRANSCRIPT_PAGE_LIMIT * an execution did not write a private subagent transcript. Project only the * visible activity vocabulary and bound it before MongoDB returns the row. */ -export const SUBAGENT_MESSAGE_ACTIVITY_ITEM_LIMIT: number = 16; -const SUBAGENT_MESSAGE_ACTIVITY_TEXT_CODE_POINT_LIMIT = 2048; -const SUBAGENT_MESSAGE_ACTIVITY_TOOL_INPUT_CODE_POINT_LIMIT = 512; -const SUBAGENT_MESSAGE_ACTIVITY_TOOL_OUTPUT_CODE_POINT_LIMIT = 1024; +/** + * Per-item bounds mirror `SUBAGENT_ACTIVITY_LIMITS` in `packages/api` + * (`src/agents/activity.ts`) at worst-case 4-byte UTF-8, so activity projected + * from ordinary message content is clipped no harder than activity projected + * from a private transcript. The whole view stays bounded downstream by the + * 64KB serialized-activity budget and the 256KB response trim. + */ +export const SUBAGENT_MESSAGE_ACTIVITY_ITEM_LIMIT: number = 100; +const SUBAGENT_MESSAGE_ACTIVITY_TEXT_CODE_POINT_LIMIT = 8192; +const SUBAGENT_MESSAGE_ACTIVITY_TOOL_INPUT_CODE_POINT_LIMIT = 2048; +const SUBAGENT_MESSAGE_ACTIVITY_TOOL_OUTPUT_CODE_POINT_LIMIT = 4096; const SUBAGENT_MESSAGE_ACTIVITY_ID_CODE_POINT_LIMIT = 128; const SUBAGENT_MESSAGE_ACTIVITY_LABEL_CODE_POINT_LIMIT = 512; const SUBAGENT_MESSAGE_ACTIVITY_LABEL_IDS_LIMIT = 8; +const SUBAGENT_MESSAGE_ACTIVITY_TOTAL_BYTE_LIMIT = 64 * 1024; const SUBAGENT_VIEW_CONTROL_RECEIPT_LIMIT = 32; const SUBAGENT_VIEW_CONTROL_STRING_CODE_POINT_LIMIT = 128; @@ -2290,7 +2298,130 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa null, ], }; - const boundedActivityContent = { + /** Mixed-typed content metadata must be type-gated before projection, or a + * malformed part could tunnel arbitrarily large values past the byte cap + * through a passthrough field. `$type`-based checks stay DocumentDB-safe. */ + const boundedNumber = (path: string) => ({ + $cond: [{ $in: [{ $type: path }, ['int', 'long', 'double', 'decimal']] }, path, null], + }); + const boundedBoolean = (path: string) => ({ + $cond: [{ $eq: [{ $type: path }, 'bool'] }, path, null], + }); + const boundedEnum = (path: string, allowed: string[]) => ({ + $cond: [{ $in: [path, allowed] }, path, null], + }); + /** Estimated serialized bytes for one already-clipped activity item; the + * constants cover the non-string JSON envelope per item type. This is a + * BSON-transfer budget: `$strLenBytes` measures exactly what MongoDB + * ships to the API. JSON escaping can expand strings after transfer, + * which the API bounds precisely — `boundActivity` in + * `packages/api/src/agents/activity.ts` re-fits the same array to 64KB + * of `JSON.stringify` output before anything reaches a response. */ + const activityItemBytes = (item: string) => ({ + $switch: { + branches: [ + { + case: { $eq: [`${item}.type`, 'writing'] }, + then: { $add: [{ $strLenBytes: `${item}.text` }, 64] }, + }, + { + case: { $eq: [`${item}.type`, 'activity_label'] }, + then: { + $add: [ + { $strLenBytes: `${item}.label` }, + { + $reduce: { + input: { + $concatArrays: [ + { + $cond: [{ $isArray: `${item}.toolCallIds` }, `${item}.toolCallIds`, []], + }, + { $cond: [{ $isArray: `${item}.agentIds` }, `${item}.agentIds`, []] }, + ], + }, + initialValue: 0, + in: { $add: ['$$value', { $strLenBytes: '$$this' }, 8] }, + }, + }, + 512, + ], + }, + }, + { + case: { $eq: [`${item}.type`, 'tool'] }, + then: { + $add: [ + { $strLenBytes: `${item}.input` }, + { $strLenBytes: `${item}.output` }, + { $strLenBytes: `${item}.name` }, + { $strLenBytes: `${item}.toolCallId` }, + 192, + ], + }, + }, + ], + default: 32, + }, + }); + /** Newest-first fit into the aggregate budget, so raising per-item limits + * cannot multiply into megabytes per row before the API's own trims run. */ + const budgetedActivity = (clipped: Record) => ({ + $reverseArray: { + $let: { + vars: { + fitted: { + $reduce: { + input: { $reverseArray: clipped }, + initialValue: { items: [] as never[], bytes: 0, done: false }, + in: { + $let: { + vars: { size: activityItemBytes('$$this') }, + in: { + $cond: [ + { + $or: [ + '$$value.done', + { + $gt: [ + { $add: ['$$value.bytes', '$$size'] }, + SUBAGENT_MESSAGE_ACTIVITY_TOTAL_BYTE_LIMIT, + ], + }, + ], + }, + /* The retained timeline must be a contiguous newest + suffix: once one entry does not fit, older entries + are not allowed to fill the gap around it. */ + { items: '$$value.items', bytes: '$$value.bytes', done: true }, + { + items: { $concatArrays: ['$$value.items', ['$$this']] }, + bytes: { $add: ['$$value.bytes', '$$size'] }, + done: false, + }, + ], + }, + }, + }, + }, + }, + }, + in: '$$fitted.items', + }, + }, + }); + const activityBytesOverBudget = (clipped: Record) => ({ + $gt: [ + { + $reduce: { + input: clipped, + initialValue: 0, + in: { $add: ['$$value', activityItemBytes('$$this')] }, + }, + }, + SUBAGENT_MESSAGE_ACTIVITY_TOTAL_BYTE_LIMIT, + ], + }); + const clippedActivityContent = { $filter: { input: { $map: { @@ -2330,14 +2461,14 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa '$$part.activity_label', SUBAGENT_MESSAGE_ACTIVITY_LABEL_CODE_POINT_LIMIT, ), - labelType: '$$part.activity_label_type', + labelType: boundedEnum('$$part.activity_label_type', ['phase']), toolCallIds: boundedStringArray('$$part.tool_call_ids'), - activityStartIndex: '$$part.activity_start_index', - activityEndIndex: '$$part.activity_end_index', - activityCount: '$$part.activity_count', + activityStartIndex: boundedNumber('$$part.activity_start_index'), + activityEndIndex: boundedNumber('$$part.activity_end_index'), + activityCount: boundedNumber('$$part.activity_count'), agentIds: boundedStringArray('$$part.agent_ids'), - status: '$$part.status', - pending: '$$part.pending', + status: boundedEnum('$$part.status', ['ok', 'partial', 'failed']), + pending: boundedBoolean('$$part.pending'), labelTruncated: { $or: [ stringProjectionTruncated( @@ -2396,9 +2527,16 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa '$$part.tool_call.output', SUBAGENT_MESSAGE_ACTIVITY_TOOL_OUTPUT_CODE_POINT_LIMIT, ), - progress: '$$part.tool_call.progress', - runStepStatus: '$$part.tool_call.runStepStatus', - inputValidationError: '$$part.tool_call.inputValidationError', + progress: boundedNumber('$$part.tool_call.progress'), + runStepStatus: boundedEnum('$$part.tool_call.runStepStatus', [ + 'running', + 'completed', + 'failed', + 'cancelled', + ]), + inputValidationError: boundedBoolean( + '$$part.tool_call.inputValidationError', + ), inputTruncated: stringProjectionTruncated( '$$part.tool_call.args', SUBAGENT_MESSAGE_ACTIVITY_TOOL_INPUT_CODE_POINT_LIMIT, @@ -2419,6 +2557,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa cond: { $ne: ['$$activity', null] }, }, }; + const boundedActivityContent = budgetedActivity(clippedActivityContent); type ActivitySourceProjection = WithNullSentinels< Pick< SubagentThreadViewMessageRecord, @@ -2450,11 +2589,16 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa }, subagentActivity: boundedActivityContent, subagentActivityProjectionTruncated: { - $gt: [ + $or: [ { - $size: { $cond: [{ $isArray: '$content' }, '$content', []] }, + $gt: [ + { + $size: { $cond: [{ $isArray: '$content' }, '$content', []] }, + }, + SUBAGENT_MESSAGE_ACTIVITY_ITEM_LIMIT, + ], }, - SUBAGENT_MESSAGE_ACTIVITY_ITEM_LIMIT, + activityBytesOverBudget(clippedActivityContent), ], }, subagentTask: boundedSubagentTask,