From cb03c8c2844790230a2ee1de24f72c7cf4bac742 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:17:57 +0800 Subject: [PATCH] fix(scope): align trust and input boundaries Rewrite the agent-scope RFC with executable examples and an explicit security non-goal. Harden subagent scalar and depth validation, and pin live tool-filter semantics across code, tests, and generated docs. --- CONTEXT.md | 8 +- docs/config-catalog.md | 10 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 4 +- docs/core-data-structures/subagent.md | 6 +- docs/core-data-structures/tools.md | 2 +- ...t-variables-and-tool-guidance-ownership.md | 4 +- .../2026-07-08-agent-scope-contexts.md | 403 ++++++++++++++++-- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/core/scope/README.md | 4 +- packages/core/scope/src/index.ts | 10 +- packages/core/system-prompt/README.md | 4 +- packages/core/system-prompt/src/index.ts | 12 +- packages/core/tools/README.md | 2 +- packages/core/tools/src/index.ts | 26 +- packages/core/tools/tests/scoped.spec.ts | 27 +- packages/subagent/subagent-fork/README.md | 2 +- .../subagent/subagent-inprocess/README.md | 8 +- .../subagent/subagent-inprocess/src/index.ts | 12 +- .../tests/structured.spec.ts | 2 +- .../tests/subagent-inprocess.spec.ts | 23 + packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/src/types.ts | 13 +- .../subagent/subagent/tests/service.spec.ts | 3 +- packages/subagent/tool-subagent/README.md | 8 +- packages/subagent/tool-subagent/src/index.ts | 21 +- .../tool-subagent/tests/tool-subagent.spec.ts | 20 +- packages/support/subagent-mock/README.md | 2 +- packages/support/subagent-mock/src/index.ts | 8 +- 29 files changed, 545 insertions(+), 105 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 7fd11ae59e..2b3584aff4 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -4,12 +4,12 @@ Domain vocabulary for the DeepSeek Harness SDK — one canonical term per concep ## agent-scope -- **scope** — the unit of per-agent registration: a contribution (tool, prompt section, variable, restriction, listener) is either *global* (visible to every agent) or *scoped* (owned by exactly one [[scope-key]]). Two levels, flat: nothing inherits down to subagents; subtree behavior is expressed with [[lineage]] data, never structure. +- **scope** — the unit of per-agent registration: a contribution (tool, prompt section, variable, restriction, listener) is either *global* (visible to every agent) or *scoped* (owned by exactly one [[scope-key]]). Two levels, flat: scoped registrations do not inherit down to subagents; subtree behavior is expressed with [[lineage]] data, never scope structure. - **scope key** — the opaque identity a scope is keyed by, compared by object identity. The harness convention: a live agent is the key of its own scope. -- **agent context (`agent.ctx`)** — the agent's scoped context; registrations through it are scope-visible AND scope-lifetime (one fact drives both), and listeners on it hear only that agent's dispatches. +- **agent context (`agent.ctx`)** — the agent's scoped context; registrations through it are scope-visible AND scope-lifetime (one fact drives both), and listeners on it participate in that agent's scope-filtered dispatches. Registry-subject events may remain deliberately unfiltered under their own event contracts. - **scope carrier** — the `thisArg` a scope-filtered dispatch carries (built by `scopeTarget`); its filter admits untagged listeners plus the subject's own. A *subject-less* carrier (no key) admits untagged listeners only. - **scoped dispatch** — the rule: an event about one agent's activity dispatches with that agent's carrier. Events about a registry itself (a tool was added) are *registry-subject* and stay unfiltered. - **shadowing** — most-specific-wins name resolution: a scoped tool/section/variable replaces its same-named global twin for that scope alone. The per-agent persona and per-agent tool-variant mechanism. -- **restriction / grant** — a restriction (`tools.restrict`) masks the GLOBAL tool surface for one scope (compose by intersection); a scoped registration is an explicit grant that bypasses restrictions. A restricted-away tool is absent from the prompt AND refuses execution, indistinguishably from a nonexistent one. -- **setup window** — the creation slot where a creator composes an agent's scoped world (`CreateAgentOptions.setup`): after the scope exists and the agent is registered, before `agent/session-start` and the first prompt assembly. Setup registers; it never drives the agent. +- **restriction / scope-local registration** — a restriction (`tools.restrict`) filters the GLOBAL tool surface for one scope (compose by intersection); scope-local registrations are merged after that filter. A filtered-away global tool is absent from the prompt AND refuses execution, indistinguishably from a nonexistent one. +- **setup window** — the creation slot where a creator composes an agent's scoped world (`CreateAgentOptions.setup`): after the scope and agent object exist but before the agent or session is published, `agent/session-start` fires, or the first prompt is assembled. Setup registers; it never drives the agent. - **lineage** — parent/child facts carried as data (`parentSession`, `subagentDepth`); never affects visibility. diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8fd8e3379b..57f8bc961a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -712,9 +712,11 @@ export interface Config { /** Which start-time capabilities to advertise (default: all `true`). */ capabilities?: Partial /** - * The context contract to declare ({@link SubagentProvider.inheritsParentContext}); - * default `false` (spawn-like). Set `true` to exercise the fork-shaped tool - * wording in consumer tests. + * The conversation-history descriptor to declare + * ({@link SubagentProvider.inheritsParentContext}); default `false` (fresh + * conversation). Set `true` to exercise seeded/fork wording in consumer + * tests. This flag says nothing about tool, service, scope, or authority + * inheritance. */ inheritsParentContext?: boolean /** @@ -903,7 +905,7 @@ export interface Config { Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) -Source: [`packages/subagent/tool-subagent/src/index.ts:45`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:47`](../packages/subagent/tool-subagent/src/index.ts) ## `@deepseek-ai/dsh-tool-web` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index d13f88bc2d..d59bc617d0 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -385,7 +385,7 @@ Source: [`packages/core/tools/src/index.ts:176`](../../packages/core/tools/src/i ### `tools/execute` — waterfall -Around-dispatch waterfall wrapping the registry's core tool dispatch, between the `tools/pre-execute` gate and the `tools/post-execute` seam. A listener receives `(exec, next)`: call `next()` to delegate to dispatch (returning its ToolExecutionResult, optionally wrapped), or return a replacement result without calling `next()` to short-circuit dispatch. The base `next()` IS the dispatch-with-normalization thunk — a thrown tool (or unknown tool) is already normalized to an `isError` result by the time a listener's `await next()` returns, so a wrapper never sees a raw throw from the tool body. This is the seam a timeout/retry/metrics plugin wraps: it can set or replace the one mutable field, `exec.signal` (e.g. with a per-call deadline), BEFORE `next()`, restore/delete it afterward, and inspect the result AFTER. Call identity (`token`, `callId`, `name`, `arguments`, `agent`, and `parent`) is immutable throughout the pipeline so a wrapper cannot change which capability or scope was authorized. (Cordis `next()` ignores passed arguments and re-invokes downstream with the shared payload, so a wrapper changes `exec.signal` in place rather than passing a new object to `next()`.) Multiple listeners compose by registration order — an outer one wraps the inner ones plus dispatch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by `exec.agent` — a listener registered through `agent.ctx` wraps only that agent's calls; a plain plugin listener wraps every call (including agent-less ones, which dispatch subject-less). +Around-dispatch waterfall wrapping the registry's core tool dispatch, between the `tools/pre-execute` gate and the `tools/post-execute` seam. A listener receives `(exec, next)`: call `next()` to delegate to dispatch (returning its ToolExecutionResult, optionally wrapped), or return a replacement result without calling `next()` to short-circuit dispatch. The base `next()` IS the dispatch-with-normalization thunk — a thrown tool (or unknown tool) is already normalized to an `isError` result by the time a listener's `await next()` returns, so a wrapper never sees a raw throw from the tool body. This is the seam a timeout/retry/metrics plugin wraps: it can set or replace the one mutable field, `exec.signal` (e.g. with a per-call deadline), BEFORE `next()`, restore/delete it afterward, and inspect the result AFTER. Call identity (`token`, `callId`, `name`, `arguments`, `agent`, and `parent`) is immutable throughout the pipeline so a wrapper cannot change which tool and scope the pipeline accepted. (Cordis `next()` ignores passed arguments and re-invokes downstream with the shared payload, so a wrapper changes `exec.signal` in place rather than passing a new object to `next()`.) Multiple listeners compose by registration order — an outer one wraps the inner ones plus dispatch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by `exec.agent` — a listener registered through `agent.ctx` wraps only that agent's calls; a plain plugin listener wraps every call (including agent-less ones, which dispatch subject-less). ```ts cordis-catalog 'tools/execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index f5946a7e4d..877c10e080 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -254,7 +254,7 @@ Source: [`packages/subagent/subagent/src/index.ts:180`](../../packages/subagent/ ## `ctx.systemPrompt` — `SystemPrompt` -Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, named prompt variables, and authoritative contribution protections; the agent loop calls `assemble(context)` once per step. Registers the harness-owned `harness:identity` and `deployment:persona` sections itself (see Config.persona). +Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, named prompt variables, and owner-final contribution protections; the agent loop calls `assemble(context)` once per step. Registers the harness-owned `harness:identity` and `deployment:persona` sections itself (see Config.persona). ```ts cordis-catalog section(section: PromptSection): () => Promise | void @@ -285,7 +285,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:481`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:484`](../../packages/core/tools/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 3de6b5861e..2f3e089a22 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -78,7 +78,7 @@ interface SubagentRun { ## The provider seam: `SubagentProvider` -One transport for running a child agent. Implementations register under a unique name via `SubagentService.registerProvider`; multiple coexist in one context. The service validates every requested start-time capability before calling `start`, so an implementation may assume e.g. `request.maxDepth` is honorable when present. `inheritsParentContext` is a DESCRIPTIVE fact beside the capabilities (nothing validates against it): whether a child sees the parent conversation (`fork`: true, `spawn`/`acp`: false) — the model-facing consumer derives truthful tool wording from it. +One transport for running a child agent. Implementations register under a unique name via `SubagentService.registerProvider`; multiple coexist in one context. The service validates every requested start-time capability before calling `start`, so an implementation may assume e.g. `request.maxDepth` is honorable when present. `inheritsParentContext` is a DESCRIPTIVE fact beside the capabilities (nothing validates against it): whether a child sees the parent conversation (`fork`: true, `spawn`/`acp`: false) — the model-facing consumer derives truthful tool wording from it. It describes conversation history only, not tool registrations, injected services, or authority inheritance. ```ts type-equiv interface SubagentProvider { @@ -93,7 +93,7 @@ The service (`ctx.subagents`) emits `subagent/start` only after `run.started` fu ## In-process backends: depth and seed -The two in-process backends ([dsh-subagent-spawn](../../packages/subagent/subagent-spawn) fresh, [dsh-subagent-fork](../../packages/subagent/subagent-fork) seeded) run the child as a child `Agent` on the same application. They synchronously snapshot caller-owned data, install provider ownership before attaching the abort listener, create one run-owner fiber under `parent.ctx`, and invoke the factory through that fiber: parent teardown, provider teardown, and manual run disposal share the same pre-publication ownership and quiescence boundary, while the child still receives a flat new scope rather than inheriting the parent's capabilities. Their `started` promise projects the factory's successful publication and the result driver awaits that same promise before sending the prompt. Two pieces of vocabulary ride on the existing agent/session types rather than new core types: +The two in-process backends ([dsh-subagent-spawn](../../packages/subagent/subagent-spawn) fresh, [dsh-subagent-fork](../../packages/subagent/subagent-fork) seeded) run the child as a child `Agent` on the same application. They synchronously snapshot caller-owned data, install provider ownership before attaching the abort listener, create one run-owner fiber under `parent.ctx`, and invoke the factory through that fiber: parent teardown, provider teardown, and manual run disposal share the same pre-publication ownership and quiescence boundary, while the child still receives a flat new scope rather than inheriting the parent's registrations. Their `started` promise projects the factory's successful publication and the result driver awaits that same promise before sending the prompt. Two pieces of vocabulary ride on the existing agent/session types rather than new core types: -- **Delegation depth** is a merge-extensible `AgentOptions.subagentDepth` field (`0` for a top-level agent, parent + 1 for a child). The seam owns it — the loop neither sets nor reads it — so a nested spawn reads its parent's depth from `parent.options.subagentDepth` and the `depthLimit` capability caps the tree by refusing a child whose depth would exceed `request.maxDepth`. +- **Delegation depth** is a merge-extensible `AgentOptions.subagentDepth` field (`0` for a top-level agent, parent + 1 for a child). Only `undefined` means top level; every stored present value must be a non-negative safe integer. The seam owns it — the loop neither sets nor reads it — so a nested spawn validates its parent's stored depth, rejects a derived child depth outside the safe-integer domain, and applies a defined absolute `request.maxDepth` cap to that child. - **Fork seeding** uses `CreateAgentOptions.seed` (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `resume` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/support/invariants) replay accepts it (the in-flight, unbalanced turn is excluded). diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 5c11f1a07c..1766b4c9ce 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -118,7 +118,7 @@ interface ToolExecution extends ToolExecutionInput { } ``` -`ToolExecutionToken` is a compile-time opaque type and a frozen, property-free object at runtime; identity comparison is its only operation. Before policy runs, `ctx.tools.execute()` reads each caller-owned field once, materializes `arguments` as detached lossless JSON in one recursive pass, assigns a fresh token, and deep-freezes the accepted arguments. A mutable exotic such as `Map` is rejected and normalized to an error before policy; one-pass materialization prevents a stateful getter from supplying different values to validation and storage. `token`, `callId`, `name`, `arguments`, `agent`, and the optional `parent` token are non-writable throughout all waterfalls, so a listener cannot change which capability or scope was authorized or reach a live enclosing execution; an around-dispatch wrapper may add, replace, or remove only optional `signal`. After the complete pipeline the registry freezes the execution and exposes its stable identity to `tools/result` observers, where the execution remains usable as a `WeakMap` key without mutation races. +`ToolExecutionToken` is a compile-time opaque type and a frozen, property-free object at runtime; identity comparison is its only operation. Before policy runs, `ctx.tools.execute()` reads each caller-owned field once, materializes `arguments` as detached lossless JSON in one recursive pass, assigns a fresh token, and deep-freezes the accepted arguments. A mutable exotic such as `Map` is rejected and normalized to an error before policy; one-pass materialization prevents a stateful getter from supplying different values to validation and storage. `token`, `callId`, `name`, `arguments`, `agent`, and the optional `parent` token are non-writable throughout all waterfalls, so a listener cannot change which tool or scope the pipeline accepted or reach a live enclosing execution; an around-dispatch wrapper may add, replace, or remove only optional `signal`. After the complete pipeline the registry freezes the execution and exposes its stable identity to `tools/result` observers, where the execution remains usable as a `WeakMap` key without mutation races. A `ToolGuard` is scope-aware final pre-dispatch policy. Its shape deliberately has no allow result: `undefined` preserves the waterfall decision, while a returned reason can only reduce permission, so a later listener cannot undo it. diff --git a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index 24a525307e..53d3e63cdb 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md +++ b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -36,9 +36,9 @@ Plugins contribute named values via `ctx.systemPrompt.variable(name, provider)`; Per-tool semantics and when-to-use live in tool DESCRIPTIONS, which already ship in every request — the YAML prose was ~fully redundant with them. Sections carry only the cross-call habits a single call's description cannot: `dsh-tool-bash` contributes `tool:bash` (order 105) — check the `[exit code: N]` marker on every result; `dsh-tool-fs`'s read section gains the "not shell commands like cat" contrast. `todo_write` and the subagent tools need NO section — their descriptions already carry the whole contract. The leaf personas shrink to identity + behavior (verify your work; keep answers brief), and the welcome banner stops enumerating tools. -### The subagent context contract +### The subagent conversation-history descriptor -`SubagentProvider` gains `readonly inheritsParentContext: boolean` — a DESCRIPTIVE fact beside `capabilities`, not in it (capabilities are start-time validation; nothing validates against this flag). Spawn and ACP declare `false`, fork declares `true`. `dsh-tool-subagent` derives both the tool description and the `prompt` parameter description from the flag (`providerWording`): the fork instance now tells the model the child inherits the conversation's completed turns (not the in-flight turn) and that its prompt should state only what is new. Deriving the description from a provider that arrives on its own fiber is what forced the provider-lifecycle events and the tool's reactive registration — that mechanism, its Loader-concurrency rationale, and its rejected alternatives are recorded in [the provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md). +`SubagentProvider` gains `readonly inheritsParentContext: boolean` — a DESCRIPTIVE conversation-history fact beside `capabilities`, not in it (capabilities are start-time validation; nothing validates against this flag). Spawn and ACP declare `false`, fork declares `true`. The name refers only to conversation seeding, not Cordis scope, services, tools, or authority. `dsh-tool-subagent` derives both the tool description and the `prompt` parameter description from the flag (`providerWording`): the fork instance now tells the model the child is seeded with the conversation's completed turns (not the in-flight turn) and that its prompt should state only what is new. Deriving the description from a provider that arrives on its own fiber is what forced the provider-lifecycle events and the tool's reactive registration — that mechanism, its Loader-concurrency rationale, and its rejected alternatives are recorded in [the provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md). ## Alternatives considered diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md index df6433dd26..9162273c72 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -6,26 +6,48 @@ Status: implemented One application can run many agents that share infrastructure but must not share every capability or policy. A child agent may need a different persona, fewer tools, its own structured-result schema, and listeners that govern only its work, while still using the deployment's model adapters, persistence backend, tool implementations, and user interface. -This is a composition problem, not an application-isolation problem. Starting a separate service graph for every child would isolate too much; putting every registration in one global graph isolates too little. +This is a composition problem, not an application-isolation or security-confinement problem. Starting a separate service graph for every child would isolate too much; putting every registration in one global graph isolates too little. | Surface | What varies by agent | Failure when it is only global | |---|---|---| -| Tools | Available capabilities, a child-only tool, or a scoped replacement for one implementation | The model receives excess authority, or a child-specific tool leaks into every prompt | +| Tools | Available capabilities, a child-only tool, or a scoped replacement for one implementation | The model receives the wrong tool view, or a child-specific tool leaks into every prompt | | Prompt state | Persona, instructions, variables, and [Code Mode](../feature/2026-06-15-code-mode.md) SDK declarations | Every agent receives the same instructions or runtime facts | | Live policy | Hooks, execution guards, result observers, and continuation rules | A listener intended for one agent can alter another agent's work | | Lifetime | Cleanup when the agent fails, is cancelled, is disposed, or loses its owner | Registrations outlive the agent or disappear before its final work settles | -Two consistency requirements make the problem deeper than filtering a list. First, the model-visible and executable views must agree: a hidden tool must not remain callable, and an advertised tool must not fail merely because execution used a different registry view. This agreement must also cover Code Mode bindings and UI presentation. +Three consistency requirements make the problem deeper than filtering a list. First, the model-visible and executable views must agree: a hidden tool must not remain callable, and an advertised tool must not fail merely because execution used a different registry view. This agreement must also cover Code Mode bindings and UI presentation. Second, some rules are invariants rather than cooperative extensions. An ordinary middleware listener may replace a prompt assembly, turn an allow into a deny, rewrite a result, force another model step, or short-circuit listeners registered after it. Structured output therefore cannot rely on being “first” or “last” in an extensible listener chain; the owning service needs a final boundary for rules that later listeners must not undo. Third, accepting a value must transfer ownership of the exact value that was checked. TypeScript `readonly` annotations disappear at runtime, callers and providers may expose stateful accessors, and a validation pass followed by a clone reads mutable input twice. Identity fields, schemas, session data, requests, and results therefore need runtime boundaries that capture each caller-owned field once, materialize data once, and expose only owner-controlled snapshots. Otherwise the checked, executed, logged, and observed views can diverge even when scope resolution itself is correct. +The failure does not require TypeScript or threads. One JavaScript getter is enough to make a two-read boundary validate one name and store another: + +```js +let reads = 0 +const input = { + get name() { + reads += 1 + return reads === 1 ? 'safe_tool' : 'different_tool' + }, +} + +// Wrong: validation and storage observe different values. +validateName(input.name) +storeName(input.name) + +// Right: capture once, then validate and store that capture. +reads = 0 +const acceptedName = input.name +validateName(acceptedName) +storeName(acceptedName) +``` + The subagent API makes these requirements concrete. Two concurrent children can request different personas, tool filters, and output schemas. Those requests are honest only when each child receives an independently owned view and when its terminal-output protocol survives unrelated plugins. ## Decision -Each live agent owns a registration context named `agent.ctx`, and services expose narrow owner-final policy boundaries where ordinary middleware ordering is not strong enough. Together these choices make one agent's world composable with normal plugin APIs while keeping authority, observation, and cleanup aligned. +Each live agent owns a registration context named `agent.ctx`, and services expose narrow owner-final policy boundaries where ordinary middleware ordering is not strong enough. Together these choices make one agent's registration view composable with normal plugin APIs while keeping visibility, observation, and cleanup aligned. The design has five parts: @@ -37,11 +59,90 @@ The design has five parts: | Owner-final policy | Prompt protection, tool guards, final tool-result observation, and terminal turn stopping run at service-owned boundaries | Invariants do not depend on listener registration order | | Boundary ownership | Services capture fixed fields once, materialize lossless-JSON data once, and publish owner-controlled views | Validation, execution, persistence, and telemetry cannot observe different values from one call | +### One public call shows the composition model + +The common case uses ordinary registration APIs through the setup context. Code blocks in this RFC are focused examples and start from a fresh initialized application unless one explicitly continues another. Here `ctx` is a plugin's service context, `setup(agentCtx)` receives the unpublished agent's scoped context, and helpers such as `AgentId`, `SessionId`, and `CallId` construct opaque IDs. Assume the deployment already registered global `read` and `bash` tools; this creates a reviewer whose persona, global-tool filter, and extra reporting tool exist only for that agent and disappear with its handle: + +```js +const reviewSummaryTool = { + name: 'review_summary', + description: 'Return the review summary.', + parameters: { type: 'object', properties: {} }, + async execute() { + return [{ type: 'text', text: 'review complete' }] + }, +} + +const handle = await ctx.agents.create({ + agentId: AgentId('reviewer'), + sessionId: SessionId('reviewer-session'), + agentOptions: { model: 'model-name' }, + setup(agentCtx) { + agentCtx.systemPrompt.section({ + name: 'deployment:persona', + order: 0, + text: 'Review code, but do not modify files.', + }) + agentCtx.tools.restrict({ allow: ['read'] }) + agentCtx.tools.register(reviewSummaryTool) + }, +}) + +const reviewer = handle.agent +ctx.tools.get('read', reviewer) // global tool, visible +ctx.tools.get('bash', reviewer) // undefined: filtered global tool +ctx.tools.get('review_summary') // undefined: not global +ctx.tools.get('review_summary', reviewer) // child-only definition + +await handle.dispose() +ctx.tools.get('review_summary', reviewer) // undefined: scope was unwound +``` + +The rest of this RFC explains why the short setup above needs scope-aware resolution, unpublished construction, ordered teardown, and owner-final policy. + +### Security and authority are explicit non-goals + +Scoped contexts are trusted in-process registration composition, not a sandbox, authorization ledger, or parent-to-child authority lattice. A plugin with a Cordis context executes in the same process and can call the services injected into that context. Scope filtering decides which registered contribution participates in one operation and who cleans it up; it does not prove that a child can do no more than its parent. + +#### Flat scopes do not enforce a parent-to-child subset + +Flat lookup makes that boundary visible. Parent lifetime ownership does not cause the child to inherit the parent's restriction, and a child-local registration is merged after the global filter: + +```text +global tools = { read, bash } +parent restriction = allow { read } +parent scoped registrations = { delegate } +child restriction = none +child scoped registrations = { deploy } + +visible(parent) = { read, delegate } +visible(child) = { read, bash, deploy } +``` + +Through `delegate`, a parent can deliberately start this child and indirectly obtain work performed with `bash` or `deploy`. This RFC neither prevents nor blesses that arrangement; deployments that need a non-escalation guarantee require a separate authority design and enforcement boundary. + +#### Restrictions are live views, not grant snapshots + +Restrictions also resolve against a live global registry rather than an immutable authorization snapshot. A deny-list names removals, while an allow-list names the complete retained global set: + +```text +at time 0: + global tools = { read, bash } + deny { bash } view = { read } + allow { read } view = { read } + +after registering global tool web: + deny { bash } view = { read, web } + allow { read } view = { read } +``` + +Scope-local tools are merged after either filter. There is no separate authority-versus-visibility ledger, frozen creation-time grant snapshot, parent-subset rule, future-tool grant API, or generic capability/output/terminal tag system here. `run_code` and `structured_output` have explicit protocol-owned treatment described below; they do not imply a general security taxonomy. Those questions are separate design work rather than hidden promises of scoped contexts. + Three domain terms recur below. A **Session** is one agent run's append-only event log, from which model history and durable replay are derived. **Lossless JSON** means JSON primitives plus dense arrays and plain objects that can be copied without changing meaning; the boundary rejects sparse arrays, cycles, exotic prototypes, non-finite numbers, negative zero, `undefined`, `bigint`, functions, and symbols instead of coercing or erasing them. **Code Mode** presents the model with a generated software-development-kit interface and a reserved `run_code` transport, rather than advertising every end-capability as a native tool. Ownership stays with the component that can enforce each fact. The scope package owns scope tags and carrier construction; each registry owns acceptance snapshots and resolution; the caller owns the programmatic agent lifetime it requested; the concrete agent factory owns identity reservation, setup, publication, and structural invalidation of agents that still depend on it; the session owns accepted history; the tool and subagent services own their pipeline records; and each workflow run captures its holder-bound dependencies and owns its cancellation after the engine returns it. A caller never validates a value that another component later rereads from the caller's mutable object. -The scope is flat. An agent resolves the deployment-global layer plus its own layer; a child does not inherit registrations from its parent's scope. Parent/child lineage remains explicit session data, and parent-owned disposal links lifetimes without silently inheriting authority. +The scope is flat. An agent resolves the deployment-global layer plus its own layer; a child does not inherit registrations from its parent's scope. Parent/child lineage remains explicit session data, and parent-owned disposal links lifetimes without inheriting registrations. The core implementation lives in [`dsh-scope`](../../../../packages/core/scope/README.md), [`dsh-agent`](../../../../packages/core/agent/README.md), [`dsh-agent-loop`](../../../../packages/core/agent-loop/README.md), [`dsh-session`](../../../../packages/core/session/README.md), [`dsh-system-prompt`](../../../../packages/core/system-prompt/README.md), and [`dsh-tools`](../../../../packages/core/tools/README.md). The composition example spans [`dsh-subagent`](../../../../packages/subagent/subagent/README.md), [`dsh-subagent-inprocess`](../../../../packages/subagent/subagent-inprocess/README.md), and [`dsh-workflow-workerthread`](../../../../packages/workflow/workflow-workerthread/README.md). The [generated Cordis event catalog](../../../cordis-catalog/events.md) is the exhaustive event-signature reference; this RFC explains why the contracts have their current shape. @@ -53,10 +154,29 @@ The design relies on four framework ideas: contexts, effects, waterfall events, A Cordis `Context` is the object through which a plugin reaches services such as `ctx.tools`, `ctx.systemPrompt`, and `ctx.sessions`. A service method can recover the context through which it was accessed, so the service can tell whether a call came from an ordinary plugin context or from an agent's scoped context without adding a `scope` parameter to every registration API. -A context also carries a capability view. A derived context reaches the services injected into the plugin that created it. Handing out `agent.ctx` therefore hands out the agent loop's injected service surface; it is not an ambient root context. +A context also carries an injected dependency view. A derived context reaches the services injected into the plugin that created it. Handing out `agent.ctx` therefore hands out the agent loop's injected service surface; it is not an ambient root context or a security confinement boundary. Factory delegation uses two contexts whose jobs must remain separate. The registry derives a caller-bound context carrying the fiber and scope from which `ctx.agents.create()` or `resume()` was called and passes it explicitly as `ownerCtx`; those facts identify the fiber and optional parent agent that own the requested lifetime. When the registered factory is itself a Cordis service, the registry also invokes it through a traced receiver, which preserves the factory's own injected dependency origin. A plain object that merely implements the factory methods receives the same explicit `ownerCtx` without depending on Cordis tracing. Conflating these roles would either attach the agent to the factory registrant instead of the caller or make the concrete loop resolve dependencies from the wrong service view. +The object before a service or event method selects the registration origin. The method itself does not need an extra agent parameter: + +```js +ctx.tools.register(globalTool) +agent.ctx.tools.register(agentOnlyTool) + +ctx.on('tools/result', globalObserver) +agent.ctx.on('tools/result', agentObserver) +``` + +Factory calls preserve caller ownership and factory dependency lookup as separate values: + +```text +callerCtx.agents.create(options) + ownerCtx = context carrying callerCtx's fiber and scope + factoryThis = concrete factory traced through ownerCtx + Reflect.apply(capturedCreateAgent, factoryThis, [ownerCtx, options]) +``` + ### Effects give registrations an owner A Cordis effect is work whose cleanup belongs to a runtime unit called a fiber. Tool registration, prompt contribution, and event subscription are effects, so disposing their fiber unwinds them on normal teardown, failure, or hot reload. @@ -65,12 +185,39 @@ Ownership must exist before effect setup can call arbitrary code. The vendored F `dsh-scope` mounts a no-op plugin fiber for each scope. The plugin contributes no behavior; its fiber is the ownership bucket for everything registered through the scoped context. +In its simplest form, an effect is a setup function that returns its cleanup. Cordis also supports generator effects that compose child effects in a chosen order. In either form Cordis records the wrapper before calling setup, so even setup-triggered reentrant teardown can find and await it: + +```js +ctx.effect(() => { + const resource = openResource() + return async () => { + await resource.close() + } +}) +``` + ### A waterfall is ordered around-middleware A Cordis waterfall is an extensible middleware chain. A listener calls `next()` to delegate, can inspect or replace the downstream result, and can return without calling `next()` to short-circuit everything inside it. This flexibility is useful for cooperative transformations, but registration order is not an invariant boundary. A later plugin can prepend another listener, a wrapper can replace the downstream result after `next()` returns, and a short-circuit can prevent inner listeners from running at all. +The code shape is ordinary around-middleware. Calling `next()` includes downstream work; returning directly skips that listener's downstream listeners and base implementation: + +```js +ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { + const downstream = await next() + return { + ...downstream, + sections: [...downstream.sections, extraSection], + } +}) + +ctx.on('system-prompt/assemble', async () => replacementAssembly) +// This listener skips its downstream/base. An outer listener that already +// awaited next() still resumes around replacementAssembly. +``` + ### The dispatch receiver selects scoped listeners Cordis filters event listeners using the dispatch receiver, the object exposed as `this` inside a function-style listener. `dsh-scope` supplies a receiver carrying the operation's scope key, so the event system can admit global listeners plus listeners registered for that key and reject listeners belonging to other agents. @@ -118,6 +265,18 @@ The scope key is an opaque object compared by identity. The harness uses the liv The property is deliberately not treated as the authoritative scope tag. A nested scope can install a nearer scope key while still inheriting the original `ctx.agent` association, so lower-level services resolve layers with `scopeOf(context)`. In normal agent composition the two point at the same live agent; the separation keeps the generic scope primitive independent of the agent package. +The distinction appears when a plugin deliberately nests another scope: + +```js +const auditKey = {} +const auditScope = createScope(agent.ctx, auditKey) + +auditScope.ctx.agent === agent // true: inherited ergonomic association +scopeOf(auditScope.ctx) === auditKey // true: authoritative nearest scope tag + +await auditScope.dispose() +``` + ### The scope primitive has separate public and composite disposal forms `dsh-scope` exposes the minimum operations needed to create a layer, read it, target events, and dispose it. “Quiescent” here means that every asynchronous cleanup registered in the scope has settled and no teardown work remains in flight. @@ -190,15 +349,32 @@ registerTool(context, definition): The reserved Code Mode transport uses the same frozen-definition contract even though it lives outside the ordinary layers. -### Tool restrictions reduce end capabilities without removing transport +### Tool restrictions filter the global view without removing transport -A tool restriction masks the global end-capability layer for one agent, while tools registered in that agent's own layer are explicit grants. Multiple restrictions intersect, so separately installed policies can only reduce the global surface. +A tool restriction masks the global end-capability layer for one agent, while tools registered in that agent's own layer are merged afterward. Multiple restrictions intersect, so separately installed filters can only reduce the global part of the view; they do not filter scope-local registrations. The restriction reads `allow` and `deny` once, snapshots those exact values, rejects an empty filter, and validates named tools against the pre-restriction capability universe. The same captured arrays are then enforced, so a stateful accessor cannot pass one policy through validation and install another. A restricted-away tool behaves like an unknown tool at execution, avoiding disclosure of a hidden global implementation. [Code Mode](../feature/2026-06-15-code-mode.md)'s `run_code` is not an end capability. It is a reserved presentation transport that carries calls to the visible end capabilities, so the registry keeps it outside both global and scoped registration layers: restrictions cannot remove it, a scoped tool cannot shadow it, and configuration cannot explicitly allow or deny it. Without this exception, a restriction could leave the generated SDK in the prompt but remove the only way to invoke it. -The registry still uses one executable visibility view. It first resolves restricted global capabilities plus scoped grants, then appends the reserved transport in non-native modes; registry-owned prompt schemas, lookup, execution, Code Mode SDK bindings, timeout lookup, inspection, and UI presentation all consume that view. +The registry still uses one executable visibility view. It first resolves filtered global capabilities plus scope-local registrations, then appends the reserved transport in non-native modes; registry-owned prompt schemas, lookup, execution, Code Mode SDK bindings, timeout lookup, inspection, and UI presentation all consume that view. + +The public lookup API exposes the exact same resolution used for prompt schemas and execution: + +```js +ctx.tools.register(readTool) +ctx.tools.register(bashTool) + +agent.ctx.tools.restrict({ allow: ['read'] }) +agent.ctx.tools.register(reviewSummaryTool) + +ctx.tools.get('read', agent) // visible global definition +ctx.tools.get('bash', agent) // undefined: filtered global definition +ctx.tools.get('review_summary', agent) // visible scope-local definition +ctx.tools.get('review_summary') // undefined: absent from global view +``` + +Executing `bash` for this agent follows the same lookup and produces the ordinary unknown-tool error; it does not bypass the filter through a separate execution registry. The [security non-goal](#security-and-authority-are-explicit-non-goals) explains why later global registrations and scope-local registrations are not an authorization snapshot. The guarantee covers the tool registry's contribution. A plugin can deliberately use the lower-level `systemPrompt.tools()` API or assembly waterfall to add an unrelated wire schema; that plugin owns the matching executable behavior and any ordering it introduces. Owner protection preserves reserved named infrastructure without turning the system-prompt service into a validator for unrelated contributions. @@ -227,6 +403,24 @@ The operation being described determines the key; callers cannot attach an unrel | `session/created`, `session/disposed`, `session/event`, `session/flush` | The owner scope captured when the session enters the store | | `subagent/start`, `subagent/end` | The delegating parent agent | +The observable rule is global plus matching, not global plus every scoped listener. This example drives a real tool execution so routing and notification use the same accepted `agent` subject: + +```js +const seen = [] +ctx.tools.register(readTool) +ctx.on('tools/result', () => seen.push('global')) +agentA.ctx.on('tools/result', () => seen.push('A')) +agentB.ctx.on('tools/result', () => seen.push('B')) + +await ctx.tools.execute({ + callId: CallId('read-1'), + name: 'read', + arguments: {}, + agent: agentA, +}) +seen // ['global', 'A'] +``` + Approval requests cross an asynchronous answer boundary, so the service snapshots the accepted record synchronously. It preserves the exact agent and abort-signal identities but copies the scalar fields, captures the agent's session once, and uses that one snapshot for `approval/asked`, scoped dispatch, cancellation, policy, and `approval/decided`. Mutating the caller-owned record after `request()` returns therefore cannot split the audit pair or redirect the question to another agent's listeners. The dispatch rule can be read independently of Cordis internals: @@ -252,16 +446,65 @@ Function-style listeners receive the carrier as `this`, and agent event APIs all Binding matters for classes with JavaScript private fields: a method called with the proxy itself as receiver would fail the runtime private-field identity check. The carrier therefore uses a dedicated surrogate proxy target with its own immutable composed-filter slot, while ordinary property access, writes, own-key visibility, methods, invocation, and construction delegate to the real subject; callable carriers also preserve whether the subject is constructable. -The composed filter is an authorization boundary, not an ordinary exposed callback. It invokes a subject's pre-existing filter with stable references to the built-in `Reflect.apply` and `Function.prototype.call` operations, pins its own `.call` to that captured built-in, and freezes the callable. Code holding the subject or carrier therefore cannot replace either `.call` property to turn a scoped predicate into an always-allow predicate. Keeping the filter on the surrogate also means a filter property pinned on the subject before, during, or after carrier construction cannot trigger a Proxy invariant that silently replaces scope isolation with the subject's raw filter. +A minimal JavaScript example shows why method binding is observable rather than a TypeScript detail: + +```js +class Subject { + #count = 0 + increment() { this.#count += 1 } +} + +const subject = new Subject() +new Proxy(subject, {}).increment() // TypeError: proxy lacks Subject's private identity + +const carrier = scopeTarget(subject, subject) +carrier.increment() // works: method is bound to subject +carrier === subject // false: dispatch carrier has distinct identity +``` + +The composed filter is a listener-selection correctness boundary, not an ordinary exposed callback. It invokes a subject's pre-existing filter with stable references to the built-in `Reflect.apply` and `Function.prototype.call` operations, pins its own `.call` to that captured built-in, and freezes the callable. Code holding the subject or carrier therefore cannot accidentally replace either `.call` property and turn the scoped predicate into an always-admit predicate. Keeping the filter on the surrogate also means a filter property pinned on the subject before, during, or after carrier construction cannot trigger a Proxy invariant that silently replaces scope filtering with the subject's raw filter. The surrogate must remain extensible so its reported own-key view can follow the subject. For non-overlay properties owned by the subject, descriptor queries preserve values and flags except that `configurable` is reported as `true`, which is the only Proxy-safe description of a property the extensible surrogate does not itself own. For the same reason, defining a property through the carrier is supported only when the descriptor explicitly says `configurable: true`; an omitted or false flag is rejected before the subject is touched. The carrier is intentionally not identity-equal to the subject; event arguments carry the real object whenever identity matters. -`Scoped` is a TypeScript-only marker that requires this carrier at declared scoped dispatch sites. It improves authoring but adds no runtime security, so runtime marks and development invariants check the same contract for JavaScript, casts, and hand-written dispatches. +`Scoped` is a TypeScript-only marker that requires this carrier at declared scoped dispatch sites. It improves authoring but adds no runtime enforcement by itself, so runtime marks and development invariants check the same contract for JavaScript, casts, and hand-written dispatches. These checks detect routing mistakes; they do not confine a hostile in-process plugin. ## Agent creation and teardown An agent's scope, session, registry entry, and driver form one transaction with two ownership edges. The caller context owns the work it requested and receives the only consumer-facing teardown capability; the concrete `AgentLoop` provider is a structural co-owner because the live agent continues to use the provider's injected services. Either edge deactivates the transaction and converges on the same ordered, memoized quiescence boundary. Setup finishes before publication, and publication is synchronous and rollback-covered rather than magically atomic. +The public contract is simple: setup may await while both identities remain absent from their registries; fulfillment publishes the complete agent; disposal removes it again. + +```js +const setupGate = Promise.withResolvers() +const agentId = AgentId('reviewer') +const sessionId = SessionId('reviewer-session') +const creating = ctx.agents.create({ + agentId, + sessionId, + agentOptions: { model: 'model-name' }, + async setup(agentCtx) { + await setupGate.promise + agentCtx.systemPrompt.section({ + name: 'deployment:persona', + order: 0, + text: 'Review the change.', + }) + }, +}) + +ctx.agents.get(agentId) // undefined while setup is pending +ctx.sessions.get(sessionId) // undefined while setup is pending +setupGate.resolve() + +const handle = await creating +ctx.agents.get(agentId) === handle.agent // true after publication +ctx.sessions.get(sessionId) === handle.agent.session // true after publication + +await handle.dispose() +ctx.agents.get(agentId) // undefined after quiescent teardown +ctx.sessions.get(sessionId) // undefined after quiescent teardown +``` + ### Create and resume reserve identities before asynchronous work Programmatic create and resume reserve both the agent ID and session ID before work that can await. Create prepares a fresh or seeded session; resume first loads and reconstructs the persisted session. Both paths then construct the agent, mint `agent.ctx`, and install the complete teardown skeleton before awaiting setup. @@ -421,7 +664,7 @@ For the concrete AgentLoop transaction, `agent/disposed` runs after the driver i Provider co-ownership is specific to resources that remain structurally dependent on their provider. An AgentLoop-created agent continues to resolve the loop's injected services, so loop unload must stop it. A worker workflow run instead captures its holder-bound `SubagentService` handle synchronously at `start()` and stores that independent dependency on the run; unloading `WorkerWorkflowEngine` removes the ability to start new runs but does not revoke an already returned run or prevent its later worker message from starting a child. The two lifetimes differ by dependency shape, not by a blanket rule that every service must own every value it creates. -Parent-owned subagents use explicit ownership rather than capability inheritance. The driver creates one run-owner fiber under `parent.ctx` and invokes the child factory through that fiber, so lifecycle ownership exists before setup or publication begins; disposing a parent reaches its descendants even if a delegating tool never reaches its own `finally`. The child still receives a newly minted scope and resolves only global plus child-scoped capabilities. +Parent-owned subagents use explicit ownership rather than registration inheritance. The driver creates one run-owner fiber under `parent.ctx` and invokes the child factory through that fiber, so lifecycle ownership exists before setup or publication begins; disposing a parent reaches its descendants even if a delegating tool never reaches its own `finally`. The child still receives a newly minted scope and resolves only global plus child-scoped registrations. ## Owner-final policy boundaries @@ -475,7 +718,7 @@ The registry materializes `arguments` in one lossless-JSON traversal and deep-fr The registry assigns each pipeline trip a frozen, property-free `ToolExecutionToken`; callers cannot choose that token. The execution is identity-stable, not fully immutable, while the pipeline runs: its `token`, `callId`, `name`, `agent`, optional opaque `parent` token, and detached `arguments` are non-writable and non-configurable from the first policy listener onward. `signal` is the only operational field; an around-dispatch wrapper may add, replace, or remove it. The registry freezes the complete execution before outcome observation. -Stable identity prevents a listener from changing which capability or scope was authorized after policy ran. It also gives commit-style observers a safe `WeakMap` key even when an adapter reuses a model call ID. +Stable identity prevents a listener from changing which tool or scope the pipeline accepted after policy ran. It also gives commit-style observers a safe `WeakMap` key even when an adapter reuses a model call ID. For a nested transport dispatch, `parent` carries only the enclosing execution's opaque token rather than its live object. Code Mode sets an SDK sub-call's `parent` to the outer `run_code` execution's `token`, so an observer can correlate the two outcomes without receiving a reference that could mutate the still-running outer wrapper. @@ -512,6 +755,24 @@ prepareExecution(input): This one-way result makes the boundary monotonic. Pre-execution hooks can still compose ordinary allow, deny, and ask decisions; an ask resolves through the optional `ctx.approval` seam, where only `allowed-once` becomes allow and an absent channel or any non-grant becomes deny before guards run. No listener ordering can convert a guard denial back into dispatched work. A denied call still continues through result transformation and final observation as an error outcome. +The two APIs have deliberately different strength. A waterfall listener may return an allow decision, but the later guard has no corresponding allow result: + +```js +agent.ctx.on( + 'tools/pre-execute', + async () => ({ kind: 'allow' }), + { prepend: true }, +) + +agent.ctx.tools.guard(execution => + execution.name === 'bash' + ? 'reviewer agents are read-only' + : undefined, +) +``` + +Even a later prepended allow listener cannot bypass this guard because the registry evaluates guards after the complete waterfall. + ### `tools/result` observes the authoritative live outcome The complete live pipeline is `tools/pre-execute` → monotonic guards → `tools/execute` → `tools/post-execute` → `tools/result`. The first three named events are transformable waterfalls; `tools/result` is an awaited, observe-only notification after all transforms and the registry's outer error normalization. At each untrusted result boundary, the registry captures every top-level field once and materializes the complete authoritative outcome as detached lossless JSON. Immediately before observation it materializes that owned outcome again and deep-freezes the shared listener snapshot. An invalid tool or listener result becomes a normal JSON-safe `isError` outcome instead of reaching observers as apparent success and failing later at the session log. @@ -520,7 +781,7 @@ Every `tools/result` listener receives the same frozen execution and deep-frozen `tools/result` is not the durable session event `tool/result`. The live notification belongs to the registry and also fires for direct programmatic executions; the agent loop subsequently appends `tool/result` to the session log for replay, UI reconstruction, and model history. A policy that needs the final in-process verdict uses the former, while a consumer that needs persisted transcript state uses the latter. -The entire registry method reads like one authority ladder: +The entire registry method reads like one execution-decision pipeline: ```text execute(input): @@ -575,7 +836,7 @@ Ordinary continuation remains extensible. The loop computes a default, runs the The scoped serial `agent/turn-stop` checkpoint runs after that folding. Its strict serial helper consults listeners in order until one returns a non-`undefined` value; a listener returns `{ action: 'stop' }` or abstains with `undefined`. The dedicated helper exists because ordinary Cordis serial dispatch treats `null` and `false` as framework abstentions, while this public contract has exactly one abstention value. A stop is terminal, so later listeners and pending steering cannot restore continuation. A malformed result, including `null` or `false`, or a throwing policy closes the current turn with an error while leaving the driver available for later work. -Terminal stop deliberately discards steering while preserving ordinary queued prompts. Its terminal state remains in force through `turn/end` and the durability flush, so steering added by continuation, turn-close, or flush listeners cannot escape through the loop's late-steering fallback into another step or turn. This is the explicit exception to the normal rule that leftover steering becomes input for another turn. The authority is reserved for protocols, such as a completed structured child, where further model work would violate the result contract. +Terminal stop deliberately discards steering while preserving ordinary queued prompts. Its terminal state remains in force through `turn/end` and the durability flush, so steering added by continuation, turn-close, or flush listeners cannot escape through the loop's late-steering fallback into another step or turn. This is the explicit exception to the normal rule that leftover steering becomes input for another turn. The stronger terminal control is reserved for protocols, such as a completed structured child, where further model work would violate the result contract. ```text afterSuccessfulStep(turn): @@ -605,13 +866,67 @@ The queued-prompt FIFO is separate and is never drained by terminal stop. In-process subagents demonstrate how the scope, lifecycle, and final-policy pieces compose. A provider builds the child's world during unpublished setup, then lets the ordinary agent lifecycle own it. +The caller-facing seam separates acceptance, readiness, result settlement, cancellation, and disposal. This example assumes the spawn backend is loaded under its configurable default provider name, `spawn`, `parent` is top-level (depth 0), and a global `read` tool has already been registered. A caller observes readiness before treating the child as live and always disposes the run: + +```js +const run = ctx.subagents.start('spawn', { + parent, + prompt: [{ type: 'text', text: 'Review this change.' }], + persona: 'You are a careful code reviewer.', + toolFilter: { allow: ['read'] }, + maxDepth: 2, + outputSchema: { + type: 'object', + properties: { summary: { type: 'string' } }, + required: ['summary'], + additionalProperties: false, + }, +}) + +try { + await run.started + const result = await run.result + // result.structured exists only after a successful committed capture. +} finally { + await run.dispose() +} +``` + ### Inputs and ownership are fixed before asynchronous creation +This section follows a child from provider/request acceptance through the service wrapper and then through the workflow bridge. Each layer captures its boundary before arbitrary asynchronous work and owns the cleanup it may need to start. + +#### Provider and request acceptance own the child boundary + Provider registration first freezes an acceptance snapshot of the provider name, capability flags, parent-context descriptor, and `start` callback; the callback is bound to the original provider object so its intentional internal state stays live. Lookup, validation, model-facing wording, dispatch, lifecycle notifications, and hot-reload cleanup all use that snapshot. Mutating or reusing the caller's provider object later therefore cannot rename a live entry, change its advertised powers, replace its callback, or make its disposer delete the wrong key. Starting a run reads every top-level request field once before capability validation, then snapshots every accepted field before asynchronous owner setup. This order makes checked and delegated capabilities identical even for a JavaScript caller with stateful accessors. Fixed scalars are checked at the same boundary: `maxDepth` must be a non-negative safe integer and `persona` must be a string. The parent and abort signal are retained as identity capabilities but never reread from the mutable request record; tool filters, seed events, agent options, output schema, and prompt are detached through the one-pass lossless-JSON materializer. The exported in-process driver repeats this boundary for direct callers before it awaits run-owner activation, including taking one seed snapshot from which it derives both the child prefix and `seedLength`. Later caller mutation therefore cannot change lifecycle scope, configuration, the schema enforced by the capture tool, or the prompt eventually logged and sent. -The driver first installs provider ownership. Only after that succeeds does it attach the request's abort listener and create one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves neither a child nor an orphaned listener. Calling `runOwner.ctx.agents.create()` gives the child factory an explicit `ownerCtx` carrying the run-owner fiber and scope, while the registry's traced factory receiver preserves AgentLoop's injected dependency origin. Parent teardown, provider teardown, and manual run disposal all dispose this same run-owner node; moving it out of the active state synchronously prevents an unpublished setup from publishing afterward, while all three paths follow one quiescence promise. This structured ownership does not change the child's flat capability view. +Depth validation is intentionally repeated at every public entry path, while one seam-owned helper keeps the accepted domain identical: + +```text +tool-subagent plugin load: + schema requires natural <= Number.MAX_SAFE_INTEGER + assertSubagentMaxDepth(config.maxDepth) + +SubagentService.start(request): + capture request.maxDepth once + assertSubagentMaxDepth(captured maxDepth) + +startInProcessRun(request): + capture request.maxDepth once + assertSubagentMaxDepth(captured maxDepth) + parentDepth = depthOf(parent) # also a non-negative safe integer + childDepth = parentDepth + 1 + if childDepth is not a safe integer: throw RangeError + if maxDepth is defined and childDepth > maxDepth: throw SubagentDepthError +``` + +The derived-value check is separate from validating either input: `Number.MAX_SAFE_INTEGER` is a valid stored parent depth, but adding one cannot produce a contract-valid child depth. The driver rejects that overflow even when no request-level `maxDepth` cap was supplied. + +The driver first installs provider ownership. Only after that succeeds does it attach the request's abort listener and create one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves neither a child nor an orphaned listener. Calling `runOwner.ctx.agents.create()` gives the child factory an explicit `ownerCtx` carrying the run-owner fiber and scope, while the registry's traced factory receiver preserves AgentLoop's injected dependency origin. Parent teardown, provider teardown, and manual run disposal all dispose this same run-owner node; moving it out of the active state synchronously prevents an unpublished setup from publishing afterward, while all three paths follow one quiescence promise. This structured ownership does not change the child's flat registration view. + +#### The service wrapper orders readiness, results, and lifecycle The provider's run separates acceptance from publication with `started: Promise`, but the service does not expose that caller-owned handle directly. It captures `id`, `started`, `result`, and each method once, binds methods to the provider-owned run handle, and returns a frozen service-owned wrapper. Capturing `dispose` first also preserves a rollback capability if a later accessor or method check reveals a malformed handle. The wrapper installs its shared disposal promise before invoking the raw provider callback, so synchronous reentry through the returned wrapper and ordinary repeat calls join one provider disposal rather than slipping through a not-yet-assigned memo. If the raw disposer directly returns that same reentrant wrapper promise, the service rejects the cyclic provider contract instead of awaiting a promise that depends on itself forever. @@ -654,7 +969,13 @@ SubagentService.start(...): on fulfillment, emit subagent/start and then buffered or eventual subagent/end on rejection, discard buffered lifecycle telemetry return serviceRun immediately +``` +#### The workflow bridge closes readiness and settlement races + +Every downstream protocol that announces a subagent must honor the same boundary. The workflow worker bridge therefore registers the returned run before waiting, observes and snapshots `result` immediately, and sends `ChildStarted` only after `started` fulfills while admission remains open. A readiness rejection is refused and host-disposed; `ChildStartError` is sent while worker-message admission remains open, and an already-retired exact run is not cleaned twice. Provider `start()` is itself arbitrary code and may synchronously reenter workflow cancellation before its returned run reaches that registry. The bridge attaches both promise observers, re-checks terminal admission immediately after `start()` returns and again at readiness, and turns a closed boundary into identity-guarded cancellation, disposal, and refusal rather than late worker admission or lifecycle announcement. An arbitrary provider may still fulfill its own `started` promise after the workflow boundary; the bridge refuses and cleans up that attempt instead of claiming it can undo provider-side publication. + +```text Workflow worker bridge after receiving returnedRun: register the run so cancellation can reach pre-publication work attach result settlement handlers immediately and snapshot the outcome @@ -700,8 +1021,6 @@ Host at physical worker exit: do not repeat explicit child cancellation ``` -Every downstream protocol that announces a subagent must honor the same boundary. The workflow worker bridge therefore registers the returned run before waiting, observes and snapshots `result` immediately, and sends `ChildStarted` only after `started` fulfills while admission remains open. A readiness rejection is refused and host-disposed; `ChildStartError` is sent while worker-message admission remains open, and an already-retired exact run is not cleaned twice. Provider `start()` is itself arbitrary code and may synchronously reenter workflow cancellation before its returned run reaches that registry. The bridge attaches both promise observers, re-checks terminal admission immediately after `start()` returns and again at readiness, and turns a closed boundary into identity-guarded cancellation, disposal, and refusal rather than late worker admission or lifecycle announcement. An arbitrary provider may still fulfill its own `started` promise after the workflow boundary; the bridge refuses and cleans up that attempt instead of claiming it can undo provider-side publication. - Cancellation before readiness is a publication decision, not merely a flag for later result mapping. The in-process run synchronously deactivates its owner fiber. If cancellation lands before publication, the factory's liveness check prevents either creation edge. If it begins synchronously inside `session/created`, `agent/created`, or `agent/session-start`, the publication barrier lets the current notification phase unwind without revoking its world, the next liveness check prevents every later phase and driver start, and rollback pairs every creation edge that already began. In either case `started` rejects, no `subagent/start` or `subagent/end` is emitted, and the run result settles as `aborted`. Receipt of the worker's `Result` message is the workflow host's atomic first-wins boundary. The worker queues that message before its own settlement-reap `ChildCancel` messages, so same-port FIFO prevents an internal child callback from masquerading as earlier run cancellation. Each contender records its claim before its own callback fanout: external `cancel()` records its reason first, while Result receipt snapshots any earlier cancellation and claims the resulting terminal outcome before invoking settlement-cleanup provider code. A caller, signal, or dispose cancellation already in flight therefore overrides a non-cancelled worker report, while the report wins otherwise. Before exposing that chosen result, the host drives both permitted child-cancellation channels by aborting the shared request signal and calling every registered run's `cancel()`, including runs still waiting on readiness. Those calls are settlement-only cleanup, and the terminal claim makes a reentrant `WorkerRun.cancel()` a side-effect-free loser rather than merely repairing its result afterward. Host fanout and the worker's FIFO-later `ChildCancel` can both reach the explicit channel, so a per-call gate invokes each provider `cancel()` at most once; the seam does not require that callback to be idempotent. Explicit child cancel callbacks are contained independently so one throwing callback cannot starve peers or alter settlement. @@ -716,7 +1035,26 @@ Parent teardown reaches `runOwner` by nesting; the provider and returned run han A child persona is a scoped `deployment:persona` section that shadows the deployment-wide section. A child tool filter is a scoped restriction over global end capabilities. Omitted filters remain omitted; a materialized empty `allow` list means “allow nothing” and is not confused with absence. -The child's persona, filter, and structured runtime are installed inside factory setup. The common run-owner fiber gives structured-concurrency-style teardown without importing the parent's capability layer into the child. +The child's persona, filter, and structured runtime are installed inside factory setup. Persona and filtering use public registration methods directly. The package-internal structured helper groups the public tool, prompt, protection, guard, and listener registrations that form one terminal protocol: + +```js +let structured +const setup = childCtx => { + if (persona !== undefined) { + childCtx.systemPrompt.section({ + name: 'deployment:persona', + order: 0, + text: persona, + }) + } + if (toolFilter !== undefined) childCtx.tools.restrict(toolFilter) + if (schema !== undefined) { + structured = attachStructuredRuntime(childCtx, schema) + } +} +``` + +The common run-owner fiber gives structured-concurrency-style teardown without importing the parent's registration layer into the child. The filter affects the child's global tool view; it is not a parent-derived authority ceiling. ### Structured output is a child-owned terminal protocol @@ -784,6 +1122,17 @@ Scope mistakes are fail-open if they merely omit a carrier, so the implementatio `agentEvents(context, agent)` couples the dispatch carrier to the agent argument, `assembleContextFor(agent)` couples prompt facts to the scope selector, and `SessionStore.flush(session)` owns lookup of the carrier captured when the session entered the store. These helpers make a mismatched subject harder to express than the correct spelling. +Their essential construction makes the coupling explicit: + +```text +assembleContextFor(agent): + return { agent, scope: agent } + +agentEvents(context, agent): + carrier = scopeTarget(agent, agent) + return dispatcher that always injects agent as the event subject +``` + ### Type markers cover every scoped event declaration Scoped agent, approval, tool, prompt, session, and subagent lifecycle events declare a `Scoped` receiver. TypeScript therefore rejects a bare subject at typed dispatch sites, including the `subagent/start` and `subagent/end` paths whose scope is the delegating parent. @@ -814,9 +1163,9 @@ Service isolation chooses one registry instance for a context, while agent compo Isolation remains appropriate for independent applications. It is too coarse for collaborating agents inside one deployment. -### Inherit the parent's scope into a child +### Inherit the parent's registrations into a child -Hierarchical capability inheritance makes lifetime convenient but silently grants every child the parent's scoped tools and policies. A flat view plus an explicit parent-owned disposer separates the two questions: the parent owns the child without conferring its authority. +Hierarchical registration inheritance makes lifetime convenient but silently copies every parent-scoped tool and policy into each child. A flat view plus an explicit parent-owned disposer separates lifetime from registration composition: the parent owns the child without importing the parent's layer. As the [security non-goal](#security-and-authority-are-explicit-non-goals) states, flat lookup does not by itself impose a child-within-parent authority relationship. ### Publish the agent before running setup @@ -832,7 +1181,7 @@ Awaited setup makes the transaction explicit and keeps the first assembly behind ### Enforce invariants with prepended waterfall listeners -A prepended listener is not necessarily outermost: another plugin can prepend later, a short-circuit can skip inner work, and an outer wrapper can replace the result after delegation. The same issue appears in prompt assembly, tool authorization, result commit, and turn continuation. +A prepended listener is not necessarily outermost: another plugin can prepend later, a short-circuit can skip inner work, and an outer wrapper can replace the result after delegation. The same issue appears in prompt assembly, tool decisions, result commit, and turn continuation. The owner-final APIs express the actual strength required by each rule: restore named canonical data, deny monotonically, observe the immutable final outcome, or stop after all ordinary continuation inputs are folded. @@ -861,16 +1210,16 @@ The main benefit is one composition model across data, behavior, and lifetime: r ### Costs and constraints -The costs are concentrated in dispatch discipline, per-scope registry state, and explicit authority boundaries that are intentionally stronger than ordinary middleware. +The costs are concentrated in dispatch discipline, per-scope registry state, and owner-final decision boundaries that are intentionally stronger than ordinary middleware. - Every scoped event dispatcher must carry the correct receiver; fused helpers, type markers, invariants, and gates exist because omission would otherwise deliver only to global listeners. -- `agent.ctx` is capability-bearing. Its available services come from the agent loop's injected context, so holders receive that deliberate service surface. +- `agent.ctx` is service-bearing. Its available services come from the agent loop's injected context, so holders receive that deliberate dependency surface; this is not confinement. - Registries maintain per-scope maps and perform a global-plus-one-layer merge for the agent lifetime. - The dispatch carrier is proxy-shaped and not identity-equal to its subject, even though method calls and property access behave like the subject. Its composed filter is frozen, and defining a property through the carrier requires an explicitly configurable descriptor because the extensible surrogate cannot truthfully expose a new non-configurable subject property. -- Flat scopes do not inherit parent capabilities; a desired child capability must be global or explicitly registered for the child. +- Flat scopes do not inherit parent registrations; a desired child-local contribution must be global or explicitly registered for the child. - `run_code` is protected transport infrastructure rather than a filterable end capability, so a policy that must forbid programs denies execution at the tool-policy layer instead of removing the transport from a Code Mode prompt. - Prompt protection restores named canonical contributions and their anchor placement, not the entire assembly; unprotected output remains extensible, while a globally protected section name is deliberately unavailable for scoped shadowing. -- Terminal turn stopping has authority to discard pending steering. That power is appropriate for owner-enforced terminal protocols and too strong for ordinary cooperative continuation policy. +- Terminal turn stopping can discard pending steering. That control is appropriate for owner-enforced terminal protocols and too strong for ordinary cooperative continuation policy. - Programmatic `ctx.agents.create()` and `ctx.agents.resume()` are asynchronous because they await setup. The direct no-setup `ctx.agentLoop.create()` path, used by configuration and programmatic callers that already have complete options, remains synchronous. - A programmatic agent is caller-owned but also structurally owned by its concrete AgentLoop provider. Reloading that provider tears the agent down even if a consumer still holds its handle, because the handle cannot keep the provider's dependency surface valid. - Ordered composition requires exact raw effect identities plus shared public quiescence promises; the dual surfaces and lifecycle-long owner sentinels reflect distinct Cordis nesting and repeated-caller requirements. @@ -878,3 +1227,5 @@ The costs are concentrated in dispatch discipline, per-scope registry state, and ### Deliberate boundaries The scope primitive is generic, but this decision applies it only where one agent needs a coherent registration view: tools, prompt state, scoped events, sessions, and in-process subagent composition. `agent.ctx` does not automatically scope every service call; filesystem policy, LLM interception, background subagent state, and other registries retain their existing seams until their own designs explicitly adopt the context rule. + +Security hardening remains separate design work; the [security and authority non-goals](#security-and-authority-are-explicit-non-goals) define this RFC's trust boundary without turning registration scope into an authorization model. diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 942ec4efb4..88b89f5d3b 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -189,7 +189,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'systemPrompt', - summary: 'Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, named prompt variables, and authoritative contribution protections; the agent loop calls `assemble(context)` once per step.', + summary: 'Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, named prompt variables, and owner-final contribution protections; the agent loop calls `assemble(context)` once per step.', methods: [ 'section(section: PromptSection): () => Promise | void', 'tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise | void', diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md index 1813ff5071..06d06ba59e 100644 --- a/packages/core/scope/README.md +++ b/packages/core/scope/README.md @@ -16,6 +16,6 @@ Scoped-context registration primitive. `createScope(ctx, key)` mints a Cordis co ## Design contract -Ownership and visibility derive from ONE fact — which context a registration went through. An explicit `{ scope }` registration parameter could express "visible to X, disposed with Y", which is almost always a bug; the scoped context makes it unrepresentable. Rationale and alternatives: [the agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md). +Ownership and visibility derive from ONE fact — which context a registration went through. An explicit `{ scope }` registration parameter could express "visible to X, disposed with Y", which is almost always a bug; the scoped context makes it unrepresentable. This is trusted registration and listener routing, not sandboxing or an authority hierarchy: a same-process plugin is not confined, and a child scope need not be a subset of its parent's view. Rationale, alternatives, and the security non-goal: [the agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals). -Handing out a scoped context hands out the minting plugin's service-resolution capability (resolution walks the minting fiber's dependency chain, not the holder's) — mint scopes from a plugin whose `inject` surface is what scope holders should reach. +Handing out a scoped context hands out the minting plugin's service-resolution surface (resolution walks the minting fiber's dependency chain, not the holder's) — mint it from the plugin whose dependencies the scoped registrations need to resolve. diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index 158d6d37d8..526dffbb51 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -27,8 +27,8 @@ import { Context as CordisContext } from 'cordis' // Capture the invocation primordials once. A carrier holder can reach the // composed Context.filter function, so neither that function's mutable -// property surface nor a base filter's own `.call` may choose how isolation -// predicates are invoked. +// property surface nor a base filter's own `.call` may choose how listener- +// selection predicates are invoked. const reflectApply = Reflect.apply // eslint-disable-next-line @typescript-eslint/unbound-method const functionCall = Function.prototype.call @@ -131,7 +131,7 @@ function scope(): void {} * Service resolution through the scoped context flows through the minting * plugin's dependency chain (the fiber walk), regardless of what the eventual * holder's own fiber injected — handing out the scoped context hands out that - * capability; see `Agent.ctx` in `@deepseek-ai/dsh-agent` for the harness's + * dependency surface; see `Agent.ctx` in `@deepseek-ai/dsh-agent` for the harness's * contract. * @param ctx - the context to mount the scope under; its fiber must be active * (a disposing owner throws Cordis's INACTIVE_EFFECT), and its plugin's @@ -201,7 +201,7 @@ function isConstructable(value: (...args: unknown[]) => unknown): boolean { * compatibility default: plain plugin listeners see every subject), or * - its tag IS `key` (a scoped listener seeing exactly its own subject), * - * AND `base`'s own filter (a Cordis `Service`'s isolation check) also admits + * AND `base`'s own filter (a Cordis `Service`'s listener-filter check) also admits * it. Both the captured base filter and the composed filter are invoked * through captured JavaScript primordials, so mutating either function's * public `.call` property cannot bypass either predicate. Dispatching with @@ -263,7 +263,7 @@ export function scopeTarget(base: T, key: ScopeKey | undefined // construction, a base-target proxy would therefore silently replace the // composed scope predicate with the caller's filter. The surrogate owns the // two immutable overlay slots, so later descriptor changes on `base` cannot - // affect isolation. It shares the base prototype and delegates ordinary + // affect listener selection. It shares the base prototype and delegates ordinary // reads/writes/keys to preserve the supported transparent shape. Callable // targets use native bound built-ins so V8 contributes no user-code surface; // the chosen built-in matches whether `base` has [[Construct]], and the traps diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index dd395b3f88..06d735e271 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -1,6 +1,6 @@ # dsh-system-prompt -System prompt assembly registry. Plugins contribute ordered text sections, tool-schema providers, named prompt variables, and authoritative named protections; the agent loop calls `assemble(context)` once per step, and `renderPrompt(assembly)` is the full system prompt the model sees. The plugin registers the harness-owned openers itself — the static `harness:identity` section and the deployment's `deployment:persona` section — so they exist for every agent regardless of which loop plugin drives it. +System prompt assembly registry. Plugins contribute ordered text sections, tool-schema providers, named prompt variables, and owner-final named protections; the agent loop calls `assemble(context)` once per step, and `renderPrompt(assembly)` is the full system prompt the model sees. The plugin registers the harness-owned openers itself — the static `harness:identity` section and the deployment's `deployment:persona` section — so they exist for every agent regardless of which loop plugin drives it. ## Config @@ -16,7 +16,7 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool- - `ctx.systemPrompt.section(section: PromptSection): () => Promise | void` Contribute a section. The registry reads `name`, `order`, and the text value/callback once, validates their fixed string/finite-number/string-or-function types, and stores only that accepted record; later caller-object mutation cannot rename or reshape it. The layer is the CALLING context's scope: `agent.ctx` contributes to that agent alone, SHADOWING a same-named global section there (the per-agent persona mechanism — a scoped `deployment:persona`). Duplicate names within one layer throw, and a globally protected section name cannot be shadowed. Disposed with the calling fiber. - `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise | void` Contribute tool schemas, evaluated at each assembly with that assembly's context; a non-function provider rejects before effect storage. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set for `context.scope`; `knownNames` (defaulting to the same captured schemas' names) is the pre-restriction universe `toolOrder` validates against. Assembly reads the result, each schema field, and the optional known-name list once before detaching them, rejects non-string schema names/descriptions or known names, and uses those same accepted strings for validation and the model-visible collection. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber. - `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => Promise | void` Contribute a prompt variable, referenced from section text as `{{name}}`. The fixed string name and function provider types reject before effect storage. Scoped variables (via `agent.ctx`) shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber. -- `ctx.systemPrompt.protect(protection: PromptProtection): () => Promise | void` Make named section/tool contributions authoritative after the assembly waterfall. Protection restores canonical registry/provider presence and definition; restored entries keep canonical order with one another and anchor before their first surviving later unprotected canonical neighbor (or at the end), without undoing listener reordering of unprotected entries. Canonical absence is authoritative too, so a mode-hidden tool cannot be fabricated by a listener. Calling through `agent.ctx` protects only that agent's assemblies. A global section protection additionally reserves its name against scoped shadows; registering either side of that conflict fails loudly instead of treating the shadow as canonical. Each optional field and array slot is read once, non-array fields or non-string names reject before effect storage, and the accepted arrays are deduplicated and frozen. Finalization materializes each waterfall-produced entry name once, so a stateful getter cannot evade canonical replacement. Empty protections throw, and disposal removes the protection. +- `ctx.systemPrompt.protect(protection: PromptProtection): () => Promise | void` Make named section/tool contributions owner-final after the assembly waterfall. Protection restores canonical registry/provider presence and definition; restored entries keep canonical order with one another and anchor before their first surviving later unprotected canonical neighbor (or at the end), without undoing listener reordering of unprotected entries. Canonical absence is owner-final too, so a mode-hidden tool cannot be fabricated by a listener. Calling through `agent.ctx` protects only that agent's assemblies. A global section protection additionally reserves its name against scoped shadows; registering either side of that conflict fails loudly instead of treating the shadow as canonical. Each optional field and array slot is read once, non-array fields or non-string names reject before effect storage, and the accepted arrays are deduplicated and frozen. Finalization materializes each waterfall-produced entry name once, so a stateful getter cannot evade canonical replacement. Empty protections throw, and disposal removes the protection. - `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer (scoped shadows global). Provider output becomes one coherent detached snapshot before `toolOrder` validation. Runs through the scope-filtered `system-prompt/assemble` waterfall, then restores protected contributions from the pre-waterfall canonical assembly. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe (a restricted-away KNOWN tool is a normal absence), or when a provider returns the reserved rest-entry name. ### Live events diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index b810d07559..40eddc7ba1 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -1,6 +1,6 @@ /** * System prompt assembly registry. Plugins contribute ordered text sections, - * tool schema providers, named prompt variables, and authoritative named + * tool schema providers, named prompt variables, and owner-final named * protections; `assemble(context)` collates them through a waterfall that * runs once per step, restores protected contributions, and `renderPrompt` * interpolates `{{variable}}` references into the final text. @@ -138,9 +138,9 @@ export interface ToolProviderResult { * off the wire in Code Mode). */ export interface PromptProtection { - /** Section names whose canonical registry output is authoritative. */ + /** Section names whose canonical presence and definition are restored after the waterfall. */ sections?: readonly string[] - /** Tool names whose canonical provider output is authoritative. */ + /** Tool names whose canonical presence and definition are restored after the waterfall. */ tools?: readonly string[] } @@ -422,7 +422,7 @@ function interpolate(section: AssembledSection, variables: Record Promise | void { @@ -736,7 +736,7 @@ export class SystemPrompt extends Service { return dispose } - /** Resolve the authoritative names registered for one assembly scope. */ + /** Resolve the owner-final names registered for one assembly scope. */ private protectedNames(scope: ScopeKey | undefined): { sections: Set; tools: Set } { const records = [ ...this.protections, diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 74f1ff14c4..39874a41bd 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -16,7 +16,7 @@ tools: ### Public API - `ctx.tools.register(definition: ToolDefinition): () => Promise | void` Register a tool as a frozen snapshot. Every top-level caller field is read once into one coherent acceptance record; `name`/`description` must be strings and `timeoutMs`, when present, must be positive and finite before the snapshot can own them. Parameters are validated and detached by one recursive lossless-JSON traversal, so a stateful getter cannot show one value to a check and another to a prototype-erasing clone. Execute/presentation callbacks are bound once to the original definition as their method receiver, so later caller mutation cannot change the executable definition. The layer is the CALLING context's scope (`dsh-scope`): a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, SHADOWING a same-named global tool there (per-agent tool variants). Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Disposed with the calling fiber (= the agent, for scoped registrations). -- `ctx.tools.restrict(filter: ToolRestriction): () => Promise | void` Scoped-only (throws on a plain context): mask the GLOBAL end-capability surface for the calling agent — `allow` keeps only the listed tools, `deny` removes them; multiple restrictions intersect; scoped registrations bypass restriction as explicit grants. The registry reads `allow`/`deny` once, so the values checked for an empty filter and unknown names are exactly the values enforced. The reserved `run_code` transport remains available automatically and cannot be named explicitly. Snapshot-at-registration, loud unknown-name validation, `restrict({})` rejects (the materialized-empty-config trap). +- `ctx.tools.restrict(filter: ToolRestriction): () => Promise | void` Scoped-only (throws on a plain context): mask the GLOBAL end-capability surface for the calling agent — `allow` keeps only the listed tools, `deny` removes them; multiple restrictions intersect; scope-local registrations are merged after the global filter. The registry reads `allow`/`deny` once, so the values checked for an empty filter and unknown names are exactly the values enforced. A deny-list admits a later global tool unless it names that tool; an allow-list excludes later names; neither filters a later scope-local registration. The reserved `run_code` transport remains available automatically and cannot be named explicitly. Filter-value snapshot at registration, loud unknown-name validation, `restrict({})` rejects (the materialized-empty-config trap). This is live registration composition, not a parent-derived authority ceiling; see the [agent-scope security non-goal](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals). - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. Returned definitions are the registry's frozen snapshots. - `ctx.tools.visible(scope?: ScopeKey): ToolDefinition[]` The canonical executable view — restricted global layer ∪ the scope's own layer, plus the reserved transport in non-native modes — feeding prompt assembly, `get`, and `execute`, so presentation and dispatch resolve the same frozen definitions. - `ctx.tools.knownNames(scope?: ScopeKey): string[]` The PRE-restriction end-capability name universe `restrict` validates against: a typo fails loud while a restricted-away tool stays a normal absence. Presentation providers add reserved transport names separately when validating `toolOrder`. diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index d757f6cffa..4dd59efb8c 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -115,8 +115,8 @@ declare module 'cordis' { * set or replace the one mutable field, `exec.signal` (e.g. with a per-call * deadline), BEFORE `next()`, restore/delete it afterward, and inspect the result AFTER. Call identity * (`token`, `callId`, `name`, `arguments`, `agent`, and `parent`) is immutable throughout the - * pipeline so a wrapper cannot change which capability or scope was - * authorized. (Cordis `next()` ignores passed arguments and re-invokes + * pipeline so a wrapper cannot change which tool and scope the pipeline + * accepted. (Cordis `next()` ignores passed arguments and re-invokes * downstream with the shared payload, so a wrapper changes `exec.signal` in * place rather than passing a new object to `next()`.) * Multiple listeners compose by registration order — an outer one wraps the @@ -429,8 +429,11 @@ export interface Config { * {@link ToolRegistry.restrict}. `allow` keeps only the listed global tools; * `deny` removes the listed ones; both present = allow first, then deny. * Restrictions never touch scoped registrations — a tool registered through - * the same scope is an explicit grant that bypasses them (which is what keeps - * e.g. a structured-output capture tool alive under an allow-list). The + * the same scope is merged after the global filter (which is what keeps e.g. a + * structured-output capture tool alive under an allow-list). The filter values + * are snapshotted at registration, but resolution uses the live global registry: + * a later global name passes a deny-only filter unless explicitly denied and + * fails an allow-list unless explicitly allowed. The * reserved `run_code` presentation transport is likewise outside capability * filtering, and naming it explicitly is rejected. Multiple restrictions on * one scope compose by intersection: every one must admit. @@ -705,11 +708,14 @@ export class ToolRegistry extends Service { * this). A non-native mode's reserved `run_code` presentation transport is * not a filterable capability; naming it explicitly throws, while omitting * it from an allow-list cannot remove it. `allow` and `deny` are each read - * once, then the filter is SNAPSHOT at registration: the values checked are - * the values enforced, and later caller mutation of the arrays changes nothing. - * Multiple restrictions compose by intersection. Scoped registrations - * bypass restrictions (explicit grants win). Disposed with the calling - * fiber (revocable independently); emits `tools/change`. + * once, then the filter VALUES are snapshotted at registration: the values + * checked are the values enforced, and later caller mutation of the arrays + * changes nothing. Resolution still uses the live global registry, so a later + * global name passes a deny-only filter unless named and fails an allow-list + * unless named. Multiple restrictions compose by intersection. Scoped + * registrations are merged after restrictions and therefore remain visible. + * Disposed with the calling fiber (revocable independently); emits + * `tools/change`. * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove). * @returns the disposer that lifts this restriction. The exact * Cordis effect disposer (single-shot): composite (generator) effects may @@ -863,7 +869,7 @@ export class ToolRegistry extends Service { if (this.admits(scope, name)) result.set(name, definition) } // Scoped layer second: same-name entries REPLACE (shadow) the global ones, - // and grants bypass restrictions by construction (never filtered above). + // and scope-local registrations are never part of the global filter above. for (const [name, definition] of layer ?? []) result.set(name, definition) // Presentation infrastructure is resolved last and outside capability // filtering. Registration rejects this reserved name, so this set is an diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index ea4ef57ddd..40671ae6fc 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -101,7 +101,7 @@ describe('scoped tool registration', () => { }) describe('restrict()', () => { - it('masks global tools for the scope; grants bypass; assembly and execute agree', async () => { + it('masks global tools, merges scope-local tools afterward, and keeps assembly with execution', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'a') ctx.tools.register(tool('read')) @@ -109,7 +109,7 @@ describe('restrict()', () => { scope.ctx.tools.register(tool('capture')) scope.ctx.tools.restrict({ allow: ['read'] }) - // The scoped grant survives the allow-list; the unlisted global is gone. + // The scope-local registration survives the allow-list; the unlisted global is gone. expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['capture', 'read']) expect(await run(ctx, 'bash', key)).toBe('Error: unknown tool "bash"') expect(await run(ctx, 'read', key)).toBe('ran:read') @@ -118,6 +118,29 @@ describe('restrict()', () => { expect(ctx.tools.schemas().map(t => t.name).sort()).toEqual(['bash', 'read']) }) + it('applies snapshotted filters to the live global registry before merging later scope-local tools', async () => { + const ctx = await mount() + const denied = await mintAgentScope(ctx, 'denied') + const allowed = await mintAgentScope(ctx, 'allowed') + ctx.tools.register(tool('read')) + ctx.tools.register(tool('bash')) + denied.scope.ctx.tools.restrict({ deny: ['bash'] }) + allowed.scope.ctx.tools.restrict({ allow: ['read'] }) + + ctx.tools.register(tool('web')) + denied.scope.ctx.tools.register(tool('denied-local')) + allowed.scope.ctx.tools.register(tool('allowed-local')) + + expect(ctx.tools.schemas(denied.key).map(t => t.name).sort()) + .toEqual(['denied-local', 'read', 'web']) + expect(ctx.tools.schemas(allowed.key).map(t => t.name).sort()) + .toEqual(['allowed-local', 'read']) + expect(await run(ctx, 'web', denied.key)).toBe('ran:web') + expect(await run(ctx, 'web', allowed.key)).toBe('Error: unknown tool "web"') + expect(await run(ctx, 'denied-local', denied.key)).toBe('ran:denied-local') + expect(await run(ctx, 'allowed-local', allowed.key)).toBe('ran:allowed-local') + }) + it('composes multiple restrictions by intersection and lifts each independently', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'a') diff --git a/packages/subagent/subagent-fork/README.md b/packages/subagent/subagent-fork/README.md index 1434601c8e..2aef651839 100644 --- a/packages/subagent/subagent-fork/README.md +++ b/packages/subagent/subagent-fork/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-subagent-fork -The in-process **fork** subagent backend: a [`SubagentProvider`](../subagent/README.md) that runs each child as a child [`Agent`](../../core/agent) **seeded with a prefix of the parent's session log** — so the child inherits the parent's conversation context instead of starting fresh. Shares the run driver (`startInProcessRun`) with [`dsh-subagent-spawn`](../subagent-spawn/README.md); the only difference is the seed. The shared `run.started` boundary resolves only after the seeded child is published, so `subagent/start` observers see a live registry entry. +The in-process **fork** subagent backend: a [`SubagentProvider`](../subagent/README.md) that runs each child as a child [`Agent`](../../core/agent) **seeded with a prefix of the parent's session log** instead of starting with an empty conversation. The seed affects conversation history only. Tool registrations and restrictions follow the child's fresh flat scope; no parent/child authority relation is defined. Fork shares the run driver (`startInProcessRun`) with [`dsh-subagent-spawn`](../subagent-spawn/README.md); the only difference is the seed. The shared `run.started` boundary resolves only after the seeded child is published, so `subagent/start` observers see a live registry entry. ## The seed boundary (the crux) diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index b96cc811ad..0980d85aa9 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -8,13 +8,15 @@ The shared **in-process subagent run driver**. A library with no provider or imp Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`): -1. reads every public request and seed field once before asynchronous owner setup: the parent and signal remain identity capabilities, while tool filter, seed, agent options, output schema, and prompt are each materialized by the shared one-pass lossless-JSON snapshot. It rejects a malformed `request.maxDepth`, validates the parent's `subagentDepth`, computes child depth = `depthOf(parent) + 1`, rejects cap overflow with `SubagentDepthError`, reports an invalid schema as `OutputSchemaError`, and derives both the child prefix and `seedLength` from the same detached seed; +1. reads every public request and seed field once before asynchronous owner setup: the parent and signal remain identity capabilities, while tool filter, seed, agent options, output schema, and prompt are each materialized by the shared one-pass lossless-JSON snapshot. It rejects malformed `request.maxDepth` and `persona` values, validates the parent's `subagentDepth`, computes child depth = `depthOf(parent) + 1`, rejects a child depth outside the safe-integer domain with `RangeError`, rejects a defined `maxDepth` cap breach with `SubagentDepthError`, reports an invalid schema as `OutputSchemaError`, and derives both the child prefix and `seedLength` from the same detached seed; 2. first installs provider ownership, then attaches the request abort listener and creates one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves no child or orphaned listener. Async child creation goes through that fiber's `ctx.agents` service with fresh IDs, lineage/seed, inherited model, and an unpublished setup transaction for persona, tool restriction, and structured output. Parent teardown, provider teardown, manual `run.dispose()`, and cancellation before readiness all dispose this exact node, preventing publication after it becomes inactive and sharing the same quiescence boundary. `startInProcessRun` still returns its `SubagentRun` immediately: `run.started` resolves only after `ctx.agents.create()` has published the child and rejects when pre-readiness cancellation rolls the transaction back; 3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts); there is deliberately NO re-prompt for a structured child that finished cleanly without calling `structured_output` — the shortfall maps to an `error` result for the parent; 4. reads the result, scoped to the child's OWN events (everything at or after `seedLength`, so a seeded child that produced no message of its own never returns the seeded parent's last message): the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. A structured run surfaces the captured value as `result.structured`; a structured child that finished cleanly WITHOUT ever capturing settles `error` (a clean finish without the demanded result is a failure, not a success with a missing field). `SubagentService` waits for `run.started` before emitting `subagent/start`, so a synchronous start observer can resolve the published child with `ctx.agents.get(run.id)`; the result driver awaits the same boundary before sending the prompt. An attempt that never publishes rejects readiness and emits no false start/end pair; its result reports a deliberate cancel/dispose as `aborted` and propagates an infrastructure fault. `dispose()` awaits creation or rollback and then delegates to `AgentHandle.dispose()` (stop and drain → remove agent → detach session → unwind scope). Before readiness, `cancel()` deactivates the creation owner: before creation notification begins, no agent/session lifecycle edge escapes; if cancellation is triggered synchronously by a creation observer, every begun edge is paired by rollback and the driver never unlocks or starts. After readiness, cancellation reaches the live child immediately. Either path records the cancellation, so a cancel landing before any `turn/end` settles `aborted`, honoring the cancel contract rather than the generic no-turn `error`. +The child receives a fresh flat registration scope. Its `toolFilter` masks the live global tool layer and scope-local registrations are merged afterward; parent ownership does not import the parent's tool restrictions or establish an authority subset. The [agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals) owns that explicit non-goal. + ### `InProcessRunOptions` `{ seed?: SessionEvent[] }` — the optional child-session seed: absent for spawn, or the parent's balanced completed-turn prefix for fork. @@ -32,8 +34,8 @@ Runs a child as a child [`Agent`](../../core/agent) on the same cordis context ( ### `depthOf(agent): number` -Delegation depth rides on a merge-extensible `AgentOptions.subagentDepth` field (0 for a top-level agent, parent + 1 for a child), so a nested spawn reads its parent's depth from `parent.options.subagentDepth`. `depthOf` reads it (absent ⇒ 0). +Delegation depth rides on a merge-extensible `AgentOptions.subagentDepth` field (0 for a top-level agent, parent + 1 for a child), so a nested spawn reads its parent's depth from `parent.options.subagentDepth`. `depthOf` treats only `undefined` as absent (top-level depth 0) and rejects every malformed present value instead of letting it disable the cap comparison. ### `SubagentDepthError` -Thrown by `startInProcessRun` when a spawn would exceed the request's `maxDepth` cap; carries `attemptedDepth` and `maxDepth`. +Thrown by `startInProcessRun` when a spawn would exceed the request's defined `maxDepth` cap; carries `attemptedDepth` and `maxDepth`. A valid parent at `Number.MAX_SAFE_INTEGER` instead produces `RangeError`, because its child depth cannot be represented within the stored safe-integer domain even when `maxDepth` is omitted. diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index e0ad53c486..3ccb08fda9 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -58,7 +58,8 @@ declare module '@deepseek-ai/dsh-agent' { * @returns 0 for a top-level agent, its parent's depth + 1 for a subagent. */ export function depthOf(agent: Agent): number { - const depth = agent.options.subagentDepth ?? 0 + const depth = agent.options.subagentDepth + if (depth === undefined) return 0 if (!Number.isSafeInteger(depth) || depth < 0 || Object.is(depth, -0)) { throw new TypeError('agent subagentDepth must be a non-negative safe integer') } @@ -123,7 +124,8 @@ async function quiesceFiber(fiber: Fiber): Promise { * resolves `aborted`. * * Throws {@link SubagentDepthError} before creating anything when the child's - * depth (parent depth + 1) would exceed `request.maxDepth`. + * depth (parent depth + 1) would exceed `request.maxDepth`, and throws a + * `RangeError` when a valid parent depth has no safe-integer successor. * @param ctx - the provider context that owns the live run as a second * structured-concurrency boundary alongside the parent agent. * @param request - the start request (prompt, parent, signal, per-child options). @@ -147,6 +149,9 @@ export function startInProcessRun( const inputAgentOptions = request.agentOptions const inputSeed = options.seed assertSubagentMaxDepth(inputMaxDepth) + if (persona !== undefined && typeof persona !== 'string') { + throw new TypeError('subagent persona must be a string') + } const toolFilter = inputToolFilter === undefined ? undefined : snapshotJsonValue(inputToolFilter) if (inputToolFilter !== undefined && toolFilter === undefined) { throw new TypeError('subagent tool filter must be losslessly JSON-serializable') @@ -156,6 +161,9 @@ export function startInProcessRun( throw new TypeError('subagent seed must be losslessly JSON-serializable') } const childDepth = depthOf(parent) + 1 + if (!Number.isSafeInteger(childDepth)) { + throw new RangeError('subagent child depth exceeds the safe-integer range') + } if (inputMaxDepth !== undefined && childDepth > inputMaxDepth) { throw new SubagentDepthError(childDepth, inputMaxDepth) } diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 1e63617154..54ac968f4b 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -36,7 +36,7 @@ const SCHEMA: StructuredOutputSchema = { } /** - * Real loop + scripted mock model + an INLINE spawn-shaped provider over the + * Real loop + scripted mock model + an INLINE fresh-conversation provider over the * shared driver. The concrete backend plugins are deliberately NOT loaded — * they would devDep-cycle this package (spawn/fork already depend on the * driver), and the runtime under test is the driver's; plugin-level structured diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index db37c974b3..8f710237ab 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -48,6 +48,7 @@ describe('depthOf', () => { }) it.each([ + { label: 'null', value: null as unknown as number }, { label: 'a string', value: '1' as unknown as number }, { label: 'NaN', value: Number.NaN }, { label: 'positive infinity', value: Number.POSITIVE_INFINITY }, @@ -64,6 +65,7 @@ describe('depthOf', () => { describe('startInProcessRun', () => { it.each([ + { label: 'null', value: null as unknown as number }, { label: 'a string', value: '1' as unknown as number }, { label: 'NaN', value: Number.NaN }, { label: 'positive infinity', value: Number.POSITIVE_INFINITY }, @@ -81,6 +83,27 @@ describe('startInProcessRun', () => { }, {})).toThrow('subagent maxDepth must be a non-negative safe integer') }) + it('rejects a non-string persona before acquiring run ownership', async () => { + const { ctx, parent } = await setup([]) + expect(() => startInProcessRun(ctx, { + prompt: [{ type: 'text', text: 'must never start' }], + parent, + persona: 42 as unknown as string, + }, {})).toThrow('subagent persona must be a string') + }) + + it('rejects a child depth with no safe-integer representation before acquiring run ownership', async () => { + const { ctx } = await setup([]) + const parent = { + options: { subagentDepth: Number.MAX_SAFE_INTEGER }, + } as unknown as Agent + + expect(() => startInProcessRun(ctx, { + prompt: [{ type: 'text', text: 'must never start' }], + parent, + }, {})).toThrow(RangeError) + }) + it('rejects a non-JSON prompt before acquiring any run ownership', async () => { const { ctx, parent } = await setup([]) expect(() => startInProcessRun(ctx, { diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index e98d8cd8de..6470b64ee5 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -29,7 +29,7 @@ Unlike the bash seam (one executor per context, second load throws), **multiple - **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter/persona`) are a static `provider.capabilities` descriptor, checked by the service BEFORE a run exists. A request that needs one the provider lacks is **rejected loud** (`UNSUPPORTED_CAPABILITY`), never accepted-then-ignored. - **Runtime features** (steering, resume) are **optional methods** on `SubagentRun` (`sendMessage?`, `resume?`). The method's presence IS the capability; TS narrowing is the discovery mechanism — a consumer cannot call an absent method without narrowing first, so there is no silent degradation path. -Beside `capabilities` sits one DESCRIPTIVE fact: `provider.inheritsParentContext` — whether a child sees the parent conversation (`fork`: true — seeded with the completed-turn prefix; `spawn`/`acp`: false). The service validates that the descriptor is a boolean but does not interpret or enforce its meaning; the model-facing consumer (`dsh-tool-subagent`) derives truthful tool wording from it. +Beside `capabilities` sits one DESCRIPTIVE fact: `provider.inheritsParentContext` — whether a child sees the parent conversation (`fork`: true — seeded with the completed-turn prefix; `spawn`/`acp`: false). “Context” here means conversation history only; it says nothing about tool registrations, injected services, or authority inheritance. The service validates that the descriptor is a boolean but does not interpret or enforce its meaning; the model-facing consumer (`dsh-tool-subagent`) derives truthful tool wording from it. ## Run lifecycle diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 97efd4661f..42f55b300d 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -69,9 +69,10 @@ export interface SubagentStartRequest { */ outputSchema?: StructuredOutputSchema /** - * Optional recursion cap (max delegation depth below this child). Must be a - * non-negative safe integer. Requires {@link SubagentCapabilities.depthLimit}; - * rejected at start otherwise. + * Optional absolute delegation-depth cap for the child being started: its + * computed depth must be less than or equal to this non-negative safe + * integer. Requires {@link SubagentCapabilities.depthLimit}; rejected at + * start otherwise. */ maxDepth?: number /** @@ -195,13 +196,15 @@ export interface SubagentProvider { /** The start-time features this provider supports (see {@link SubagentCapabilities}). */ readonly capabilities: SubagentCapabilities /** - * The provider's context contract: `true` when a child SEES the parent + * The provider's conversation-history descriptor: `true` when a child SEES the parent * conversation (fork — the child is seeded with the parent's completed-turn * prefix), `false` when it starts fresh (spawn, ACP). A DESCRIPTIVE fact, * not a start-time capability: the service validates nothing against it — * the model-facing consumer (`dsh-tool-subagent`) derives truthful tool * wording from it, so a tool bound to a fork provider stops telling the - * model the child "does not see this conversation". + * model the child "does not see this conversation". This descriptor concerns + * conversation history only; it says nothing about tool registrations, + * injected services, or authority inheritance. */ readonly inheritsParentContext: boolean /** diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index faf4bb00b3..182ee7ac8b 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -226,7 +226,7 @@ describe('SubagentService', () => { patch: { capabilities: { ...NO_CAPS, persona: 'yes' } }, message: 'capability "persona" must be a boolean', }, - { label: 'a non-boolean context descriptor', patch: { inheritsParentContext: 'yes' }, message: 'inheritsParentContext must be a boolean' }, + { label: 'a non-boolean conversation-history descriptor', patch: { inheritsParentContext: 'yes' }, message: 'inheritsParentContext must be a boolean' }, { label: 'a non-callable start field', patch: { start: 42 }, message: 'start must be a function' }, ])('rejects a provider registration with $label before entering the registry', async ({ patch, message }) => { const ctx = new Context() @@ -431,6 +431,7 @@ describe('SubagentService', () => { }) it.each([ + { label: 'null', value: null as unknown as number }, { label: 'a string', value: '1' as unknown as number }, { label: 'NaN', value: Number.NaN }, { label: 'positive infinity', value: Number.POSITIVE_INFINITY }, diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 9aed426ac0..2163d4c884 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -6,9 +6,9 @@ The model-facing `subagent` tool: delegate a self-contained task to a child agen This plugin binds to **exactly one** provider (`Config.provider`). The model sees only `{ description, prompt }` — there is no provider/type parameter in the schema. To expose more than one transport, load the plugin more than once, each bound to a different provider **and a distinct `toolName`** (the tool registry rejects a duplicate name, so a second load that kept the default `subagent` name would throw). Keeping selection in config (not the schema) is the deliberate split: the *service* holds a multi-provider registry; the *tool* picks one. -## The description states the provider's context contract +## The description states the provider's conversation-history descriptor -The tool description and the `prompt` parameter description are DERIVED from the bound provider's `inheritsParentContext` (`providerWording`): a fresh-context provider (spawn, ACP) gets the standalone-prompt wording ("it does not see this conversation"), an inheriting provider (fork) tells the model the child already sees the conversation's completed turns and its prompt should state only what is new. Because the description is fixed at tool registration, the tool **mirrors the provider's lifecycle** (`subagent/provider-added`/`-removed`): it registers when the bound provider is (or becomes) available and unregisters when the provider goes away — no load-order requirement (the cordis Loader starts sibling entries concurrently, so "listed first" never guaranteed "registered first"), and an HMR reload of the backend re-derives the wording from the fresh provider. While the provider is absent the tool simply does not exist (a `ctx.logger` note records the wait; a typo'd provider name shows up as a tool that never materializes). +The tool description and the `prompt` parameter description are DERIVED from the bound provider's `inheritsParentContext` (`providerWording`): a fresh-conversation provider (spawn, ACP) gets the standalone-prompt wording ("it does not see this conversation"), while fork tells the model the child is seeded with the conversation's completed turns and its prompt should state only what is new. The descriptor concerns conversation history only, not tool or authority inheritance. Because the description is fixed at tool registration, the tool **mirrors the provider's lifecycle** (`subagent/provider-added`/`-removed`): it registers when the bound provider is (or becomes) available and unregisters when the provider goes away — no load-order requirement (the cordis Loader starts sibling plugins concurrently, so "listed first" never guaranteed "registered first"), and an HMR reload of the backend re-derives the wording from the fresh provider. While the provider is absent the tool simply does not exist (a `ctx.logger` note records the wait; a typo'd provider name shows up as a tool that never materializes). | Config key | Meaning | |---|---| @@ -17,7 +17,9 @@ The tool description and the `prompt` parameter description are DERIVED from the | `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; a non-negative safe integer validated when this plugin loads. Requires the provider's `depthLimit` capability. | +| `maxDepth` | Maximum absolute delegation-tree depth for every child this tool starts; a non-negative safe integer validated when this plugin loads. Requires the provider's `depthLimit` capability. | + +`toolFilter` uses [`ToolRegistry.restrict()`](../../core/tools/README.md)'s live global-view semantics and is not a parent-derived authority ceiling; see the [agent-scope security non-goal](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals). ## Lifecycle (synchronous collect) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index ca7f20d23d..25bd5e3acf 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -11,10 +11,12 @@ * — there is no provider/type parameter in the model-facing schema. The model * sees only `{ description, prompt }`. * - * The tool DESCRIPTION is derived from the bound provider's context contract - * ({@link providerWording}): a fresh-context provider (spawn, ACP) gets the - * standalone-prompt wording, an inheriting provider (fork) tells the model the - * child already sees the conversation's completed turns. The tool MIRRORS the + * The tool DESCRIPTION is derived from the bound provider's conversation-history + * descriptor ({@link providerWording}): a fresh-conversation provider (spawn, + * ACP) gets the standalone-prompt wording, while a seeded-conversation provider + * (fork) tells the model the child already sees the conversation's completed + * turns. This descriptor says nothing about Cordis scope, services, tools, or + * authority. The tool MIRRORS the * provider's lifecycle via `subagent/provider-added`/`-removed` — it registers * when the provider is (or becomes) available and unregisters when the * provider goes away — so no load-order requirement exists and an HMR reload @@ -154,16 +156,19 @@ function stopReasonError(result: SubagentResult): string | undefined { } /** - * Model-facing wording per context contract ({@link SubagentProvider.inheritsParentContext}). + * Model-facing wording from the provider's conversation-history descriptor + * ({@link SubagentProvider.inheritsParentContext}). * A fresh child needs a standalone prompt; a forked child already sees the * conversation's completed turns — telling the model to restate everything * (or, worse, that the child "does not see this conversation") would be false * for a fork. Exported for tests. - * @param inherits - the bound provider's context contract. + * @param inheritsConversation - whether the child's conversation is seeded + * with the parent's completed turns; this says nothing about tool, service, + * scope, or authority inheritance. * @returns the tool `description` and the `prompt` parameter description. */ -export function providerWording(inherits: boolean): { description: string; promptDescription: string } { - if (inherits) { +export function providerWording(inheritsConversation: boolean): { description: string; promptDescription: string } { + if (inheritsConversation) { return { description: 'Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all ' diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 4e125d24e1..701a4dc667 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -220,7 +220,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(SubagentService) - const backend = await ctx.plugin(mock, { name: 'mock' }) // spawn-shaped (inherits: false) + const backend = await ctx.plugin(mock, { name: 'mock' }) // fresh conversation (descriptor: false) await ctx.plugin(tool, { provider: 'mock' }) expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('does not see this conversation') @@ -228,7 +228,7 @@ describe('dsh-tool-subagent', () => { await backend.dispose() expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(false) - // Backend reloads with a DIFFERENT contract: the wording is re-derived + // Backend reloads with a DIFFERENT conversation-history descriptor: the wording is re-derived // from the fresh provider, not served stale from the first mount. await ctx.plugin(mock, { name: 'mock', inheritsParentContext: true }) expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('INHERITS this conversation') @@ -273,7 +273,7 @@ describe('dsh-tool-subagent', () => { expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(true) }) - it('derives spawn-shaped wording from a fresh-context provider (default mock)', async () => { + it('derives spawn-shaped wording from a fresh-conversation provider (default mock)', async () => { const ctx = await setup({ provider: 'mock' }) const schema = ctx.tools.schemas().find(s => s.name === 'subagent')! expect(schema.description).toContain('does not see this conversation') @@ -281,7 +281,7 @@ describe('dsh-tool-subagent', () => { expect(props['prompt']!.description).toContain('include everything it needs') }) - it('derives fork-shaped wording from an inheriting provider (the description stops lying)', async () => { + it('derives fork-shaped wording from a seeded-conversation provider (the description stops lying)', async () => { const ctx = await setup({ provider: 'mock', toolName: 'subagent' }, { inheritsParentContext: true }) const schema = ctx.tools.schemas().find(s => s.name === 'subagent')! expect(schema.description).toContain('INHERITS this conversation') @@ -494,6 +494,8 @@ describe('dsh-tool-subagent', () => { }) it.each([ + { label: 'null', value: null as unknown as number }, + { label: 'a string', value: '1' as unknown as number }, { label: 'NaN', value: Number.NaN }, { label: 'positive infinity', value: Number.POSITIVE_INFINITY }, { label: 'negative infinity', value: Number.NEGATIVE_INFINITY }, @@ -506,6 +508,16 @@ describe('dsh-tool-subagent', () => { .rejects.toThrow() }) + it('validates maxDepth when apply() is invoked directly without Schemastery', () => { + const ctx = new Context() + expect(() => { + tool.apply(ctx, { + provider: 'unused', + maxDepth: Number.NaN, + }) + }).toThrow('subagent maxDepth must be a non-negative safe integer') + }) + it('a partial toolFilter (deny only) does not materialize an empty allow-list (deny-all trap)', async () => { let seen: { toolFilter?: { allow?: string[]; deny?: string[] } } | undefined const ctx = new Context() diff --git a/packages/support/subagent-mock/README.md b/packages/support/subagent-mock/README.md index af169ed4c2..6114bdf2c9 100644 --- a/packages/support/subagent-mock/README.md +++ b/packages/support/subagent-mock/README.md @@ -14,7 +14,7 @@ Load it as a plugin (functional shape: `name`/`inject`/`Config`/`apply`, no defa | `reply` | `mock subagent reply` | The scripted child's final answer text. | | `stopReason` | `completed` | The stop reason `result` settles with. | | `capabilities` | all `true` | Which start-time capabilities (`outputSchema`/`depthLimit`/`toolFilter`) the provider advertises. | -| `inheritsParentContext` | `false` | The context contract to declare; `true` exercises the fork-shaped tool wording in consumer tests. | +| `inheritsParentContext` | `false` | Conversation-history descriptor: `false` means fresh, while `true` exercises seeded/fork wording. It says nothing about tool, service, scope, or authority inheritance. | | `structured` | `{ reply }` | Structured value surfaced when a request carries an `outputSchema` and the capability is on. | A `cancel()` issued before `result` settles flips the stop reason to `aborted`, so the cancellation path is observable. diff --git a/packages/support/subagent-mock/src/index.ts b/packages/support/subagent-mock/src/index.ts index 715c7451d7..554889db71 100644 --- a/packages/support/subagent-mock/src/index.ts +++ b/packages/support/subagent-mock/src/index.ts @@ -94,9 +94,11 @@ export interface Config { /** Which start-time capabilities to advertise (default: all `true`). */ capabilities?: Partial /** - * The context contract to declare ({@link SubagentProvider.inheritsParentContext}); - * default `false` (spawn-like). Set `true` to exercise the fork-shaped tool - * wording in consumer tests. + * The conversation-history descriptor to declare + * ({@link SubagentProvider.inheritsParentContext}); default `false` (fresh + * conversation). Set `true` to exercise seeded/fork wording in consumer + * tests. This flag says nothing about tool, service, scope, or authority + * inheritance. */ inheritsParentContext?: boolean /**