refactor(tools): let the terminal marker ride the nested result
The staged-promotion machinery (concludingParents keyed by parent token plus a pendingParentConclusions staging map) spread one fact — this call concluded the turn — across three registry-side collections with manual cleanup. Align it with how additionalContexts already crosses the same boundary: concludeTurn() marks only its own execution, the marker rides that execution's successful result (ToolExecutionFailure types concludesTurn as never, so a policy-converted failure sheds it with the type), and the composite that owns the nested dispatch forwards it — Code Mode's binding does so beside its existing context forwarding. The registry loses both parent-keyed collections and the promotion block; the propagation decision moves to the owning boundary; the structured-output consumer's own two-phase commit is untouched.
This commit is contained in:
@@ -268,6 +268,12 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
|
|||||||
for (const context of result.additionalContexts ?? []) {
|
for (const context of result.additionalContexts ?? []) {
|
||||||
exec.deferContext(context)
|
exec.deferContext(context)
|
||||||
}
|
}
|
||||||
|
// Like the context forwarding above, cross-boundary facts travel on
|
||||||
|
// the nested result and the composite forwards them: only a
|
||||||
|
// successful nested result can carry the terminal marker
|
||||||
|
// (ToolExecutionFailure types it never), so a policy-converted
|
||||||
|
// failure cannot stop the turn through a recovering program.
|
||||||
|
if (result.concludesTurn) exec.concludeTurn()
|
||||||
exec.agent?.session.append('tool/code-dispatch', {
|
exec.agent?.session.append('tool/code-dispatch', {
|
||||||
parentCallId: exec.callId,
|
parentCallId: exec.callId,
|
||||||
subCallId,
|
subCallId,
|
||||||
|
|||||||
@@ -307,7 +307,14 @@ export interface ToolRunContext extends ToolExecution {
|
|||||||
* are emitted in call order.
|
* are emitted in call order.
|
||||||
*/
|
*/
|
||||||
deferContext(context: UserMessageData): void
|
deferContext(context: UserMessageData): void
|
||||||
/** Mark a successful final result as terminal for the current agent turn. */
|
/**
|
||||||
|
* Mark a successful final result as terminal for the current agent turn.
|
||||||
|
* The marker rides this execution's own result (`concludesTurn` exists only
|
||||||
|
* on {@link ToolExecutionSuccess}); a composite that dispatches nested
|
||||||
|
* calls forwards it from the nested result, exactly like
|
||||||
|
* `additionalContexts`, so only an authoritative nested success can
|
||||||
|
* conclude the enclosing run.
|
||||||
|
*/
|
||||||
concludeTurn(): void
|
concludeTurn(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,16 +660,8 @@ export class ToolRegistry extends Service {
|
|||||||
|
|
||||||
/** Context deferred by a running tool body, keyed by its scheduler-owned execution. */
|
/** Context deferred by a running tool body, keyed by its scheduler-owned execution. */
|
||||||
private deferredContexts = new WeakMap<ToolRunContext, UserMessageData[]>()
|
private deferredContexts = new WeakMap<ToolRunContext, UserMessageData[]>()
|
||||||
/** Successful executions whose tool body declared the current turn complete. */
|
/** Executions whose tool body declared the current turn complete. */
|
||||||
private concludingExecutions = new WeakSet<ToolExecution>()
|
private concludingExecutions = new WeakSet<ToolExecution>()
|
||||||
/** Enclosing transport tokens marked terminal by a successful nested call. */
|
|
||||||
private concludingParents = new Set<ToolExecutionToken>()
|
|
||||||
/**
|
|
||||||
* Nested conclusions staged until their call's final verdict: a post-execute
|
|
||||||
* policy may still convert the nested success into an error, and a failed
|
|
||||||
* terminal operation must not stop the turn through its composite.
|
|
||||||
*/
|
|
||||||
private pendingParentConclusions = new WeakMap<ToolRunContext, ToolExecutionToken>()
|
|
||||||
/** Original caller cancellation, kept outside the wrapper-mutable execution object. */
|
/** Original caller cancellation, kept outside the wrapper-mutable execution object. */
|
||||||
private cancellationStates = new WeakMap<ToolRunContext, ToolCancellationState>()
|
private cancellationStates = new WeakMap<ToolRunContext, ToolCancellationState>()
|
||||||
/** Definition-owned final content transform snapshotted before policy begins. */
|
/** Definition-owned final content transform snapshotted before policy begins. */
|
||||||
@@ -985,7 +984,6 @@ export class ToolRegistry extends Service {
|
|||||||
const definition = this.get(name, agent)
|
const definition = this.get(name, agent)
|
||||||
const finalizeContent = definition?.finalizeContent?.bind(definition)
|
const finalizeContent = definition?.finalizeContent?.bind(definition)
|
||||||
const concludingExecutions = this.concludingExecutions
|
const concludingExecutions = this.concludingExecutions
|
||||||
const pendingParentConclusions = this.pendingParentConclusions
|
|
||||||
const base = {
|
const base = {
|
||||||
token,
|
token,
|
||||||
callId,
|
callId,
|
||||||
@@ -997,10 +995,7 @@ export class ToolRegistry extends Service {
|
|||||||
deferredContexts.push(context)
|
deferredContexts.push(context)
|
||||||
},
|
},
|
||||||
concludeTurn(): void {
|
concludeTurn(): void {
|
||||||
if (parent === undefined) concludingExecutions.add(this as unknown as ToolExecution)
|
concludingExecutions.add(this as unknown as ToolExecution)
|
||||||
// Staged, not propagated: only this nested call's authoritative
|
|
||||||
// successful result promotes the marker onto its parent.
|
|
||||||
else pendingParentConclusions.set(this as unknown as ToolRunContext, parent)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -1214,16 +1209,7 @@ export class ToolRegistry extends Service {
|
|||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
finalResult = this.materializeFinalResult(toolErrorResult(error))
|
finalResult = this.materializeFinalResult(toolErrorResult(error))
|
||||||
}
|
}
|
||||||
// Promote a staged nested conclusion only on the call's authoritative
|
|
||||||
// successful verdict — a policy-converted failure must not let the
|
|
||||||
// composite stop the turn on a failed terminal operation.
|
|
||||||
const stagedParent = this.pendingParentConclusions.get(exec)
|
|
||||||
if (stagedParent !== undefined) {
|
|
||||||
this.pendingParentConclusions.delete(exec)
|
|
||||||
if (!finalResult.isError) this.concludingParents.add(stagedParent)
|
|
||||||
}
|
|
||||||
this.notifyResult(exec, finalResult)
|
this.notifyResult(exec, finalResult)
|
||||||
this.concludingParents.delete(exec.token)
|
|
||||||
return finalResult
|
return finalResult
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1394,7 +1380,7 @@ export class ToolRegistry extends Service {
|
|||||||
}
|
}
|
||||||
meta = snapshotProjection(tool.name, 'presentationMeta', projected)
|
meta = snapshotProjection(tool.name, 'presentationMeta', projected)
|
||||||
}
|
}
|
||||||
const concludesTurn = this.concludingExecutions.has(exec) || this.concludingParents.has(exec.token)
|
const concludesTurn = this.concludingExecutions.has(exec)
|
||||||
return this.markCanonical(exec, this.materializeFinalResult({
|
return this.markCanonical(exec, this.materializeFinalResult({
|
||||||
isError: false,
|
isError: false,
|
||||||
value,
|
value,
|
||||||
|
|||||||
@@ -417,6 +417,46 @@ describe('the run_code dispatch bridge', () => {
|
|||||||
expect(result.content).toEqual([{ type: 'text', text: 'done' }])
|
expect(result.content).toEqual([{ type: 'text', text: 'done' }])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('forwards a nested terminal conclusion onto the successful run_code result', async () => {
|
||||||
|
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||||
|
ctx.tools.register(defineTool({
|
||||||
|
name: 'finalize',
|
||||||
|
description: 'Terminal tool.',
|
||||||
|
parameters: {},
|
||||||
|
output: {
|
||||||
|
schema: { type: 'string' },
|
||||||
|
render: (_args, value) => [{ type: 'text', text: value }],
|
||||||
|
},
|
||||||
|
execute(_args, exec) {
|
||||||
|
exec.concludeTurn()
|
||||||
|
return Promise.resolve('done')
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
runtime.behavior = async (request) => {
|
||||||
|
await request.bindings[0]!.functions.finalize!({})
|
||||||
|
return { logs: [], value: 'program complete' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const concluded = await runCode(ctx, 'await tools.finalize({})')
|
||||||
|
expect(concluded.isError).toBe(false)
|
||||||
|
expect(concluded.concludesTurn).toBe(true)
|
||||||
|
|
||||||
|
// A policy that converts the nested success into an error strips the
|
||||||
|
// marker with the result type: the recovering program cannot conclude.
|
||||||
|
const veto = ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => {
|
||||||
|
if (exec.name !== 'finalize') return next()
|
||||||
|
return { kind: 'block', feedback: [{ type: 'text', text: 'terminal rejected' }] }
|
||||||
|
})
|
||||||
|
runtime.behavior = async (request) => {
|
||||||
|
await request.bindings[0]!.functions.finalize!({}).catch(() => undefined)
|
||||||
|
return { logs: [], value: 'recovered' }
|
||||||
|
}
|
||||||
|
const recovered = await runCode(ctx, 'await tools.finalize({}).catch(() => {})')
|
||||||
|
veto()
|
||||||
|
expect(recovered.isError).toBe(false)
|
||||||
|
expect(recovered.concludesTurn).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
it('serializes Promise.all dispatches: tool executions never overlap, in submission order', async () => {
|
it('serializes Promise.all dispatches: tool executions never overlap, in submission order', async () => {
|
||||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||||
const intervals: [string, string][] = []
|
const intervals: [string, string][] = []
|
||||||
|
|||||||
@@ -553,18 +553,20 @@ describe('ToolRegistry', () => {
|
|||||||
expect(nested.isError ? undefined : nested.value).toBe('')
|
expect(nested.isError ? undefined : nested.value).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('propagates a nested concludeTurn only when the nested verdict stays successful', async () => {
|
it('carries a nested conclusion on the nested result for its composite to forward', async () => {
|
||||||
const ctx = await setup()
|
const ctx = await setup()
|
||||||
ctx.tools.register({
|
ctx.tools.register({
|
||||||
...echoTool,
|
...echoTool,
|
||||||
name: 'terminal-nested',
|
name: 'terminal-nested',
|
||||||
async execute(_args, exec) {
|
async execute(_args, exec) {
|
||||||
exec.concludeTurn()
|
exec.concludeTurn()
|
||||||
return 'staged'
|
return 'terminal'
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
// A composite that swallows its nested failure and returns success — the
|
// A composite that forwards the marker from the nested result — the Code
|
||||||
// Code Mode shape the staged propagation exists for.
|
// Mode dispatch shape. A recovering composite (nested failure swallowed)
|
||||||
|
// has no marker to forward: ToolExecutionFailure types concludesTurn as
|
||||||
|
// never, so only an authoritative nested success can conclude the run.
|
||||||
let call = 0
|
let call = 0
|
||||||
ctx.tools.register({
|
ctx.tools.register({
|
||||||
...echoTool,
|
...echoTool,
|
||||||
@@ -574,13 +576,13 @@ describe('ToolRegistry', () => {
|
|||||||
const nested = await ctx.tools.execute({
|
const nested = await ctx.tools.execute({
|
||||||
signal: exec.signal, callId: CallId(`nested-${call}`), name: 'terminal-nested', arguments: {}, parent: exec.token,
|
signal: exec.signal, callId: CallId(`nested-${call}`), name: 'terminal-nested', arguments: {}, parent: exec.token,
|
||||||
})
|
})
|
||||||
|
if (nested.concludesTurn) exec.concludeTurn()
|
||||||
return nested.isError ? 'nested failed, composite recovered' : 'nested succeeded'
|
return nested.isError ? 'nested failed, composite recovered' : 'nested succeeded'
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// A policy converts the nested success into an error: the staged
|
// A policy converts the nested success into an error: the failed result
|
||||||
// conclusion must NOT reach the composite, or the loop would stop the
|
// carries no marker, so the recovering composite does not conclude.
|
||||||
// turn on a failed terminal operation.
|
|
||||||
const veto = ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => {
|
const veto = ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => {
|
||||||
if (exec.name !== 'terminal-nested') return next()
|
if (exec.name !== 'terminal-nested') return next()
|
||||||
return { kind: 'block', feedback: [{ type: 'text', text: 'nested success rejected' }] }
|
return { kind: 'block', feedback: [{ type: 'text', text: 'nested success rejected' }] }
|
||||||
@@ -592,8 +594,8 @@ describe('ToolRegistry', () => {
|
|||||||
expect(recovered.concludesTurn).toBeUndefined()
|
expect(recovered.concludesTurn).toBeUndefined()
|
||||||
veto()
|
veto()
|
||||||
|
|
||||||
// The same nested call succeeding promotes the marker: the composite's
|
// The same nested call succeeding carries the marker; the composite
|
||||||
// own successful result now carries concludesTurn.
|
// forwards it onto its own successful result.
|
||||||
const concluded = await ctx.tools.execute({
|
const concluded = await ctx.tools.execute({
|
||||||
signal: testToolSignal, callId: CallId('composite-ok'), name: 'composite', arguments: {},
|
signal: testToolSignal, callId: CallId('composite-ok'), name: 'composite', arguments: {},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user