Skip to content

Commit b05178b

Browse files
committed
Keep child workflow diagnostics out of model tool responses
1 parent 9a66fee commit b05178b

2 files changed

Lines changed: 110 additions & 15 deletions

File tree

‎apps/sim/providers/runtime-context.test.ts‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,95 @@ async function executeProviderTool(
3232
}
3333

3434
describe('provider runtime context', () => {
35+
it.each([false, true])(
36+
'keeps workflow diagnostics out of model context with secret projection=%s',
37+
async (withRegistry) => {
38+
const registry = new ResolvedSecretTraceRegistry([
39+
{ name: 'TOKEN', plaintext: 'secret-value', encryptedValue: 'ciphertext' },
40+
])
41+
const rawResponse = {
42+
success: true,
43+
output: {
44+
success: true,
45+
result: { token: 'secret-value', childTraceSpans: 'business field' },
46+
childTraceSpans: [{ input: { source: 'x'.repeat(17 * 1024 * 1024) } }],
47+
_childWorkflowInstanceId: 'child-instance',
48+
cost: { total: 0.25 },
49+
},
50+
}
51+
mockExecuteTool.mockImplementationOnce(async (_toolId, _params, options) => {
52+
options.resolvedSecretTraceRegistry?.recordResolved('TOKEN', 'secret-value', {
53+
propagated: true,
54+
})
55+
return rawResponse
56+
})
57+
const result = await runWithProviderRuntimeContext(
58+
{
59+
toolIdByWireId: new Map([['selected_workflow', 'workflow_executor']]),
60+
...(withRegistry ? { resolvedSecretTraceRegistry: registry } : {}),
61+
},
62+
() => executeProviderToolWithInput('selected_workflow', {})
63+
)
64+
expect(result.rawResponse).toBe(rawResponse)
65+
expect(Object.keys(result.modelResponse.output)).not.toContain('childTraceSpans')
66+
expect(Object.keys(result.modelResponse.output)).not.toContain('_childWorkflowInstanceId')
67+
expect(result.modelResponse.output.result).toEqual({
68+
token: withRegistry ? '{{TOKEN}}' : 'secret-value',
69+
childTraceSpans: 'business field',
70+
})
71+
expect(result.rawResponse.output.cost.total).toBe(0.25)
72+
if (withRegistry) expect(registry.isComplete()).toBe(true)
73+
}
74+
)
75+
76+
it('preserves similarly named business fields from non-workflow tools', async () => {
77+
const response = {
78+
success: true,
79+
output: {
80+
childTraceSpans: ['business data'],
81+
_childWorkflowInstanceId: 'business identifier',
82+
},
83+
}
84+
mockExecuteTool.mockResolvedValueOnce(response)
85+
const result = await executeProviderToolWithInput('http_request', {})
86+
expect(result.modelResponse).toEqual(response)
87+
})
88+
89+
it('does not replay legacy workflow diagnostics into the model or rebill the child', async () => {
90+
const session = new AgentTurnStateMachine({ save: vi.fn() })
91+
await session.captureStep({
92+
assistant: { role: 'assistant', content: '' },
93+
calls: [{ toolId: 'workflow_executor', providerCallId: 'child-call', arguments: '{}' }],
94+
native: {
95+
protocol: 'responses',
96+
providerId: 'openai',
97+
model: 'gpt-4.1-mini',
98+
binding: 'binding',
99+
value: {},
100+
},
101+
})
102+
const invocationId = session.resolveInvocationId('child-call', 'workflow_executor')!
103+
const response = {
104+
success: true,
105+
output: {
106+
result: { answer: 'retained answer' },
107+
childTraceSpans: [{ input: 'internal trace' }],
108+
_childWorkflowInstanceId: 'child-instance',
109+
cost: { total: 0.25 },
110+
},
111+
}
112+
await session.recordToolResult({ invocationId, rawResponse: response, modelResponse: response })
113+
const replay = await runWithProviderRuntimeContext({ agentConversation: session }, () =>
114+
executeProviderToolWithInput('workflow_executor', { _context: { invocationId } })
115+
)
116+
expect(replay.rawResponse.output.childTraceSpans).toEqual(response.output.childTraceSpans)
117+
expect(replay.rawResponse.output.cost).toMatchObject({ total: 0 })
118+
expect(Object.keys(replay.modelResponse.output)).not.toContain('childTraceSpans')
119+
expect(Object.keys(replay.modelResponse.output)).not.toContain('_childWorkflowInstanceId')
120+
expect(replay.modelResponse.output.result).toEqual({ answer: 'retained answer' })
121+
expect(mockExecuteTool).not.toHaveBeenCalled()
122+
})
123+
35124
it('dispatches the bound memory reader and reauthorizes repeated invocation reads', async () => {
36125
const execute = vi
37126
.fn()

‎apps/sim/providers/runtime-context.ts‎

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -94,27 +94,26 @@ function toProviderModelResponse(
9494
}
9595

9696
/**
97-
* Drops a custom block's child-run handle from the copy bound for the model.
97+
* Drops executor-owned diagnostics from the copy bound for the model, before secret projection.
9898
*
9999
* The handle has to survive on `rawResponse`, which is what the tool-call record
100100
* (and therefore the trace span) is built from — but it is trace plumbing, and an
101101
* opaque execution id in a tool result reads to a model like data the tool
102102
* returned, which it may then quote back to the user. Applied at this single
103103
* split point because every provider's tool loop goes through here; there is no
104-
* other place both copies exist.
104+
* other place both copies exist. Workflow tools also carry full child spans, including intermediate
105+
* inputs and outputs, which can dwarf their final result. Only their wrapper is filtered; nested
106+
* business data and similarly named output fields from unrelated tools remain untouched.
105107
*/
106-
function withoutChildTraceHandle(response: ToolResponse): ToolResponse {
108+
function withoutToolDiagnostics(response: ToolResponse, toolId: string): ToolResponse {
107109
const output = response.output
108110
if (!isRecordLike(output)) return response
109-
if (
110-
!Object.hasOwn(output, CHILD_EXECUTION_ID_OUTPUT_KEY) &&
111-
!Object.hasOwn(output, CHILD_TRACE_DISABLED_OUTPUT_KEY)
112-
) {
113-
return response
114-
}
111+
const keys = [CHILD_EXECUTION_ID_OUTPUT_KEY, CHILD_TRACE_DISABLED_OUTPUT_KEY]
112+
if (toolId === 'workflow_executor') keys.push('childTraceSpans', '_childWorkflowInstanceId')
113+
if (!keys.some((key) => Object.hasOwn(output, key))) return response
115114
return {
116115
...response,
117-
output: omit(output, [CHILD_EXECUTION_ID_OUTPUT_KEY, CHILD_TRACE_DISABLED_OUTPUT_KEY]),
116+
output: omit(output, keys),
118117
}
119118
}
120119

@@ -226,8 +225,10 @@ export async function executeProviderTool(
226225
: recorded.rawResponse
227226
return {
228227
rawResponse,
229-
modelResponse:
228+
modelResponse: withoutToolDiagnostics(
230229
session?.getRecordedResult?.(invocationId!)?.modelResponse ?? recorded.modelResponse,
230+
executionToolId
231+
),
231232
}
232233
}
233234
const recordResult = async (
@@ -249,7 +250,10 @@ export async function executeProviderTool(
249250
})
250251
const retained = session.getRecordedResult?.(invocationId)
251252
if (retained && !memoryRetrieval)
252-
return { ...result, modelResponse: retained.modelResponse }
253+
return {
254+
...result,
255+
modelResponse: withoutToolDiagnostics(retained.modelResponse, executionToolId),
256+
}
253257
}
254258
} catch {
255259
logger.warn('Agent tool result durability unavailable')
@@ -314,12 +318,14 @@ export async function executeProviderTool(
314318
resolvedSecretTraceRegistry: toolCallRegistry,
315319
})
316320
accumulateToolCost(executionToolId, result, runtimeContext)
321+
const functionalResult = withoutToolDiagnostics(result, executionToolId)
317322
if (!registry || !toolCallRegistry) {
318-
return recordResult({ rawResponse: result, modelResponse: withoutChildTraceHandle(result) })
323+
return recordResult({ rawResponse: result, modelResponse: functionalResult })
319324
}
320325

321-
const modelResponse = withoutChildTraceHandle(
322-
toProviderModelResponse(result, projectToolResultForCopilot(result, toolCallRegistry))
326+
const modelResponse = toProviderModelResponse(
327+
functionalResult,
328+
projectToolResultForCopilot(functionalResult, toolCallRegistry)
323329
)
324330
registry.mergeToolCallRegistry(toolCallRegistry)
325331
return recordResult({ rawResponse: result, modelResponse })

0 commit comments

Comments
 (0)