Skip to content

Commit 220da44

Browse files
authored
fix(mothership): stop inlining full execution traces for the logs context (#5353)
* fix(mothership): stop inlining full execution traces for the logs context Tagging a run via "Troubleshoot in Chat" (or any @-mention of a logs context) resolved through processExecutionLogFromDb, which materialized the ENTIRE execution trace (every block's input/output, nested tool-call spans) and inlined it directly into the prompt. For any non-trivial run this repeatedly blew the context window, forcing multiple compactions and eventually auto-stopping the agent before it could investigate anything. Every other context resolver in this file already avoids this by sending a lightweight pointer instead of a full inline dump (workflow/blocks/ workflow_block contexts point into the VFS). Logs contexts have no VFS materialization to point at, but the equivalent lightweight mechanism already exists as a tool: query_logs supports incremental disclosure (overview for timing/cost, full for a scoped block's input/output, or pattern to grep the trace) and is already registered for the mothership agent. Now processExecutionLogFromDb sends a compact summary (id, workflow, level, trigger, timing, cost) plus a note pointing the model at query_logs with the executionId, instead of materializing and embedding the trace. Also drops the now-unused executionData column from the select projection, so resolving a logs context no longer fetches a potentially large JSONB blob it never reads. * 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 b346088 commit 220da44

2 files changed

Lines changed: 227 additions & 8 deletions

File tree

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

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,18 @@
22
* @vitest-environment node
33
*/
44

5+
import { dbChainMock, dbChainMockFns, workflowAuthzMockFns } from '@sim/testing'
56
import { beforeEach, describe, expect, it, vi } from 'vitest'
67
import type { ChatContext } from '@/stores/panel'
78

89
const { getSkillById } = vi.hoisted(() => ({ getSkillById: vi.fn() }))
910

1011
vi.mock('@/lib/workflows/skills/operations', () => ({ getSkillById }))
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+
*/
16+
vi.mock('@sim/db', () => dbChainMock)
1117

1218
import { processContextsServer } from './process-contents'
1319

@@ -67,3 +73,186 @@ describe('processContextsServer - skill contexts', () => {
6773
expect(result).toEqual([])
6874
})
6975
})
76+
77+
describe('processContextsServer - logs contexts', () => {
78+
beforeEach(() => {
79+
vi.clearAllMocks()
80+
})
81+
82+
it('resolves a tagged run to a compact summary with a block overview, never raw input/output', async () => {
83+
dbChainMockFns.limit.mockResolvedValueOnce([
84+
{
85+
id: 'log-1',
86+
workflowId: 'wf-1',
87+
workspaceId: 'ws-1',
88+
executionId: 'exec-1',
89+
level: 'error',
90+
trigger: 'manual',
91+
startedAt: new Date('2026-01-01T00:00:00.000Z'),
92+
endedAt: new Date('2026-01-01T00:00:01.000Z'),
93+
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+
},
108+
costTotal: '0.05',
109+
workflowName: 'My Flow',
110+
},
111+
])
112+
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
113+
allowed: true,
114+
workflow: { workspaceId: 'ws-1' },
115+
})
116+
117+
const result = await processContextsServer(
118+
[{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext],
119+
'user-1',
120+
'hello',
121+
'ws-1'
122+
)
123+
124+
expect(result).toHaveLength(1)
125+
expect(result[0].type).toBe('logs')
126+
expect(result[0].tag).toBe('@My Flow')
127+
128+
const summary = JSON.parse(result[0].content)
129+
expect(summary).toMatchObject({
130+
executionId: 'exec-1',
131+
workflowId: 'wf-1',
132+
workflowName: 'My Flow',
133+
level: 'error',
134+
trigger: 'manual',
135+
totalDurationMs: 1000,
136+
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+
],
147+
})
148+
const serialized = JSON.stringify(summary)
149+
expect(serialized).not.toContain('do the thing')
150+
expect(serialized).not.toContain('429 No active subscription')
151+
expect(summary.note).toContain('query_logs')
152+
expect(summary.note).toContain('exec-1')
153+
})
154+
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+
198+
it('drops a log context when the workflow is outside the current workspace', async () => {
199+
dbChainMockFns.limit.mockResolvedValueOnce([
200+
{
201+
id: 'log-1',
202+
workflowId: 'wf-1',
203+
workspaceId: 'ws-other',
204+
executionId: 'exec-1',
205+
level: 'error',
206+
trigger: 'manual',
207+
startedAt: new Date('2026-01-01T00:00:00.000Z'),
208+
endedAt: null,
209+
totalDurationMs: null,
210+
costTotal: null,
211+
workflowName: 'My Flow',
212+
},
213+
])
214+
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
215+
allowed: true,
216+
workflow: { workspaceId: 'ws-other' },
217+
})
218+
219+
const result = await processContextsServer(
220+
[{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext],
221+
'user-1',
222+
'hello',
223+
'ws-1'
224+
)
225+
226+
expect(result).toEqual([])
227+
})
228+
229+
it('drops a log context the user is not authorized to read', async () => {
230+
dbChainMockFns.limit.mockResolvedValueOnce([
231+
{
232+
id: 'log-1',
233+
workflowId: 'wf-1',
234+
workspaceId: 'ws-1',
235+
executionId: 'exec-1',
236+
level: 'error',
237+
trigger: 'manual',
238+
startedAt: new Date('2026-01-01T00:00:00.000Z'),
239+
endedAt: null,
240+
totalDurationMs: null,
241+
costTotal: null,
242+
workflowName: 'My Flow',
243+
},
244+
])
245+
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
246+
allowed: false,
247+
})
248+
249+
const result = await processContextsServer(
250+
[{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext],
251+
'user-1',
252+
'hello',
253+
'ws-1'
254+
)
255+
256+
expect(result).toEqual([])
257+
})
258+
})

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

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
getActiveWorkflowRecord,
77
} from '@sim/platform-authz/workflow'
88
import { and, eq, isNull, ne } from 'drizzle-orm'
9+
import { QueryLogs } from '@/lib/copilot/generated/tool-catalog-v1'
910
import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment'
1011
import {
1112
buildVfsFolderPathMap,
@@ -18,6 +19,8 @@ import {
1819
encodeVfsSegment,
1920
} from '@/lib/copilot/vfs/path-utils'
2021
import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags'
22+
import { toOverview } from '@/lib/logs/log-views'
23+
import type { TraceSpan } from '@/lib/logs/types'
2124
import { getTableById } from '@/lib/table/service'
2225
import { getWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
2326
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
@@ -565,6 +568,31 @@ async function processWorkflowBlockFromDb(
565568
}
566569
}
567570

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+
*/
568596
async function processExecutionLogFromDb(
569597
executionId: string,
570598
userId: string | undefined,
@@ -610,12 +638,14 @@ async function processExecutionLogFromDb(
610638
}
611639
}
612640

613-
// Heavy execution data may live in object storage; resolve the pointer.
614641
const { materializeExecutionData } = await import('@/lib/logs/execution/trace-store')
615642
const executionData = (await materializeExecutionData(
616643
log.executionData as Record<string, unknown> | null,
617644
{ workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId }
618-
)) as any
645+
)) as { traceSpans?: TraceSpan[] } | undefined
646+
const overview = executionData?.traceSpans?.length
647+
? toOverview(executionData.traceSpans)
648+
: undefined
619649

620650
const summary = {
621651
id: log.id,
@@ -627,13 +657,13 @@ async function processExecutionLogFromDb(
627657
endedAt: log.endedAt?.toISOString?.() || (log.endedAt ? String(log.endedAt) : null),
628658
totalDurationMs: log.totalDurationMs ?? null,
629659
workflowName: log.workflowName || '',
630-
executionData: executionData
631-
? {
632-
traceSpans: executionData.traceSpans || undefined,
633-
errorDetails: executionData.errorDetails || undefined,
634-
}
635-
: undefined,
636660
cost: log.costTotal != null ? { total: Number(log.costTotal) } : undefined,
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
637667
}
638668

639669
const content = JSON.stringify(summary)

0 commit comments

Comments
 (0)