From b05178b7226348b4eeca437f3928bd1d698cde00 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Thu, 22 Oct 2026 17:59:45 -0700 Subject: [PATCH] Keep child workflow diagnostics out of model tool responses --- apps/sim/providers/runtime-context.test.ts | 89 ++++++++++++++++++++++ apps/sim/providers/runtime-context.ts | 36 +++++---- 2 files changed, 110 insertions(+), 15 deletions(-) diff --git a/apps/sim/providers/runtime-context.test.ts b/apps/sim/providers/runtime-context.test.ts index 6c05cefbe89..fd67323c601 100644 --- a/apps/sim/providers/runtime-context.test.ts +++ b/apps/sim/providers/runtime-context.test.ts @@ -32,6 +32,95 @@ async function executeProviderTool( } describe('provider runtime context', () => { + it.each([false, true])( + 'keeps workflow diagnostics out of model context with secret projection=%s', + async (withRegistry) => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'TOKEN', plaintext: 'secret-value', encryptedValue: 'ciphertext' }, + ]) + const rawResponse = { + success: true, + output: { + success: true, + result: { token: 'secret-value', childTraceSpans: 'business field' }, + childTraceSpans: [{ input: { source: 'x'.repeat(17 * 1024 * 1024) } }], + _childWorkflowInstanceId: 'child-instance', + cost: { total: 0.25 }, + }, + } + mockExecuteTool.mockImplementationOnce(async (_toolId, _params, options) => { + options.resolvedSecretTraceRegistry?.recordResolved('TOKEN', 'secret-value', { + propagated: true, + }) + return rawResponse + }) + const result = await runWithProviderRuntimeContext( + { + toolIdByWireId: new Map([['selected_workflow', 'workflow_executor']]), + ...(withRegistry ? { resolvedSecretTraceRegistry: registry } : {}), + }, + () => executeProviderToolWithInput('selected_workflow', {}) + ) + expect(result.rawResponse).toBe(rawResponse) + expect(Object.keys(result.modelResponse.output)).not.toContain('childTraceSpans') + expect(Object.keys(result.modelResponse.output)).not.toContain('_childWorkflowInstanceId') + expect(result.modelResponse.output.result).toEqual({ + token: withRegistry ? '{{TOKEN}}' : 'secret-value', + childTraceSpans: 'business field', + }) + expect(result.rawResponse.output.cost.total).toBe(0.25) + if (withRegistry) expect(registry.isComplete()).toBe(true) + } + ) + + it('preserves similarly named business fields from non-workflow tools', async () => { + const response = { + success: true, + output: { + childTraceSpans: ['business data'], + _childWorkflowInstanceId: 'business identifier', + }, + } + mockExecuteTool.mockResolvedValueOnce(response) + const result = await executeProviderToolWithInput('http_request', {}) + expect(result.modelResponse).toEqual(response) + }) + + it('does not replay legacy workflow diagnostics into the model or rebill the child', async () => { + const session = new AgentTurnStateMachine({ save: vi.fn() }) + await session.captureStep({ + assistant: { role: 'assistant', content: '' }, + calls: [{ toolId: 'workflow_executor', providerCallId: 'child-call', arguments: '{}' }], + native: { + protocol: 'responses', + providerId: 'openai', + model: 'gpt-4.1-mini', + binding: 'binding', + value: {}, + }, + }) + const invocationId = session.resolveInvocationId('child-call', 'workflow_executor')! + const response = { + success: true, + output: { + result: { answer: 'retained answer' }, + childTraceSpans: [{ input: 'internal trace' }], + _childWorkflowInstanceId: 'child-instance', + cost: { total: 0.25 }, + }, + } + await session.recordToolResult({ invocationId, rawResponse: response, modelResponse: response }) + const replay = await runWithProviderRuntimeContext({ agentConversation: session }, () => + executeProviderToolWithInput('workflow_executor', { _context: { invocationId } }) + ) + expect(replay.rawResponse.output.childTraceSpans).toEqual(response.output.childTraceSpans) + expect(replay.rawResponse.output.cost).toMatchObject({ total: 0 }) + expect(Object.keys(replay.modelResponse.output)).not.toContain('childTraceSpans') + expect(Object.keys(replay.modelResponse.output)).not.toContain('_childWorkflowInstanceId') + expect(replay.modelResponse.output.result).toEqual({ answer: 'retained answer' }) + expect(mockExecuteTool).not.toHaveBeenCalled() + }) + it('dispatches the bound memory reader and reauthorizes repeated invocation reads', async () => { const execute = vi .fn() diff --git a/apps/sim/providers/runtime-context.ts b/apps/sim/providers/runtime-context.ts index 4aa6bf9cc50..c1c8fb648f4 100644 --- a/apps/sim/providers/runtime-context.ts +++ b/apps/sim/providers/runtime-context.ts @@ -94,27 +94,26 @@ function toProviderModelResponse( } /** - * Drops a custom block's child-run handle from the copy bound for the model. + * Drops executor-owned diagnostics from the copy bound for the model, before secret projection. * * The handle has to survive on `rawResponse`, which is what the tool-call record * (and therefore the trace span) is built from — but it is trace plumbing, and an * opaque execution id in a tool result reads to a model like data the tool * returned, which it may then quote back to the user. Applied at this single * split point because every provider's tool loop goes through here; there is no - * other place both copies exist. + * other place both copies exist. Workflow tools also carry full child spans, including intermediate + * inputs and outputs, which can dwarf their final result. Only their wrapper is filtered; nested + * business data and similarly named output fields from unrelated tools remain untouched. */ -function withoutChildTraceHandle(response: ToolResponse): ToolResponse { +function withoutToolDiagnostics(response: ToolResponse, toolId: string): ToolResponse { const output = response.output if (!isRecordLike(output)) return response - if ( - !Object.hasOwn(output, CHILD_EXECUTION_ID_OUTPUT_KEY) && - !Object.hasOwn(output, CHILD_TRACE_DISABLED_OUTPUT_KEY) - ) { - return response - } + const keys = [CHILD_EXECUTION_ID_OUTPUT_KEY, CHILD_TRACE_DISABLED_OUTPUT_KEY] + if (toolId === 'workflow_executor') keys.push('childTraceSpans', '_childWorkflowInstanceId') + if (!keys.some((key) => Object.hasOwn(output, key))) return response return { ...response, - output: omit(output, [CHILD_EXECUTION_ID_OUTPUT_KEY, CHILD_TRACE_DISABLED_OUTPUT_KEY]), + output: omit(output, keys), } } @@ -226,8 +225,10 @@ export async function executeProviderTool( : recorded.rawResponse return { rawResponse, - modelResponse: + modelResponse: withoutToolDiagnostics( session?.getRecordedResult?.(invocationId!)?.modelResponse ?? recorded.modelResponse, + executionToolId + ), } } const recordResult = async ( @@ -249,7 +250,10 @@ export async function executeProviderTool( }) const retained = session.getRecordedResult?.(invocationId) if (retained && !memoryRetrieval) - return { ...result, modelResponse: retained.modelResponse } + return { + ...result, + modelResponse: withoutToolDiagnostics(retained.modelResponse, executionToolId), + } } } catch { logger.warn('Agent tool result durability unavailable') @@ -314,12 +318,14 @@ export async function executeProviderTool( resolvedSecretTraceRegistry: toolCallRegistry, }) accumulateToolCost(executionToolId, result, runtimeContext) + const functionalResult = withoutToolDiagnostics(result, executionToolId) if (!registry || !toolCallRegistry) { - return recordResult({ rawResponse: result, modelResponse: withoutChildTraceHandle(result) }) + return recordResult({ rawResponse: result, modelResponse: functionalResult }) } - const modelResponse = withoutChildTraceHandle( - toProviderModelResponse(result, projectToolResultForCopilot(result, toolCallRegistry)) + const modelResponse = toProviderModelResponse( + functionalResult, + projectToolResultForCopilot(functionalResult, toolCallRegistry) ) registry.mergeToolCallRegistry(toolCallRegistry) return recordResult({ rawResponse: result, modelResponse })