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

View File

@@ -12,7 +12,7 @@ The seam this rides on: `CreateAgentOptions.seed` (added on `dsh-agent`, threade
## Capabilities
`{ outputSchema: true, depthLimit: true, toolFilter: false }` — identical to spawn (the depth/model/structured-output behavior is the shared driver's).
`{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }` — identical to spawn because the shared driver owns depth, model, persona, tool-filter, and structured-output behavior.
## Config

View File

@@ -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.

View File

@@ -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> {

View File

@@ -10,7 +10,7 @@ The run mechanics live in the shared [`@deepseek-ai/dsh-subagent-inprocess`](../
## Capabilities
`{ outputSchema: true, depthLimit: true, toolFilter: false }`. It constructs the child, so it enforces a recursion cap, and it supports structured output via the driver's [structured runtime](../subagent-inprocess/README.md) (acquired per structured run inside the driver — this backend registers nothing at apply). Tool-scoping is deferred (the service rejects a request needing it before `start` runs).
`{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`. It constructs the child, so it enforces a recursion cap and composes the child's persona, global-tool restriction, and [structured runtime](../subagent-inprocess/README.md) inside the agent-creation setup window. This backend registers nothing at apply.
## Config

View File

@@ -9,10 +9,10 @@
* ({@link startInProcessRun}); this backend just passes NO seed (a fresh
* child). The fork backend is an independent peer over the same driver.
*
* Structured output (`outputSchema`) is supported via the driver's shared
* structured runtime: the backend acquires it for its plugin lifetime (so the
* capture tool and request-shaping listeners exist before any run), and each
* structured run holds its own acquisition until it settles.
* Structured output (`outputSchema`) is supported through the driver's
* per-child scoped runtime: the child registers its real-schema capture tool,
* prompt instruction, and enforcement listeners inside the creation setup
* window, and its scope owns their lifetime.
*
* Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default.
*
@@ -25,11 +25,10 @@ import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } fro
import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
export const name = 'subagent-spawn'
// `tools` is deliberately NOT injected: the shared driver's structured runtime
// (acquired per structured RUN, not at apply) gates its own capture-tool
// registration on `tools` availability, so this backend's apply timing — and
// with it the provider-mirroring delegation tool's position in the
// model-visible tool list — stays what it was before structured output existed.
// `tools` is deliberately NOT injected: the shared driver registers structured
// output through the child's creation context, whose factory already requires
// the tool service. Keeping it out of this backend's inject list preserves the
// provider's independent apply timing.
export const inject = ['subagents', 'agents']
/** Config: the registry name to register the provider under. */

View File

@@ -14,7 +14,10 @@ The tool description and the `prompt` parameter description are DERIVED from the
|---|---|
| `provider` (required) | The `ctx.subagents` provider name to start runs on (`spawn`, `fork`, `acp`, …). |
| `toolName` | The model-facing tool name to register (default `subagent`). Set a distinct value per load when exposing multiple providers, e.g. `subagent` + `subagent_acp`. |
| `agentOptions` | Default per-child `{ model? }` applied to every spawned child. (No per-child persona: the deployment persona is a context-wide section every agent shares.) |
| `agentOptions` | Default per-child `{ model? }` applied to every spawned child. |
| `persona` | Per-child persona that shadows the deployment persona; requires the provider's `persona` capability. |
| `toolFilter` | Per-child `{ allow?, deny? }` restriction over global tools; requires the provider's `toolFilter` capability. |
| `maxDepth` | Maximum delegation depth; requires the provider's `depthLimit` capability. |
## Lifecycle (synchronous collect)