Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
0d88a11
fix(copilot): disclose how far a withheld run got instead of one opaq…
icecrasher321 Aug 27, 2026
04f27ad
fix(copilot): keep the withheld-result tests out of the secret scanners
icecrasher321 Aug 27, 2026
d420e4e
fix(copilot): name the dispatched run from the boundary that owns it
icecrasher321 Aug 27, 2026
dd67672
fix(copilot): refuse an in-band tool call whose egress catalog is una…
icecrasher321 Aug 27, 2026
f86e9b1
fix(copilot): put the dispatch boundary at the logging session, not t…
icecrasher321 Aug 27, 2026
89abe98
fix(copilot): log a withheld in-band result even when it withheld a s…
icecrasher321 Aug 27, 2026
f3a47bf
fix(copilot): name the run from the executor, not the logging session
icecrasher321 Aug 27, 2026
1c3572a
fix(copilot): key the dispatched-run id off the failure instead of wr…
icecrasher321 Aug 27, 2026
cc012cc
fix(copilot): let the executor say when a block could first run
icecrasher321 Aug 28, 2026
46b818b
fix(copilot): report the run from the engine, and never let recovery …
icecrasher321 Aug 28, 2026
f28a7d7
fix(copilot): mark a run that failed after the core returned
icecrasher321 Aug 28, 2026
8c8ec84
fix(copilot): stop calling a cancelled run performed
icecrasher321 Aug 28, 2026
89cdb0a
fix(copilot): derive the run phase from what the executor saw, not th…
icecrasher321 Aug 28, 2026
fd5039d
fix(copilot): report dispatch from the block handler itself
icecrasher321 Aug 28, 2026
79401fd
refactor(copilot): decide the run phase where the caller lives, not i…
icecrasher321 Aug 28, 2026
4d2ba50
docs(copilot): state that a named run may resolve to nothing
icecrasher321 Aug 28, 2026
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
20 changes: 17 additions & 3 deletions apps/sim/app/api/copilot/tools/execute/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,26 @@ describe('POST /api/copilot/tools/execute (in-band)', () => {
expect(body.error).toBe('File not found: files/a.md')
})

it('withholds results when no egress registry can be built', async () => {
mockPrepareEnvironmentContext.mockRejectedValue(new Error('env unavailable'))
/**
* Running the tool without a catalog used to produce the worst pair of outcomes available:
* the side effect happened and the caller got a bare `{success: true}` naming neither the
* cause nor whether anything had changed.
*/
it('refuses the call, without running the tool, when no egress registry can be built', async () => {
mockPrepareEnvironmentContext.mockRejectedValue(new Error('Workspace ws-gone does not exist'))
mockHandler.mockResolvedValue({ success: true, output: { content: 'sensitive' } })

const res = await POST(makeRequest({ ...BASE_BODY, messageId: 'msg-no-registry' }) as never)
const body = await res.json()
expect(body).toEqual({ success: true })

expect(mockHandler).not.toHaveBeenCalled()
expect(body.success).toBe(false)
expect(body.output).toEqual({ resultWithheld: true, effect: 'not_attempted' })
// The thrown reason is an unprojectable environment failure — the catalog that would
// vouch for it is the very thing missing — so it stays in the log.
expect(body.error).not.toContain('does not exist')
expect(body.error).toContain(BASE_BODY.workspaceId)
expect(body.error).toContain('could not be resolved')
})

it('reuses one turn registry across calls that share a messageId', async () => {
Expand Down
51 changes: 47 additions & 4 deletions apps/sim/app/api/copilot/tools/execute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,21 @@ import { type NextRequest, NextResponse } from 'next/server'
import { copilotToolExecuteInternalBodySchema } from '@/lib/api/contracts/copilot'
import { validationErrorResponse } from '@/lib/api/server'
import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context'
import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { checkInternalApiKey } from '@/lib/copilot/request/http'
import { withIncomingGoSpan } from '@/lib/copilot/request/otel'
import {
describeWithholdingCause,
inspectToolResultForCopilot,
projectToolErrorMessageForCopilot,
} from '@/lib/copilot/request/tools/resolved-secret-result'
import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources'
import type { ToolCallResult } from '@/lib/copilot/request/types'
import { ensureHandlersRegistered } from '@/lib/copilot/tool-executor'
import { executeTool } from '@/lib/copilot/tool-executor/executor'
import { TOOL_EFFECT_PHASE } from '@/lib/copilot/tool-executor/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'

Expand Down Expand Up @@ -115,17 +118,43 @@ export const POST = withRouteHandler((request: NextRequest) =>
[TraceAttr.UserId]: userId,
})

let toolRegistry: ResolvedSecretTraceRegistry | undefined
let turnRegistry: ResolvedSecretTraceRegistry | undefined
let toolRegistry: ResolvedSecretTraceRegistry
let turnRegistry: ResolvedSecretTraceRegistry
try {
turnRegistry = await getTurnEgressRegistry(userId, workspaceId, messageId)
toolRegistry = turnRegistry.forkForInputPaths([])
} catch (err) {
logger.error('In-band egress registry unavailable; results will be withheld', {
/**
* Without a catalog the projection can vouch for nothing, so every result this call
* could produce would be withheld. Running the tool anyway was the worst of both
* outcomes: the side effect happened and the caller got an opaque sentinel that named
* neither the cause nor whether anything had changed. Refusing before dispatch is
* both truthful and the only answer that leaves nothing behind.
*
* The cause is almost always the workspace itself — a deleted or inaccessible id
* reaching this lane — which is actionable, so it is reported rather than swallowed.
*/
logger.error('In-band egress registry unavailable; refusing the call', {
toolName,
toolCallId,
userId,
workspaceId,
error: getErrorMessage(err),
})
rootSpan.setAttributes({ [TraceAttr.ToolOutcome]: MothershipStreamV1ToolOutcome.error })
/**
* The thrown reason stays in the log. It is an environment or database failure that
* nothing here can project — the catalog it needed is the very thing that is missing —
* so this is the one message on this route that must be fixed text. The workspace id
* is echoed because the caller supplied it, and it is what makes this actionable.
*/
return NextResponse.json({
success: false,
error: workspaceId
? `${toolName} was not run: its workspace (${workspaceId}) could not be resolved. Check that the workspace exists and is accessible before retrying.`
: `${toolName} was not run: its execution environment could not be resolved.`,
output: { resultWithheld: true, effect: TOOL_EFFECT_PHASE.notAttempted },
})
}

try {
Expand All @@ -148,9 +177,23 @@ export const POST = withRouteHandler((request: NextRequest) =>
})
const projection = inspectToolResultForCopilot(result, toolRegistry, toolName)
const projected = projection.result
if (projection.safe && toolRegistry?.isComplete() && turnRegistry) {
if (projection.safe && toolRegistry.isComplete()) {
turnRegistry.mergeToolCallRegistry(toolRegistry)
}
if (!projection.safe) {
/**
* Reported on its own rather than folded into the failure branch below: a withheld
* SUCCESS keeps `projected.success` true, so gating on failure meant the one case
* that leaves no other trace — the model reads a bare success — was also the one
* case whose cause was never written down.
*/
logger.warn('In-band tool result withheld by egress projection', {
toolName,
toolCallId,
runtimeSucceeded: result.success,
...describeWithholdingCause(projection.cause),
})
}
if (!projected.success) {
logger.warn('In-band tool execution failed', {
toolName,
Expand Down
49 changes: 49 additions & 0 deletions apps/sim/executor/utils/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,55 @@ export function attachExecutionResult(error: Error, executionResult: ExecutionRe
Object.assign(error, { executionResult })
}

/**
* Dispatched-run ids, keyed by the thrown value itself.
*
* A side table rather than a property on the error, for the same reason
* {@link markExecutionFinalizedByCore} keeps one: a thrown value is not reliably writable.
* `Object.assign` throws on a frozen or sealed failure, and guarding that throw would drop
* the marker instead — silently converting "this run exists" into "nothing started", which
* is the one direction that duplicates work. Identity keying also means no id can arrive
* through a prototype chain, and nothing is added to the error's own surface, so a
* serialized error carries no stray field.
*/
const attemptedExecutionIds = new WeakMap<object, string>()

/**
* Names the run a failure belongs to once dispatch has been attempted.
*
* A caller that only sees the thrown error cannot tell an authorization refusal — which
* created nothing — from a crash after the run was already dispatched, and those need
* opposite retry decisions. Recording the id at the point of no return makes its absence
* mean "nothing was started" rather than "we do not know", and its presence a key that
* resolves to zero or one executions.
*
* Distinct from {@link attachExecutionResult}: that says the workflow ran and produced a
* result, this says only that it was dispatched.
*/
export function attachAttemptedExecutionId(error: unknown, executionId: string): void {
Comment thread
icecrasher321 marked this conversation as resolved.
if (!isRecordedThrown(error) || !executionId) return
if (attemptedExecutionIds.has(error)) return
attemptedExecutionIds.set(error, executionId)
}

/** Reads the dispatched-run id a thrown value carries, if dispatch was reached at all. */
export function readAttemptedExecutionId(error: unknown): string | undefined {
return isRecordedThrown(error) ? attemptedExecutionIds.get(error) : undefined
}

/**
* Any non-null object, not only an `Error`.
*
* Restricting this to `Error` would silently invert the invariant for a thrown plain object:
* no id would be recorded, its absence would read as "nothing was started", and the caller
* would retry a run that already exists. A thrown primitive cannot be keyed at all, which
* costs nothing today because every throw site past the dispatch boundary raises an `Error`.
*/
function isRecordedThrown(value: unknown): value is object {
/** Functions key a WeakMap as well as objects do, so excluding them would drop the record. */
return (typeof value === 'object' || typeof value === 'function') && value !== null
}

export interface BlockExecutionErrorDetails {
block: SerializedBlock
error: Error | string
Expand Down
12 changes: 10 additions & 2 deletions apps/sim/lib/copilot/request/tools/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ import {
setTerminalToolCallState,
} from '@/lib/copilot/request/tool-call-state'
import { maybeWriteOutputToFile } from '@/lib/copilot/request/tools/files'
import { inspectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result'
import {
describeWithholdingCause,
inspectToolResultForCopilot,
} from '@/lib/copilot/request/tools/resolved-secret-result'
import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources'
import {
maybeWriteOutputToTable,
Expand Down Expand Up @@ -737,15 +740,20 @@ async function executeToolAndReportInner(
toolSpan.attributes = {
...toolSpan.attributes,
...summarizeToolResultForSpan(copilotResult),
...(projection.safe ? {} : { resultWithheld: true }),
...(projection.safe
? {}
: { resultWithheld: true, ...describeWithholdingCause(projection.cause) }),
}
if (!projection.safe) {
// A withheld SUCCESS otherwise leaves no trace anywhere: the span reads
// ok and the model just sees a bare `{success: true}` with no output.
// The cause is what says whether a guard latched, no catalog was built,
// or the payload itself was unprojectable — three different fixes.
logger.warn('Tool result withheld by egress projection', {
toolCallId: toolCall.id,
toolName: toolCall.name,
runtimeSucceeded: result.success,
...describeWithholdingCause(projection.cause),
})
}

Expand Down
104 changes: 104 additions & 0 deletions apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import { describe, expect, it } from 'vitest'
import { RunCode, RunFunction } from '@/lib/copilot/generated/tool-catalog-v1'
import {
describeWithholdingCause,
inspectToolResultForCopilot,
projectToolResultForCopilot,
READ_TOOL_RESULT_UNAVAILABLE_ERROR,
TOOL_RESULT_UNAVAILABLE_ERROR,
Expand Down Expand Up @@ -457,3 +459,105 @@ describe('projectToolResultForCopilot', () => {
expect(toolResultUnavailableError(undefined)).toBe(TOOL_RESULT_UNAVAILABLE_ERROR)
})
})

describe('effect disclosure on a withheld result', () => {
const EXECUTION_ID = '0f4d5a4c-6a1e-4c2f-9b7d-2c8f1a3e5d90'

it('carries nothing extra for a tool that declared no effect', () => {
expect(projectToolResultForCopilot({ success: true, output: { a: 1 } }, undefined)).toEqual({
success: true,
})
expect(projectToolResultForCopilot({ success: false, error: 'why' }, undefined)).toEqual({
success: false,
error: TOOL_RESULT_UNAVAILABLE_ERROR,
})
})

/**
* The exemption is what makes the disclosure trustworthy, so it has to be all or
* nothing: a disclosure that silently dropped the id it could not vouch for would
* read exactly like one that never had a run to name.
*/
it('voids the whole disclosure when an id is not a shape this system mints', () => {
Comment thread
icecrasher321 marked this conversation as resolved.
expect(
projectToolResultForCopilot(
{
success: false,
error: 'why',
effect: { phase: 'performed', ids: { executionId: 'not-a-server-minted-id' } },
},
undefined,
'run_workflow'
)
).toEqual({ success: false, error: TOOL_RESULT_UNAVAILABLE_ERROR })
})

it.each(['effect', 'resultWithheld'])(
'voids the disclosure when an id would take the reserved key %s',
(reserved) => {
expect(
projectToolResultForCopilot(
{
success: false,
error: 'why',
effect: { phase: 'performed', ids: { [reserved]: EXECUTION_ID } },
},
undefined,
'run_workflow'
)
).toEqual({ success: false, error: TOOL_RESULT_UNAVAILABLE_ERROR })
}
)

it('reports the phase and ids when every id is vouchable', () => {
expect(
projectToolResultForCopilot(
{
success: false,
error: 'why',
effect: { phase: 'attempted', ids: { executionId: EXECUTION_ID } },
},
undefined,
'run_workflow'
)
).toEqual({
success: false,
output: { resultWithheld: true, effect: 'attempted', executionId: EXECUTION_ID },
error: expect.stringContaining('At most one run exists'),
})
})

it('never leaks the disclosure into a result that projected cleanly', () => {
const registry = new ResolvedSecretTraceRegistry()

expect(
projectToolResultForCopilot(
{
success: true,
output: { executionId: EXECUTION_ID },
effect: { phase: 'performed', ids: { executionId: EXECUTION_ID } },
},
registry,
'run_workflow'
)
).toEqual({ success: true, output: { executionId: EXECUTION_ID } })
})

it('names why the content was withheld, for the surface about to log it', () => {
const latched = createRegistry()
latched.markIncomplete('source-provenance-incomplete', { origin: 'test.origin' })

const projection = inspectToolResultForCopilot({ success: false }, latched, 'run_workflow')
expect(projection.safe).toBe(false)
// The per-call fork adds its own propagation reason; the guard that originally
// tripped has to survive alongside it, or a refusal names only the messenger.
expect(projection.safe === false && describeWithholdingCause(projection.cause)).toEqual({
withheldCause: 'registry-incomplete',
withheldReasons: expect.arrayContaining(['source-provenance-incomplete']),
withheldOrigins: ['test.origin'],
})

const absent = inspectToolResultForCopilot({ success: false }, undefined)
expect(absent.safe === false && absent.cause).toEqual({ kind: 'registry-absent' })
})
})
Loading
Loading