Fix Code Mode workspace context propagation
This commit is contained in:
@@ -70,7 +70,7 @@ forever:
|
||||
each tool-call: session('tool/call')
|
||||
→ tools.execute() [waterfall tools/pre-execute → dispatch → tools/post-execute]
|
||||
→ session('tool/result')
|
||||
append buffered post-execute additionalContext as session('context/message')(s)
|
||||
append buffered deferred/post-execute additionalContexts as session('context/message')(s)
|
||||
drain steering → session('steering/message')
|
||||
cont = waterfall agent/turn-continuation → ContinuationDecision
|
||||
({action:'continue', reason?} records reason as next-step steering)
|
||||
|
||||
@@ -176,7 +176,7 @@ export interface LoopHandle {
|
||||
* session('tool/call'); ctx.tools.execute() ⟵ tools/pre-execute (allow/deny/ask)
|
||||
* → dispatch → tools/post-execute
|
||||
* session('tool/result')
|
||||
* append buffered post-execute additionalContext → session('context/message')(s)
|
||||
* append buffered deferred/post-execute contexts → session('context/message')(s)
|
||||
* drain steering → session('steering/message')
|
||||
* session('step/end') ⟵ durable step boundary (no agent/* mirror)
|
||||
* cont = waterfall agent/turn-continuation ⟵ ContinuationDecision; default
|
||||
@@ -870,13 +870,13 @@ async function runStep(
|
||||
|
||||
// --- Tool execution (sequential; parallel execution is a TODO) ---
|
||||
// If this becomes parallel, audit post-execute plugins that keep per-step
|
||||
// pending state before their returned additionalContext is appended.
|
||||
// pending state before their returned contexts are appended.
|
||||
// ToolRegistry.execute converts tool failures (including aborts) into
|
||||
// isError results, so abort is re-checked around every call here.
|
||||
const toolCalls = message.content.filter(block => block.type === 'tool-call')
|
||||
// Per-step buffer of `additionalContext` attached by tools/post-execute
|
||||
// listeners. Appended as context/message(s) only AFTER every tool/result for
|
||||
// the step, so a multi-call step keeps tool-call/result adjacency
|
||||
// Per-step buffer of contexts deferred by composite tools or attached by
|
||||
// tools/post-execute listeners. Appended as context/message(s) only AFTER
|
||||
// every tool/result for the step, so a multi-call step keeps adjacency
|
||||
// (interleaving context between a call's result and the next call's would
|
||||
// break the pairing the next model request relies on).
|
||||
const pendingContext: HookContext[] = []
|
||||
@@ -919,8 +919,8 @@ async function runStep(
|
||||
// persisted so a UI bridge reproduces the card on replay.
|
||||
...result.meta !== undefined ? { meta: result.meta } : {},
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] })
|
||||
// Buffer (don't append yet) any post-execute additionalContext for this call.
|
||||
if (result.additionalContext) pendingContext.push(result.additionalContext)
|
||||
// Buffer (don't append yet) every context carried by this call.
|
||||
pendingContext.push(...result.additionalContexts ?? [])
|
||||
// signal CAN flip during the await above (abort() inside a tool);
|
||||
// the analyzer can't see through the await boundary.
|
||||
/* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */
|
||||
|
||||
@@ -524,8 +524,8 @@ describe('agent/turn-continuation (ContinuationDecision)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('tools/post-execute additionalContext buffering across a multi-call step', () => {
|
||||
it('appends each call\'s additionalContext only AFTER all tool/results, preserving adjacency', async () => {
|
||||
describe('tool additionalContexts buffering across a step', () => {
|
||||
it('appends each call\'s contexts only AFTER all tool/results, preserving adjacency', async () => {
|
||||
// One assistant step with TWO tool calls; the second model response stops.
|
||||
const twoCalls = [
|
||||
{ type: 'block-start' as const, index: 0, blockType: 'tool-call' as const },
|
||||
@@ -543,16 +543,16 @@ describe('tools/post-execute additionalContext buffering across a multi-call ste
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
// Each call attaches additionalContext naming itself.
|
||||
// Each call attaches one context naming itself.
|
||||
ctx.on('tools/post-execute', async (exec, _result): Promise<PostToolDecision> =>
|
||||
({
|
||||
kind: 'accept',
|
||||
additionalContext: {
|
||||
additionalContexts: [{
|
||||
content: [{ type: 'text', text: `ctx-${exec.callId}` }],
|
||||
source: { kind: 'plugin', plugin: 'p' },
|
||||
envelope: 'raw',
|
||||
meta: { callId: exec.callId },
|
||||
},
|
||||
}],
|
||||
}))
|
||||
|
||||
send(agent, 'go')
|
||||
@@ -577,6 +577,34 @@ describe('tools/post-execute additionalContext buffering across a multi-call ste
|
||||
expect(contextEvents.map(e => e.type === 'context/message' && e.data.envelope)).toEqual(['raw', 'raw'])
|
||||
expect(contextEvents.map(e => e.type === 'context/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: 'c2' }])
|
||||
})
|
||||
|
||||
it('appends multiple contexts deferred by one composite tool after its outer result', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'composite', {}), textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'composite', description: 'composite', parameters: {},
|
||||
async execute(_args, exec) {
|
||||
exec.deferContext({ content: [{ type: 'text', text: 'nested-a' }], source: { kind: 'plugin', plugin: 'a' }, meta: { order: 1 } })
|
||||
exec.deferContext({ content: [{ type: 'text', text: 'nested-b' }], source: { kind: 'plugin', plugin: 'b' }, envelope: 'raw', meta: { order: 2 } })
|
||||
return [{ type: 'text', text: 'outer result' }]
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const log = events(agent)
|
||||
const resultIndex = log.findIndex(event => event.type === 'tool/result')
|
||||
const contextEvents = log.filter(event => event.type === 'context/message')
|
||||
expect(resultIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(log.findIndex(event => event === contextEvents[0])).toBeGreaterThan(resultIndex)
|
||||
expect(contextEvents.map(event => event.type === 'context/message' && event.data.source)).toEqual([
|
||||
{ kind: 'plugin', plugin: 'a' },
|
||||
{ kind: 'plugin', plugin: 'b' },
|
||||
])
|
||||
expect(contextEvents.map(event => event.type === 'context/message' && event.data.meta)).toEqual([{ order: 1 }, { order: 2 }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end through the loop)', () => {
|
||||
@@ -637,7 +665,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
|
||||
ctx.on('tools/post-execute', async (_exec, _result, next): Promise<PostToolDecision> => {
|
||||
const decision = await next()
|
||||
if (decision.kind === 'accept') {
|
||||
return { kind: 'accept', additionalContext: { content: [{ type: 'text', text: 'audited' }], source: { kind: 'plugin', plugin: 'native-guard' } } }
|
||||
return { kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: 'audited' }], source: { kind: 'plugin', plugin: 'native-guard' } }] }
|
||||
}
|
||||
return decision
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user