docs: rewrite the agent-scope RFC

This commit is contained in:
Tianyi Cui
2026-07-11 14:01:49 +08:00
parent 4751a7be0b
commit 6091c0a3dc
9 changed files with 336 additions and 56 deletions
@@ -23,10 +23,10 @@ Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (
`attachStructuredRuntime(childCtx, schema)` registers the run's whole enforcement surface as SCOPED registrations on the child's `agent.ctx` — riding the child's fiber (a backend hot-reload mid-run cannot unregister anything; a disposed child leaves no residue) and visible to that child alone (two concurrent structured runs never interact; no placeholder schema, no strip-for-everyone-else, no refcounted global state):
- the `structured_output` capture tool with the run's REAL schema as its registered `parameters`, validating each call (`validateStructuredValue`) — violations become an `INVALID_ARGS` isError the model retries in-turn; a valid call STAGES the value keyed by its call id;
- the `structured_output` capture tool with the run's REAL schema as its registered `parameters`, validating each call (`validateStructuredValue`) — violations become an `INVALID_ARGS` isError the model retries in-turn; a valid call STAGES the value in a `WeakMap` keyed by that call's `ToolExecution` object;
- the calling instruction as an ordinary order-190 scoped prompt section (the demand travels with the tool, as prompt state of exactly one agent);
- a scoped `system-prompt/assemble` re-assert (`prepend: true` = outermost): whatever downstream listeners mutate or replace, the child's assembly always carries its capture tool and instruction — the loop logs the rendered assembly as the step's `request/header`, so the demand is reconstructable log state;
- a scoped `tools/post-execute` COMMIT (`prepend: true`): the staged value becomes the run's result only when the final decision accepts THE SAME CALL that staged it — call-keyed, so a stale stage orphaned by an outer short-circuiting listener is dropped, never promoted on a later call's acceptance;
- a scoped `system-prompt/assemble` re-assert (`prepend: true`) that post-processes its downstream chain, replacing conflicting entries with the child's capture tool and instruction — the loop logs the rendered assembly as the step's `request/header`, so the demand is reconstructable log state;
- a scoped `tools/post-execute` COMMIT (`prepend: true`): the staged value becomes the run's result when that same execution's downstream post-execute decision accepts it. Execution-object identity prevents an orphaned stage from matching a later call even when an adapter reuses the call id;
- a scoped `tools/pre-execute` deny for any call arriving after the capture — terminal means terminal WITHIN the step;
- a scoped `agent/turn-continuation` veto (`prepend: true`) stopping the child's turn once its output is captured, so a successful capture doesn't buy a wasted extra model step.
@@ -11,22 +11,21 @@
* enforcement listeners fire only for this child (scope-filtered dispatch).
* Registration lifetime rides the child's fiber, so a backend hot-reload
* mid-run cannot unregister the capture tool out from under a live child, and
* a disposed child leaves no residue — no placeholder schema, no
* strip-for-everyone-else, no refcounted global runtime, no `WeakMap` state.
* a disposed child leaves no residue — no placeholder schema,
* strip-for-everyone-else pass, or refcounted global runtime.
*
* Four listeners enforce the contract:
*
* - `system-prompt/assemble` (prepend, scoped): FINAL-ASSEMBLY re-assert —
* whatever downstream listeners mutated or replaced, the child's assembly
* always carries its capture tool and the trailing instruction section. The
* registry already contributes both; this outermost wrapper preserves the
* guarantee against a (global) listener that strips or replaces the
* assembly — placement-preserving: tools are replaced in place, the section
* re-inserted at its ascending-order position, so the untampered path keeps
* the registry's ordering (identical output, up to intra-band section order
* — which carries no contract). The loop logs the rendered assembly as the
* request header, so the demand is reconstructable log state, never a
* wire-only mutation.
* - `system-prompt/assemble` (prepend, scoped): assembly re-assert — the
* listener post-processes its downstream chain so a listener inside that
* chain cannot leave the child's capture tool or instruction stripped or
* replaced. Tools are replaced in place and the section is re-inserted at
* its ascending-order position, so the untampered path keeps the registry's
* ordering (up to intra-band section order, which carries no contract). A
* listener prepended later can still wrap and transform this result; this is
* an ordinary waterfall listener, not a service-level finalizer. The loop
* logs the rendered assembly as the request header, so the demand is
* reconstructable log state, never a wire-only mutation.
* - `agent/turn-continuation` (prepend, scoped): stop the child's turn once
* its output is captured — the loop's default "had tool calls ⇒ continue"
* would buy a wasted extra model step per structured child.
@@ -36,8 +35,9 @@
* effects after the final answer was accepted.
* - `tools/post-execute` (prepend, scoped): the capture COMMIT. The tool body
* only STAGES the validated value, KEYED BY THE EXECUTION OBJECT in a
* WeakMap; it becomes the run's captured result only when the final
* post-execute decision accepts THAT SAME pipeline trip. Execution-keyed
* WeakMap; it becomes the run's captured result when this listener's
* downstream post-execute decision accepts THAT SAME pipeline trip. A
* later-prepended wrapper remains outside that decision. Execution-keyed
* staging makes the stale-stage class structurally impossible: a value
* orphaned by an outer short-circuiting listener (a post-execute block, or
* a pre-execute deny whose call never dispatched) can never match another
@@ -125,7 +125,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
if (violations.length > 0) throw new ToolArgsError(violations)
// Two-phase commit, KEYED BY THIS EXECUTION: the body only stages; the
// post-execute listener promotes exactly this pipeline trip's entry
// when the final decision accepts it.
// when its downstream decision accepts it.
staged.set(exec, { value: args })
return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }])
},
@@ -137,10 +137,12 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
text: STRUCTURED_OUTPUT_INSTRUCTION,
})
// FINAL-ASSEMBLY re-assert (prepend = outermost): scoped dispatch means this
// fires only for the child's assemblies; `await next()` returns whatever the
// downstream chain (and any replacement assembly) produced, and the capture
// tool + instruction are re-asserted onto it if anything stripped them.
// PREPENDED assembly re-assert: scoped dispatch means this fires only for the
// child's assemblies; `await next()` returns whatever this listener's
// downstream chain produced, and the capture tool + instruction are
// re-asserted onto it if anything stripped them. A listener prepended later
// can still wrap and transform the returned assembly; this is not a
// service-level finalizer.
childCtx.on('system-prompt/assemble', async function (
this: unknown, _assembly: PromptAssembly, _context: AssembleContext, next: () => Promise<PromptAssembly>,
): Promise<PromptAssembly> {