Skip to content

Commit dcf884e

Browse files
committed
improvement(mothership): send a bounded block overview instead of a bare tool pointer
Follow-up to the previous commit's fix (stop inlining full execution traces). A pure text pointer telling the model to call query_logs made the agent's very first useful action against a tagged run contingent on it noticing and correctly acting on prose in a JSON blob it may only skim — every sibling resolver in this file instead returns a deterministic mechanism (a VFS path) the model reads on demand. There's no VFS materialization for individual execution logs, but the same deterministic signal is available cheaply: toOverview() (the exact projection query_logs's own "overview" view already returns) walks the raw trace spans and produces a compact tree — block name/type/status/ timing/cost, no input or output — without touching large-value refs at all. The summary now includes that tree, so the model can see which block failed on the first turn, and the note narrows to what still requires a tool call: a block's actual input/output/error, or a grep. materializeExecutionData is still called, but it's a no-op for the common inline case (it only unwraps a top-level object-storage pointer for runs whose whole trace was offloaded as one blob) and was needed to reach traceSpans at all for those heavier runs — exactly the runs most worth an overview. A serialized-size cap (mirroring query-logs.ts's own truncation fallback, scaled down since this lands in the prompt unconditionally) drops the overview if a pathological span count pushes it over budget, falling back to the note alone. Extends the tests: the happy path now asserts the overview tree is present and that no raw input/output payload leaks into the serialized summary, plus a new test for the size-cap fallback.
1 parent 2fd6cdb commit dcf884e

2 files changed

Lines changed: 118 additions & 14 deletions

File tree

apps/sim/lib/copilot/chat/process-contents.test.ts

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import type { ChatContext } from '@/stores/panel'
99
const { getSkillById } = vi.hoisted(() => ({ getSkillById: vi.fn() }))
1010

1111
vi.mock('@/lib/workflows/skills/operations', () => ({ getSkillById }))
12-
// Overrides the global `@sim/db` mock: the logs-context tests below need
13-
// controllable row data, which the stable dbChainMockFns.limit provides.
12+
/**
13+
* Overrides the global `@sim/db` mock: the logs-context tests below need
14+
* controllable row data, which the stable `dbChainMockFns.limit` provides.
15+
*/
1416
vi.mock('@sim/db', () => dbChainMock)
1517

1618
import { processContextsServer } from './process-contents'
@@ -77,7 +79,7 @@ describe('processContextsServer - logs contexts', () => {
7779
vi.clearAllMocks()
7880
})
7981

80-
it('resolves a tagged run to a compact summary pointing at query_logs, never the full trace', async () => {
82+
it('resolves a tagged run to a compact summary with a block overview, never raw input/output', async () => {
8183
dbChainMockFns.limit.mockResolvedValueOnce([
8284
{
8385
id: 'log-1',
@@ -89,6 +91,20 @@ describe('processContextsServer - logs contexts', () => {
8991
startedAt: new Date('2026-01-01T00:00:00.000Z'),
9092
endedAt: new Date('2026-01-01T00:00:01.000Z'),
9193
totalDurationMs: 1000,
94+
executionData: {
95+
traceSpans: [
96+
{
97+
id: 'span-1',
98+
blockId: 'block-1',
99+
name: 'Agent 1',
100+
type: 'agent',
101+
status: 'failed',
102+
duration: 500,
103+
input: { prompt: 'do the thing' },
104+
output: { error: '429 No active subscription' },
105+
},
106+
],
107+
},
92108
costTotal: '0.05',
93109
workflowName: 'My Flow',
94110
},
@@ -118,15 +134,67 @@ describe('processContextsServer - logs contexts', () => {
118134
trigger: 'manual',
119135
totalDurationMs: 1000,
120136
cost: { total: 0.05 },
137+
overview: [
138+
{
139+
id: 'span-1',
140+
blockId: 'block-1',
141+
name: 'Agent 1',
142+
type: 'agent',
143+
status: 'failed',
144+
durationMs: 500,
145+
},
146+
],
121147
})
122-
// No raw trace/error data — the model must pull it via the tool on demand.
123-
expect(summary).not.toHaveProperty('traceSpans')
124-
expect(summary).not.toHaveProperty('errorDetails')
125-
expect(summary).not.toHaveProperty('executionData')
148+
const serialized = JSON.stringify(summary)
149+
expect(serialized).not.toContain('do the thing')
150+
expect(serialized).not.toContain('429 No active subscription')
126151
expect(summary.note).toContain('query_logs')
127152
expect(summary.note).toContain('exec-1')
128153
})
129154

155+
it('drops the overview (keeping the rest of the summary) when it exceeds the size cap', async () => {
156+
const traceSpans = Array.from({ length: 2000 }, (_, i) => ({
157+
id: `span-${i}`,
158+
blockId: `block-${i}`,
159+
name: `Block ${i}`,
160+
type: 'agent',
161+
status: 'success',
162+
duration: 10,
163+
}))
164+
dbChainMockFns.limit.mockResolvedValueOnce([
165+
{
166+
id: 'log-1',
167+
workflowId: 'wf-1',
168+
workspaceId: 'ws-1',
169+
executionId: 'exec-1',
170+
level: 'error',
171+
trigger: 'manual',
172+
startedAt: new Date('2026-01-01T00:00:00.000Z'),
173+
endedAt: null,
174+
totalDurationMs: null,
175+
executionData: { traceSpans },
176+
costTotal: null,
177+
workflowName: 'My Flow',
178+
},
179+
])
180+
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
181+
allowed: true,
182+
workflow: { workspaceId: 'ws-1' },
183+
})
184+
185+
const result = await processContextsServer(
186+
[{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext],
187+
'user-1',
188+
'hello',
189+
'ws-1'
190+
)
191+
192+
const summary = JSON.parse(result[0].content)
193+
expect(summary.overview).toBeUndefined()
194+
expect(summary.executionId).toBe('exec-1')
195+
expect(summary.note).toContain('query_logs')
196+
})
197+
130198
it('drops a log context when the workflow is outside the current workspace', async () => {
131199
dbChainMockFns.limit.mockResolvedValueOnce([
132200
{

apps/sim/lib/copilot/chat/process-contents.ts

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import {
1919
encodeVfsSegment,
2020
} from '@/lib/copilot/vfs/path-utils'
2121
import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags'
22+
import { toOverview } from '@/lib/logs/log-views'
23+
import type { TraceSpan } from '@/lib/logs/types'
2224
import { getTableById } from '@/lib/table/service'
2325
import { getWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
2426
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
@@ -566,6 +568,31 @@ async function processWorkflowBlockFromDb(
566568
}
567569
}
568570

571+
/**
572+
* Cap on the serialized summary (including the block overview tree) sent for
573+
* a tagged run. `toOverview` already excludes every block's input/output, so
574+
* this is a safety net against pathological span counts, not the primary
575+
* defense — mirrors `MAX_FULL_RESULT_BYTES` in `query-logs.ts`, scaled down
576+
* since this lands in the prompt unconditionally rather than behind an
577+
* explicit tool call.
578+
*/
579+
const MAX_LOG_SUMMARY_BYTES = 64 * 1024
580+
581+
/**
582+
* Resolve a tagged run to a compact summary instead of its full execution
583+
* trace. A run's trace can carry every block's input/output plus nested
584+
* tool-call spans, which is unbounded and would repeatedly blow the context
585+
* window if inlined directly. The summary includes the block-level overview
586+
* tree (name/type/status/timing/cost, no input or output — the same
587+
* projection `query_logs`'s `overview` view returns) so the model can see
588+
* which block failed without a round trip, and points it at `query_logs` for
589+
* that block's actual input/output/error, or to grep the trace.
590+
*
591+
* `materializeExecutionData` only unwraps a top-level object-storage pointer,
592+
* for runs whose whole trace was offloaded as one blob — a no-op for the
593+
* common inline case. Individual span input/output stay as large-value refs;
594+
* `toOverview` never resolves those.
595+
*/
569596
async function processExecutionLogFromDb(
570597
executionId: string,
571598
userId: string | undefined,
@@ -585,6 +612,7 @@ async function processExecutionLogFromDb(
585612
startedAt: workflowExecutionLogs.startedAt,
586613
endedAt: workflowExecutionLogs.endedAt,
587614
totalDurationMs: workflowExecutionLogs.totalDurationMs,
615+
executionData: workflowExecutionLogs.executionData,
588616
costTotal: workflowExecutionLogs.costTotal,
589617
workflowName: workflow.name,
590618
})
@@ -610,12 +638,15 @@ async function processExecutionLogFromDb(
610638
}
611639
}
612640

613-
// Deliberately no execution-data materialization here: a run's full trace
614-
// (every block's input/output, nested tool-call spans) can be huge and
615-
// would inline directly into the prompt, repeatedly blowing the context
616-
// window. Send a compact summary instead and point the model at
617-
// `query_logs`, which already supports incremental disclosure (overview →
618-
// full → grep) for exactly this case.
641+
const { materializeExecutionData } = await import('@/lib/logs/execution/trace-store')
642+
const executionData = (await materializeExecutionData(
643+
log.executionData as Record<string, unknown> | null,
644+
{ workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId }
645+
)) as { traceSpans?: TraceSpan[] } | undefined
646+
const overview = executionData?.traceSpans?.length
647+
? toOverview(executionData.traceSpans)
648+
: undefined
649+
619650
const summary = {
620651
id: log.id,
621652
workflowId: log.workflowId,
@@ -627,7 +658,12 @@ async function processExecutionLogFromDb(
627658
totalDurationMs: log.totalDurationMs ?? null,
628659
workflowName: log.workflowName || '',
629660
cost: log.costTotal != null ? { total: Number(log.costTotal) } : undefined,
630-
note: `Trace not included here. Call ${QueryLogs.id} with executionId: '${log.executionId}' to inspect this run — view: 'overview' for the timing/cost tree, view: 'full' for a block's input/output (scope with blockId or blockName), or pattern to grep the trace for an error.`,
661+
overview,
662+
note: `For a block's input/output/error, or to grep the trace, call ${QueryLogs.id} with executionId: '${log.executionId}' — view: 'full' (scope with blockId or blockName), or pattern to grep.`,
663+
}
664+
665+
if (overview && JSON.stringify(summary).length > MAX_LOG_SUMMARY_BYTES) {
666+
summary.overview = undefined
631667
}
632668

633669
const content = JSON.stringify(summary)

0 commit comments

Comments
 (0)