From 2fd6cdb3b2a88d3df92860de940d3ce9df43ab26 Mon Sep 17 00:00:00 2001 From: waleed Date: Wed, 1 Jul 2026 22:22:10 -0700 Subject: [PATCH 1/2] 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. --- .../lib/copilot/chat/process-contents.test.ts | 121 ++++++++++++++++++ apps/sim/lib/copilot/chat/process-contents.ts | 22 ++-- 2 files changed, 129 insertions(+), 14 deletions(-) diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index 37aeaa2735c..f533fa4fefd 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -2,12 +2,16 @@ * @vitest-environment node */ +import { dbChainMock, dbChainMockFns, workflowAuthzMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ChatContext } from '@/stores/panel' const { getSkillById } = vi.hoisted(() => ({ getSkillById: vi.fn() })) vi.mock('@/lib/workflows/skills/operations', () => ({ getSkillById })) +// Overrides the global `@sim/db` mock: the logs-context tests below need +// controllable row data, which the stable dbChainMockFns.limit provides. +vi.mock('@sim/db', () => dbChainMock) import { processContextsServer } from './process-contents' @@ -67,3 +71,120 @@ describe('processContextsServer - skill contexts', () => { expect(result).toEqual([]) }) }) + +describe('processContextsServer - logs contexts', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('resolves a tagged run to a compact summary pointing at query_logs, never the full trace', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'log-1', + workflowId: 'wf-1', + workspaceId: 'ws-1', + executionId: 'exec-1', + level: 'error', + trigger: 'manual', + startedAt: new Date('2026-01-01T00:00:00.000Z'), + endedAt: new Date('2026-01-01T00:00:01.000Z'), + totalDurationMs: 1000, + costTotal: '0.05', + workflowName: 'My Flow', + }, + ]) + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + allowed: true, + workflow: { workspaceId: 'ws-1' }, + }) + + const result = await processContextsServer( + [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext], + 'user-1', + 'hello', + 'ws-1' + ) + + expect(result).toHaveLength(1) + expect(result[0].type).toBe('logs') + expect(result[0].tag).toBe('@My Flow') + + const summary = JSON.parse(result[0].content) + expect(summary).toMatchObject({ + executionId: 'exec-1', + workflowId: 'wf-1', + workflowName: 'My Flow', + level: 'error', + trigger: 'manual', + totalDurationMs: 1000, + cost: { total: 0.05 }, + }) + // No raw trace/error data — the model must pull it via the tool on demand. + expect(summary).not.toHaveProperty('traceSpans') + expect(summary).not.toHaveProperty('errorDetails') + expect(summary).not.toHaveProperty('executionData') + expect(summary.note).toContain('query_logs') + expect(summary.note).toContain('exec-1') + }) + + it('drops a log context when the workflow is outside the current workspace', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'log-1', + workflowId: 'wf-1', + workspaceId: 'ws-other', + executionId: 'exec-1', + level: 'error', + trigger: 'manual', + startedAt: new Date('2026-01-01T00:00:00.000Z'), + endedAt: null, + totalDurationMs: null, + costTotal: null, + workflowName: 'My Flow', + }, + ]) + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + allowed: true, + workflow: { workspaceId: 'ws-other' }, + }) + + const result = await processContextsServer( + [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext], + 'user-1', + 'hello', + 'ws-1' + ) + + expect(result).toEqual([]) + }) + + it('drops a log context the user is not authorized to read', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'log-1', + workflowId: 'wf-1', + workspaceId: 'ws-1', + executionId: 'exec-1', + level: 'error', + trigger: 'manual', + startedAt: new Date('2026-01-01T00:00:00.000Z'), + endedAt: null, + totalDurationMs: null, + costTotal: null, + workflowName: 'My Flow', + }, + ]) + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + allowed: false, + }) + + const result = await processContextsServer( + [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext], + 'user-1', + 'hello', + 'ws-1' + ) + + expect(result).toEqual([]) + }) +}) diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index ef33580211c..7cc1d9f680d 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -6,6 +6,7 @@ import { getActiveWorkflowRecord, } from '@sim/platform-authz/workflow' import { and, eq, isNull, ne } from 'drizzle-orm' +import { QueryLogs } from '@/lib/copilot/generated/tool-catalog-v1' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { buildVfsFolderPathMap, @@ -584,7 +585,6 @@ async function processExecutionLogFromDb( startedAt: workflowExecutionLogs.startedAt, endedAt: workflowExecutionLogs.endedAt, totalDurationMs: workflowExecutionLogs.totalDurationMs, - executionData: workflowExecutionLogs.executionData, costTotal: workflowExecutionLogs.costTotal, workflowName: workflow.name, }) @@ -610,13 +610,12 @@ async function processExecutionLogFromDb( } } - // Heavy execution data may live in object storage; resolve the pointer. - const { materializeExecutionData } = await import('@/lib/logs/execution/trace-store') - const executionData = (await materializeExecutionData( - log.executionData as Record | null, - { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } - )) as any - + // Deliberately no execution-data materialization here: a run's full trace + // (every block's input/output, nested tool-call spans) can be huge and + // would inline directly into the prompt, repeatedly blowing the context + // window. Send a compact summary instead and point the model at + // `query_logs`, which already supports incremental disclosure (overview → + // full → grep) for exactly this case. const summary = { id: log.id, workflowId: log.workflowId, @@ -627,13 +626,8 @@ async function processExecutionLogFromDb( endedAt: log.endedAt?.toISOString?.() || (log.endedAt ? String(log.endedAt) : null), totalDurationMs: log.totalDurationMs ?? null, workflowName: log.workflowName || '', - executionData: executionData - ? { - traceSpans: executionData.traceSpans || undefined, - errorDetails: executionData.errorDetails || undefined, - } - : undefined, cost: log.costTotal != null ? { total: Number(log.costTotal) } : undefined, + 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.`, } const content = JSON.stringify(summary) From dcf884e68d12152d54bb320dea00e68cc962fb83 Mon Sep 17 00:00:00 2001 From: waleed Date: Wed, 1 Jul 2026 22:34:47 -0700 Subject: [PATCH 2/2] improvement(mothership): send a bounded block overview instead of a bare tool pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../lib/copilot/chat/process-contents.test.ts | 82 +++++++++++++++++-- apps/sim/lib/copilot/chat/process-contents.ts | 50 +++++++++-- 2 files changed, 118 insertions(+), 14 deletions(-) diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index f533fa4fefd..e1d6ec21740 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -9,8 +9,10 @@ import type { ChatContext } from '@/stores/panel' const { getSkillById } = vi.hoisted(() => ({ getSkillById: vi.fn() })) vi.mock('@/lib/workflows/skills/operations', () => ({ getSkillById })) -// Overrides the global `@sim/db` mock: the logs-context tests below need -// controllable row data, which the stable dbChainMockFns.limit provides. +/** + * Overrides the global `@sim/db` mock: the logs-context tests below need + * controllable row data, which the stable `dbChainMockFns.limit` provides. + */ vi.mock('@sim/db', () => dbChainMock) import { processContextsServer } from './process-contents' @@ -77,7 +79,7 @@ describe('processContextsServer - logs contexts', () => { vi.clearAllMocks() }) - it('resolves a tagged run to a compact summary pointing at query_logs, never the full trace', async () => { + it('resolves a tagged run to a compact summary with a block overview, never raw input/output', async () => { dbChainMockFns.limit.mockResolvedValueOnce([ { id: 'log-1', @@ -89,6 +91,20 @@ describe('processContextsServer - logs contexts', () => { startedAt: new Date('2026-01-01T00:00:00.000Z'), endedAt: new Date('2026-01-01T00:00:01.000Z'), totalDurationMs: 1000, + executionData: { + traceSpans: [ + { + id: 'span-1', + blockId: 'block-1', + name: 'Agent 1', + type: 'agent', + status: 'failed', + duration: 500, + input: { prompt: 'do the thing' }, + output: { error: '429 No active subscription' }, + }, + ], + }, costTotal: '0.05', workflowName: 'My Flow', }, @@ -118,15 +134,67 @@ describe('processContextsServer - logs contexts', () => { trigger: 'manual', totalDurationMs: 1000, cost: { total: 0.05 }, + overview: [ + { + id: 'span-1', + blockId: 'block-1', + name: 'Agent 1', + type: 'agent', + status: 'failed', + durationMs: 500, + }, + ], }) - // No raw trace/error data — the model must pull it via the tool on demand. - expect(summary).not.toHaveProperty('traceSpans') - expect(summary).not.toHaveProperty('errorDetails') - expect(summary).not.toHaveProperty('executionData') + const serialized = JSON.stringify(summary) + expect(serialized).not.toContain('do the thing') + expect(serialized).not.toContain('429 No active subscription') expect(summary.note).toContain('query_logs') expect(summary.note).toContain('exec-1') }) + it('drops the overview (keeping the rest of the summary) when it exceeds the size cap', async () => { + const traceSpans = Array.from({ length: 2000 }, (_, i) => ({ + id: `span-${i}`, + blockId: `block-${i}`, + name: `Block ${i}`, + type: 'agent', + status: 'success', + duration: 10, + })) + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'log-1', + workflowId: 'wf-1', + workspaceId: 'ws-1', + executionId: 'exec-1', + level: 'error', + trigger: 'manual', + startedAt: new Date('2026-01-01T00:00:00.000Z'), + endedAt: null, + totalDurationMs: null, + executionData: { traceSpans }, + costTotal: null, + workflowName: 'My Flow', + }, + ]) + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + allowed: true, + workflow: { workspaceId: 'ws-1' }, + }) + + const result = await processContextsServer( + [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext], + 'user-1', + 'hello', + 'ws-1' + ) + + const summary = JSON.parse(result[0].content) + expect(summary.overview).toBeUndefined() + expect(summary.executionId).toBe('exec-1') + expect(summary.note).toContain('query_logs') + }) + it('drops a log context when the workflow is outside the current workspace', async () => { dbChainMockFns.limit.mockResolvedValueOnce([ { diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 7cc1d9f680d..a49ea07ccc0 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -19,6 +19,8 @@ import { encodeVfsSegment, } from '@/lib/copilot/vfs/path-utils' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' +import { toOverview } from '@/lib/logs/log-views' +import type { TraceSpan } from '@/lib/logs/types' import { getTableById } from '@/lib/table/service' import { getWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' @@ -566,6 +568,31 @@ async function processWorkflowBlockFromDb( } } +/** + * Cap on the serialized summary (including the block overview tree) sent for + * a tagged run. `toOverview` already excludes every block's input/output, so + * this is a safety net against pathological span counts, not the primary + * defense — mirrors `MAX_FULL_RESULT_BYTES` in `query-logs.ts`, scaled down + * since this lands in the prompt unconditionally rather than behind an + * explicit tool call. + */ +const MAX_LOG_SUMMARY_BYTES = 64 * 1024 + +/** + * Resolve a tagged run to a compact summary instead of its full execution + * trace. A run's trace can carry every block's input/output plus nested + * tool-call spans, which is unbounded and would repeatedly blow the context + * window if inlined directly. The summary includes the block-level overview + * tree (name/type/status/timing/cost, no input or output — the same + * projection `query_logs`'s `overview` view returns) so the model can see + * which block failed without a round trip, and points it at `query_logs` for + * that block's actual input/output/error, or to grep the trace. + * + * `materializeExecutionData` only unwraps a top-level object-storage pointer, + * for runs whose whole trace was offloaded as one blob — a no-op for the + * common inline case. Individual span input/output stay as large-value refs; + * `toOverview` never resolves those. + */ async function processExecutionLogFromDb( executionId: string, userId: string | undefined, @@ -585,6 +612,7 @@ async function processExecutionLogFromDb( startedAt: workflowExecutionLogs.startedAt, endedAt: workflowExecutionLogs.endedAt, totalDurationMs: workflowExecutionLogs.totalDurationMs, + executionData: workflowExecutionLogs.executionData, costTotal: workflowExecutionLogs.costTotal, workflowName: workflow.name, }) @@ -610,12 +638,15 @@ async function processExecutionLogFromDb( } } - // Deliberately no execution-data materialization here: a run's full trace - // (every block's input/output, nested tool-call spans) can be huge and - // would inline directly into the prompt, repeatedly blowing the context - // window. Send a compact summary instead and point the model at - // `query_logs`, which already supports incremental disclosure (overview → - // full → grep) for exactly this case. + const { materializeExecutionData } = await import('@/lib/logs/execution/trace-store') + const executionData = (await materializeExecutionData( + log.executionData as Record | null, + { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } + )) as { traceSpans?: TraceSpan[] } | undefined + const overview = executionData?.traceSpans?.length + ? toOverview(executionData.traceSpans) + : undefined + const summary = { id: log.id, workflowId: log.workflowId, @@ -627,7 +658,12 @@ async function processExecutionLogFromDb( totalDurationMs: log.totalDurationMs ?? null, workflowName: log.workflowName || '', cost: log.costTotal != null ? { total: Number(log.costTotal) } : undefined, - 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.`, + overview, + 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.`, + } + + if (overview && JSON.stringify(summary).length > MAX_LOG_SUMMARY_BYTES) { + summary.overview = undefined } const content = JSON.stringify(summary)