Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions apps/sim/providers/runtime-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
36 changes: 21 additions & 15 deletions apps/sim/providers/runtime-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}

Expand Down Expand Up @@ -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 (
Expand All @@ -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')
Expand Down Expand Up @@ -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 })
Expand Down
Loading