docs(rfc): align scoped runtime contracts

This commit is contained in:
Tianyi Cui
2026-07-12 22:49:46 +08:00
parent bb3f6bd736
commit ed5304fb6d
37 changed files with 640 additions and 1317 deletions

View File

@@ -4,30 +4,28 @@ Status: implemented
## Problem
One application needs to share infrastructure across many agents while giving each agent a coherent local world. Model adapters, persistence, user interfaces, and most tool implementations belong to the deployment; personas, visible tools, live policy, event listeners, and cleanup often belong to one agent.
One application needs to share infrastructure across many agents while letting each agent have its own tools, prompt contributions, policies, and listeners. Shared adapters, persistence, and user interfaces belong to the deployment; a persona, tool variant, or listener often belongs to one agent.
A separate service graph per agent duplicates shared infrastructure. One global registration graph has the opposite failure: an agent-specific tool, prompt section, restriction, or listener can leak into unrelated agents. Contributors need one way to compose local behavior without learning a different registration API for every service.
A separate service graph per agent duplicates shared infrastructure. One global registration graph has the opposite failure: an agent-specific contribution can leak into unrelated agents. Contributors need one ordinary registration mechanism that determines both who can see a contribution and when it is cleaned up.
The mechanism also needs a clear lifetime. An agent must not become visible before its local registrations exist, and final loop work must not lose those registrations before it settles. Parent-owned subagents make both failures easy to trigger because several differently configured agents can exist concurrently inside one application.
The mechanism also needs a publication boundary. An agent must not become visible before its local world is complete, and teardown must retain that world until final work has stopped.
## Decision
Every live agent owns one flat registration layer through `agent.ctx`. Code registers through the context that owns the contribution; scope-aware services resolve deployment-global registrations plus exactly one matching agent layer; scoped events route by the operation's real agent; and the layer is published and revoked with the agent lifecycle.
Every live agent owns one flat registration layer exposed as `agent.ctx`. Code registers through the context that owns a contribution; scope-aware services combine deployment-global registrations with exactly one matching agent layer; operations choose that layer from their real agent; and the layer exists for the agent's complete published lifetime.
A Cordis **context** is the object through which code accesses services and registers owned effects. The [Cordis primer](../../../cordis-primer.md) explains the framework beyond that concept.
Cordis is the plugin framework underneath the SDK. A Cordis **context** is the object plugins use to access services and register effects whose cleanup follows that context. The [Cordis primer](../../../cordis-primer.md) explains the framework in more detail.
The contract has four parts:
For most contributors, the complete contract is four rules:
| Contributor question | Contract |
| Question | Rule |
|---|---|
| Where do I register agent-local behavior? | Use the ordinary service API through `agent.ctx` |
| What does an agent see? | Deployment globals plus its own layer, with service-specific merge rules |
| Which scoped listeners run? | By default, unscoped listeners plus listeners for the operation's agent; an explicit global-listener exception is described below |
| How long does local behavior exist? | Assembled during unpublished setup, observable only after creation succeeds, and retained through quiescent teardown |
| Where do I register behavior for one agent? | Call the ordinary registration API through `agent.ctx` |
| What does an operation for an agent see? | Deployment globals plus that agent's layer, using the owning service's merge rules |
| Which scoped listeners run? | Unscoped listeners plus listeners registered for the operation's agent |
| How long does the layer exist? | Setup completes before publication; disposal keeps it until work reaches quiescence |
The scope is deliberately flat. Resolution never walks parent or sibling scopes. Parent ownership links lifetimes without importing registrations.
For scope-aware registries and default listener routing, the whole mechanism can be read from left to right: the registering context chooses a layer, while the agent named by an operation chooses which one local layer joins the deployment-global layer.
The scope is flat. Resolution never walks parent or sibling scopes, and lifetime ownership does not imply registration inheritance.
```mermaid
flowchart LR
@@ -35,41 +33,32 @@ flowchart LR
agentAContext["agentA.ctx<br/>cleanup follows Agent A"] -->|"registers into"| agentALayer["Agent A layer"]
agentBContext["agentB.ctx<br/>cleanup follows Agent B"] -->|"registers into"| agentBLayer["Agent B layer"]
operationA["Operation for Agent A"] -->|"selects"| agentAView["Agent A view<br/>eligible globals plus A local only"]
operationA["Operation for Agent A"] -->|"selects"| agentAView["Agent A view<br/>globals plus A local"]
globalLayer --> agentAView
agentALayer --> agentAView
operationB["Operation for Agent B"] -->|"selects"| agentBView["Agent B view<br/>eligible globals plus B local only"]
operationB["Operation for Agent B"] -->|"selects"| agentBView["Agent B view<br/>globals plus B local"]
globalLayer --> agentBView
agentBLayer --> agentBView
```
The missing cross-edges describe registry resolution and default listener routing: Agent A's registered values and ordinary scoped listeners do not enter Agent B's view, and a parent's layer does not enter a child's view merely because the parent owns the child's lifetime. For scope-filtered events, `{ global: true }` is the explicit opt-in exception; it can observe across scopes while cleanup still follows the registering agent. Registry-membership notifications are a separate unfiltered event class described below.
The missing cross-edges are the isolation rule: Agent A's local registrations do not enter Agent B's view, and a parent's registrations do not enter a child merely because the parent owns the child's lifetime.
The companion [runtime-design RFC](2026-07-12-agent-scope-runtime-design.md) explains how the implementation preserves this contract under Cordis dispatch, JavaScript mutation and reentrancy, asynchronous setup, rollback, and racing disposal.
The companion [runtime-design RFC](2026-07-12-agent-scope-runtime-design.md) explains the implementation and correctness reasoning. The [subagent composition-controls RFC](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) owns the separate `persona`, `toolFilter`, and `maxDepth` feature.
### Registration origin selects visibility and cleanup
### Registration origin chooses visibility and cleanup
A contribution made through a plain plugin context is deployment-global and is disposed with that plugin. The same method called through `agent.ctx` contributes only to that agent and is disposed with the agent scope.
A registration made through a plain plugin context is deployment-global and is disposed with that plugin. The same method called through `agent.ctx` contributes to one agent and is disposed with that agent's scope.
| Registration origin | Registration layer and default audience | Disposed with |
| Registration origin | Default visibility | Disposed with |
|---|---|---|
| Plain plugin context | Deployment-global; eligible for every agent view, subject to service merge and restriction rules | Registering plugin |
| `agent.ctx` | Agent-local; visible to that agent by default | Agent scope |
| Plain plugin context | Every eligible agent view | Registering plugin |
| `agent.ctx` | Exactly that agent's view | Agent scope |
This applies to tools, prompt sections and variables, restrictions, protections, guards, and scoped event listeners. Named scoped values ordinarily shadow same-named global values; an owning service may reserve a protected name and reject the shadow instead. Duplicate names within one layer fail. Event listeners have the explicit `{ global: true }` audience exception described below.
Tools, prompt sections and variables, tool restrictions, guards, and scoped event listeners adopt this contract. Named local values ordinarily shadow a same-named global value for that agent; each owning service documents exceptions and merge behavior.
The public pattern is ordinary registration inside `setup`:
The ordinary contributor pattern is to register the complete local world during agent setup:
```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'),
@@ -80,130 +69,108 @@ const handle = await ctx.agents.create({
order: 0,
text: 'Review code, but do not modify files.',
})
agentCtx.tools.restrict({ allow: ['read'] })
agentCtx.tools.register(reviewSummaryTool)
agentCtx.tools.register({
name: 'review_summary',
description: 'Return the review summary.',
parameters: { type: 'object', properties: {} },
async execute() {
return [{ type: 'text', text: 'review complete' }]
},
})
},
})
const reviewer = handle.agent
ctx.tools.get('read', reviewer) // global and allowed
ctx.tools.get('bash', reviewer) // undefined: filtered global
ctx.tools.get('review_summary') // undefined: not global
ctx.tools.get('review_summary', reviewer) // reviewer-local
ctx.tools.get('review_summary') // undefined: not global
ctx.tools.get('review_summary', handle.agent) // the reviewer-local tool
await handle.dispose()
ctx.tools.get('review_summary', reviewer) // undefined: scope is gone
ctx.tools.get('review_summary', handle.agent) // undefined: scope is gone
```
### The operation selects the view
Setup receives a full trusted Cordis context so it can compose ordinary plugins and services. Its contract is composition-only: driving or publishing the in-flight agent through casts or internal registry calls is unsupported.
Registration origin and operation subject are separate facts. Calling a read method through `agent.ctx` does not implicitly select that agent; lookup, execution, prompt assembly, and event dispatch still receive the agent or scope they act for.
### The operation chooses the view
For example, `agent.ctx.systemPrompt.assemble()` without an assembly scope requests the global view. `ctx.tools.get(name, agent)` and `ctx.tools.execute({ ..., agent })` select that agent's tool view explicitly. This lets one shared service act for any agent without binding the service instance itself to one scope.
Registration origin and operation subject are separate facts. Calling a service through `agent.ctx` selects where a new registration belongs; it does not bind later reads to that agent.
`agent.ctx.agent` is the associated agent for setup code, but it is not a general scope-selection shortcut. Contributors creating nested generic scopes use the nearest scope tag as the registration key; inheriting an `agent` property does not import the outer registration layer.
Tool lookup and execution receive the agent they act for. Prompt assembly receives an assembly context for the agent whose request is being built. Event dispatch receives its domain subject. This keeps shared service instances reusable across agents while making each operation's view explicit.
### Scoped events follow the operation's real subject
Only services that adopt the scope contract resolve an agent layer. `agent.ctx` does not automatically change arbitrary Cordis service calls.
By default, an event about agent A reaches unscoped listeners and A-scoped listeners, not B-scoped listeners; agent-less dispatch reaches only unscoped listeners. Product helpers and service-owned paths couple the routing key to the value the operation already owns—such as `ToolExecution.agent`, `ApprovalRequest.agent`, the prompt assembly scope, or the session's captured owner. Advanced code that constructs a low-level carrier or assembly context directly must keep its subject and scope fields aligned; development invariants detect mismatches, but the low-level types do not make every mismatch unrepresentable.
### Scoped events keep routing separate from event data
Cordis listeners have one explicit exception. `{ global: true }` bypasses contextual filtering, so a listener registered through `agent.ctx` can observe other agents and subjectless dispatches while its cleanup still belongs to that agent scope. Use it only for deliberate cross-scope observation.
An event about Agent A normally reaches unscoped listeners and A-scoped listeners, not B-scoped listeners. An event without an agent subject reaches only unscoped listeners.
Registry-membership notifications remain unfiltered because they describe shared registry state rather than an operation for one agent. The generated [event catalog](../../../cordis-catalog/events.md) is the exhaustive reference for event signatures and modes.
At the Cordis level, `Scoped<T>` is an opaque routing receiver. It carries the filter used to choose listeners but is not the domain object. Event signatures therefore keep the real `Agent`, tool execution, approval request, or other subject as an explicit argument that listeners can inspect.
### Creation publishes after setup; disposal revokes after work stops
A listener registered with `{ global: true }` deliberately bypasses contextual audience filtering while its cleanup still follows the registering context. Registry-membership notifications remain unfiltered because they describe shared registry state rather than one agent's operation. The generated [event catalog](../../../cordis-catalog/events.md) is the exhaustive event reference.
`ctx.agents.create()` and `resume()` construct an unpublished agent. Their optional `setup(agentCtx)` callback may await child-plugin activation and register the complete local world. During setup, neither the agent nor its session is visible in the public registries, and driving methods reject.
### Creation publishes last and disposal revokes last
The returned promise resolves only after setup, ordered lifecycle notification, and loop start succeed. Setup failure or owner loss rolls the unpublished world back and releases its IDs. A caller therefore never receives a handle to a partially configured agent.
`ctx.agents.create()` and `resume()` build an unpublished session, scope, agent, and driver. They await `setup`, admit the final session and agent entries, announce them in order, start the loop, and only then return a handle.
`AgentHandle.dispose()` performs the reverse boundary. It stops and drains the loop, preserves the session and scoped listeners through final events and flushes, detaches the agent and session, unwinds the scope, and releases IDs. Repeated or racing calls join the same completion promise.
An optional creation signal cancels work only while create or resume is pending. After the promise resolves, the returned `AgentHandle` owns explicit disposal.
The calling Cordis context and AgentLoop are structural co-owners. Unloading either disposes the agent, so creation through a short-lived plugin context intentionally gives the agent that shorter lifetime.
If loading, setup, admission, or publication fails, the private transaction rolls back everything it prepared. Concurrent operations using the same caller-supplied live ID may both reach setup, but final registry entry admits only one; every loser rejects and cleans its private resources. Sequential reuse after awaited disposal remains valid.
The lifecycle keeps the local layer private until setup succeeds and keeps it alive until final work has drained:
`AgentHandle.dispose()` reverses the boundary. It deactivates creation or driving, waits for synchronous publication to unwind, stops and drains the driver and final session flushes, detaches the agent and session, and finally disposes the scope. Repeated or racing disposal requests join one completion promise.
The calling Cordis context and the concrete AgentLoop factory are structural co-owners. Unloading either disposes the transaction or live agent.
```mermaid
flowchart TB
request["Create or resume"] --> reserve["Reserve agent and session IDs"]
reserve --> privateWorld["Load or build private session, scope, and driver"]
privateWorld --> setup["Await setup through agent.ctx"]
setup --> publish["Publish session and agent, then start the loop"]
publish --> live["Return the live handle"]
request["Create or resume"] --> privateWorld["Build private session, scope, agent, and driver"]
privateWorld --> setup["Await composition through agent.ctx"]
setup --> admission["Admit final session and agent entries"]
admission --> publish["Announce lifecycle and start the driver"]
publish --> live["Return AgentHandle"]
privateWorld -->|"load or preparation failure, or owner loss"| rollback["Rollback startup<br/>no handle escapes"]
setup -->|"setup failure or owner loss"| rollback
publish -->|"publication failure or owner loss"| rollback
live -->|"handle disposal, owner unload, or AgentLoop unload"| settle["Quiesce prepared or running work"]
rollback --> settle
settle --> detach["Detach any published agent, then session"]
detach --> revoke["Dispose any created agent scope"]
revoke --> release["Release acquired IDs"]
privateWorld -->|"failure, cancellation, or owner loss"| rollback["Rollback private work"]
setup -->|"failure, cancellation, or owner loss"| rollback
admission -->|"duplicate or owner loss"| rollback
publish -->|"listener failure or owner loss"| rollback
live -->|"handle or owner disposal"| quiesce["Stop and drain work"]
rollback --> quiesce
quiesce --> detach["Detach agent, then session"]
detach --> revoke["Dispose the agent scope"]
```
Contributors should put agent-local activation inside `setup` and always dispose the returned handle. Code that needs to observe a live agent waits for `create()`/`resume()` to resolve rather than polling the registries during setup.
### Subagent controls are an independent feature
## Tool restrictions resolve against a live flat view
In-process subagents consume agent scope by installing their local composition during unpublished setup. Their optional persona, live global-tool filter, and absolute depth cap are not intrinsic scope semantics; the [subagent composition-controls RFC](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) defines those controls, provider capability checks, and dynamic tool behavior.
A tool restriction filters the live deployment-global end-capability layer, after which scope-local tools are added. `allow` retains named globals, `deny` removes named globals, multiple restrictions intersect, and a hidden global tool is absent from both registry presentation and executable lookup.
`inheritsParentContext` describes conversation-history seeding only. It says nothing about Cordis scope, injected services, tools, or authority.
Filter presence is explicit: omitting a filter installs no restriction, `restrict({})` rejects, and `allow: []` deliberately hides every global end capability.
## Security and authority are non-goals
Because globals are live, allow- and deny-lists intentionally differ when a new global tool appears:
Agent scopes compose trusted same-process registrations. They do not sandbox plugins, define a parent-to-child authority lattice, freeze grants at creation, or guarantee that a child can do no more than its parent.
```text
at time 0:
global tools = { read, bash }
deny { bash } view = { read }
allow { read } view = { read }
A parent may own a child whose visible tools are wider than its own because lifetime ownership does not donate or cap registrations. A plugin holding a Cordis context also runs in the same process and can call available services directly.
after registering global tool web:
deny { bash } view = { read, web }
allow { read } view = { read }
```
Scope-local tools are merged after the filter. A local tool can therefore exist even when it is absent from an allow-list over globals. This is composition behavior, not an authorization promise.
Reserved Code Mode presentation is not part of the filterable end-capability layer. The [Code Mode RFC](../feature/2026-06-15-code-mode.md) owns the `run_code`, SDK, `toolOrder`, and presentation-versus-execution contracts; contributors changing Code Mode behavior follow that decision rather than inferring new authority semantics from agent scope.
## Security and authority are explicit non-goals
Agent scopes compose trusted in-process registrations. They do not sandbox plugins, define a parent-to-child authority lattice, freeze a creation-time grant set, or guarantee that a child can do no more than its parent. A plugin holding a Cordis context runs in the same process and can call the services injected into that context.
A parent can own a child whose visible tool set is wider than its own. For example, a parent restricted to global `read` can spawn a child with no restriction; the child then sees later global tools plus its own local registrations. The parent owns the child's lifetime but does not donate or cap the child's registration layer.
Deployments that need non-escalation require a separate authority representation, propagation rule, and execution check. Authority-versus-visibility ledgers, parent-subset grants, explicit future-grant APIs, and generic capability/output/termination tags are outside this decision.
## Subagents use the same composition rule
In-process subagents are a consumer of agent scope, not a second scoping model. A child gets a fresh flat layer during unpublished setup; its persona, tool filter, structured-output protocol, and listeners are ordinary registrations through the child's context. Parent teardown, backend teardown, and manual run disposal own the child lifetime without importing the parent's registrations.
`inheritsParentContext` describes conversation-history seeding only, not Cordis scope, service injection, tools, or authority. The [subagent capability RFC](../feature/2026-06-21-subagent-capability-seam.md) owns run usage and the provider contract, while the [runtime-design RFC](2026-07-12-agent-scope-runtime-design.md) explains in-process structured output and workflow race handling.
Deployments that need non-escalation require a separate authority representation, propagation rule, and execution check. Parent-subset grants, creation-time authorization snapshots, explicit future-grant APIs, and generic capability/output/termination tags are outside this decision.
## Alternatives considered
The rejected architectures either separate visibility from cleanup, scope only behavior but not registered data, duplicate shared infrastructure, or conflate parent ownership with registration inheritance.
The rejected designs either separate visibility from cleanup, cover only one registration family, duplicate shared infrastructure, or conflate lifetime ownership with inheritance.
### Pass an agent option to every registration
An API such as `tools.register(definition, { agent })` leaves global registration as the leak-by-omission default and repeats scope plumbing in every registry. It can also express visible to A, disposed with unrelated plugin B,” which registration through `agent.ctx` prevents.
An API such as `tools.register(definition, { agent })` repeats scope plumbing in every registry and permits visibility ownership to drift from cleanup ownership. Registering through `agent.ctx` makes both facts follow one Cordis effect owner.
### Filter events while keeping registries global
Listener filtering prevents a hook from intercepting the wrong agent but does not scope tool schemas, executable lookup, prompt sections, variables, or Code Mode bindings. Agent-local composition would still require temporary global mutation.
Listener filtering prevents the wrong hook from running but does not scope tool schemas, executable lookup, prompt sections, variables, or other registered data. Agent-local composition would still require temporary global mutation.
### Create one isolated service graph per agent
### Create one service graph per agent
Service isolation chooses one registry instance, while the desired view is deployment globals plus one agent layer. Per-agent graphs duplicate adapters and force shared persistence and UI infrastructure to discover every instance. Independent applications still deserve separate graphs; collaborating agents inside one deployment do not.
The required view is shared deployment services plus one local registration layer. Per-agent graphs duplicate adapters and complicate shared persistence, provider registries, and application boot.
### Inherit the parent's registrations into a child
### Inherit parent registration scopes
Hierarchical inheritance silently imports every parent-scoped tool and policy. Flat layers plus parent-owned disposal separate lifetime from composition: the parent owns the child without deciding the child's local world. This choice deliberately makes authorization a separate design.
Parentage describes lifetime and conversation lineage, not a universal merge policy. Hierarchical lookup makes unrelated services inherit accidentally and cannot define security without a separate authority model.
## Consequences
Contributors use the same registration methods at both deployment and agent scope; changing the calling context changes visibility and cleanup together. Model-visible tool lookup, execution, prompt assembly, policy, observation, and teardown agree on one agent key instead of maintaining parallel per-feature scope options.
Contributors use one familiar pattern: register shared behavior through a plugin context, register local behavior through `agent.ctx`, select the real agent on operations, and dispose the returned handle. Setup is atomic from an observer's perspective, and teardown preserves local behavior until work stops.
The cost is explicit subject selection on reads and dispatch, asynchronous programmatic creation, disciplined handle disposal, and awareness that flat registration scope is not authority. Registries retain service-specific merge behavior, and only services that adopt the scope contract become agent-scoped automatically.
The decision applies to tools, prompt state, scoped events, session lifecycle and scoped session events, approvals, and in-process subagent composition. Filesystem policy, LLM interception, background backend state, and other registries retain their own subject or policy mechanisms until their designs explicitly adopt agent scope.
The cost is explicit subject selection, asynchronous programmatic creation, and service-specific scope adoption. Flat registration scope is intentionally not authority, and subagent composition controls remain a separate feature rather than hidden scope semantics.

View File

@@ -24,11 +24,11 @@ Three decisions, each elaborated in its own section below:
`ToolRegistry` gains a schemastery-validated config (`static Config`), its first: `mode: 'native' | 'code' | 'both'`, default `'native'`. A deployment flips it from `cordis.yml` (`tools: { mode: code }`) — no code edit, per the no-hardcoded-tunables convention.
**Wire tool list = the registry's contribution.** The registry feeds assembly through a mode-aware provider: `'native'` contributes every capability visible to that assembly scope, `'code'` contributes only `run_code`, and `'both'` contributes both. Because [`PromptAssembly.tools` is the single source the loop's request header snapshots](../../../../packages/core/system-prompt/src/index.ts), the presentation is logged and reconstructable. The reserved transport is not a capability: it lives outside global/scoped registration and restriction layers, cannot be registered or shadowed, and cannot be named by `ctx.tools.restrict()`. `systemPrompt.protect()` restores its canonical schema after the complete assembly waterfall, so listeners cannot strip, replace, duplicate, or fabricate it. The mode governs only the registry's contribution; a deployment that deliberately installs another direct `systemPrompt.tools()` provider still owns that provider's schemas.
**Wire tool list = the registry's contribution.** The registry feeds assembly through a mode-aware provider: `'native'` contributes every capability visible to that assembly scope, `'code'` contributes only `run_code`, and `'both'` contributes both. Because [`PromptAssembly.tools` is the single source the loop's request header snapshots](../../../../packages/core/system-prompt/src/index.ts), the presentation is logged and reconstructable. The reserved transport is not a capability: it lives outside global/scoped registration and restriction layers, cannot be registered or shadowed, and cannot be named by `ctx.tools.restrict()`. Its `ToolDefinition` declares `ownerFinal: true`, so the provider reports the name as final and assembly restores its canonical schema or canonical absence after the waterfall. The mode governs only the registry's contribution; a deployment that deliberately installs another direct `systemPrompt.tools()` provider still owns that provider's schemas.
**Interaction with `toolOrder`, stated up front:** a configured `systemPrompt.toolOrder` naming native capabilities rejects every assembly under `mode: 'code'`, because those names are outside that mode's wire-validation universe. This is correct behavior, not a bug: a deployment using Code Mode updates its order config or drops it.
**The SDK prompt section.** Under `'code'` and `'both'` the registry registers one lazy prompt section (`tools:sdk`, in the 100199 tool-guidance order band) whose thunk regenerates, for each assembly scope, a TypeScript declaration of every visible end-capability tool plus fixed usage instructions. It uses the same visibility resolver as lookup and execution, so scoped grants and shadows appear while restricted globals disappear; the reserved `run_code` transport itself is excluded. The thunk emits tools in lexicographic name order, so an unchanged visible set produces byte-identical text, and `systemPrompt.protect()` restores the canonical section after every assembly listener. Because that protection is global, it also reserves the `tools:sdk` registry name against scoped section shadows; otherwise scoped-over-global resolution could make a later shadow look canonical before restoration.
**The SDK prompt section.** Under `'code'` and `'both'` the registry registers one lazy prompt section (`tools:sdk`, in the 100199 tool-guidance order band) whose thunk regenerates, for each assembly scope, a TypeScript declaration of every visible end-capability tool plus fixed usage instructions. It uses the same visibility resolver as lookup and execution, so scoped grants and shadows appear while restricted globals disappear; the reserved `run_code` transport itself is excluded. The thunk emits tools in lexicographic name order, so an unchanged visible set produces byte-identical text. The section declares `ownerFinal: true`, which restores its canonical contribution after every assembly listener and reserves the global name against scoped shadows.
**Codegen.** A pure `jsonSchemaToTs(schema)` module inside `dsh-tools` (sibling of `json-schema.ts``schemas()` and the SDK are two projections of the same store) maps the JSON-Schema subset the `defineTool` DSL emits (object/string/number/boolean/array, `properties`, `required`, string `enum` → literal union, nested objects, array `items`, `description` → JSDoc) to a TS type literal. It is **total**: any construct outside that subset (`$ref`, `oneOf`/`anyOf`, `integer`, future MCP shapes, …) degrades to `unknown` without throwing. Because `ToolSchema.name` is an arbitrary string, the SDK is declared as one object constant — `declare const tools: { "some-mcp-tool"(args: …): Promise<string>; bash(args: …): Promise<string>; … }` — quoted keys make every name reachable with no sanitization or alias-collision logic. Typing is advisory (the runtime executes type-stripped JS); the instructions say so.

View File

@@ -35,9 +35,9 @@ A new package group `packages/subagent/`:
| `@deepseek-ai/dsh-subagent-mock` | support: a scripted provider for testing the seam through the real load path |
| `@deepseek-ai/dsh-tool-subagent` | consumer: the model-facing `subagent` tool over `ctx.subagents` |
### The primitive: `start → SubagentRun`
### The primitive: async `start → SubagentRun`
A provider exposes `start(request) → SubagentRun`. The run carries `started` (the provider's publication/readiness promise), `result` (the terminal `SubagentResult`), `cancel()`, and `dispose()`. The transport-neutral verb is **`start`**; "spawn" is reserved for the in-process `dsh-subagent-spawn` backend's identity, not the service verb. The service's `start(name, request)` resolves the named provider, validates capabilities, delegates, and waits for `started` before emitting the paired `subagent/start` / `subagent/end`; an attempt that never establishes a child emits neither lifecycle event. For an in-process backend, readiness means the child is published in `ctx.agents`; for ACP it means the remote session exists.
A provider exposes `start(request) → Promise<SubagentRun>`. Promise fulfillment is the publication/readiness and provider-to-caller ownership boundary: for an in-process backend the child is already published in `ctx.agents`, and for ACP the remote session already exists. `SubagentStartRequest.signal` is the single cancellation channel before and after readiness; `SubagentRun` carries the terminal `result` and a `dispose()` method that cancels remaining work and awaits quiescence. The transport-neutral verb is **`start`**; "spawn" is reserved for the in-process `dsh-subagent-spawn` backend's identity, not the service verb. A rejected start cleans provider-owned partial resources and emits neither subagent lifecycle event.
### Two kinds of optional capability, discovered two ways
@@ -54,7 +54,7 @@ Each subagent runs in its **own `Session`** (own id, `parentSession` lineage), p
### Synchronous collect (first cut)
The `dsh-tool-subagent` consumer awaits `run.result` and returns the child's final output as the tool result, blocking the parent's turn until the child finishes. It does so inside a `try/finally` that always `dispose()`s the run (no leaked idle child/session on any path), bridges `exec.signal` to `run.cancel()`, and maps a non-`completed` stop reason to an `isError` result rather than returning partial output as success. Steering (`sendMessage`) is part of the contract but **intentionally unused** this cut.
The `dsh-tool-subagent` consumer passes its execution signal into the start request, awaits the ready run's `result`, and returns the child's final output as the tool result, blocking the parent's turn until the child finishes. A `try/finally` always `dispose()`s the run, so no success, failure, or cancellation path leaks an idle child/session. A non-`completed` stop reason maps to an `isError` result rather than returning partial output as success. Steering (`sendMessage`) is part of the contract but intentionally unused in this consumer.
### Provider selection is config, not model-facing
@@ -66,7 +66,7 @@ The seam is tested through the real cordis Loader / export path, not a hand-buil
## Consequences
- **Recursion.** Without a guard, an in-process child inherits the spawn tool and can spawn unboundedly. Depth-limit is an optional capability (the in-process backends enforce it; ACP advertises it off and rejects a `maxDepth` request); tool-filtering is likewise optional. Tool-filtering, when implemented, needs a `tools/pre-execute` deny in the child context — schema filtering alone is insufficient because a model can hallucinate a denied tool name.
- **Recursion.** Without a bound, an in-process child can see the delegation tool and recurse. The in-process backends implement the optional absolute depth limit and scoped live-global `toolFilter`; ACP advertises both capabilities off and rejects such a request. The [subagent composition-controls RFC](2026-07-12-subagent-persona-tool-filter-and-depth.md) owns their exact semantics and security limits.
- **Blocking the parent turn.** Synchronous collect holds the parent's `runStep` open for the child's full duration. This is acceptable for the first cut; **background / poll / spill semantics are deferred to a future redesign that unifies long-running-tool handling across subagents AND bash** (a sub-agent and a long `bash` background task pose the same "the model started something slow, how does it collect later" problem, and should share one mechanism rather than each inventing its own).
- **Live progress.** This cut surfaces only lifecycle + final result; a per-chunk child→parent update stream is deferred with the background redesign.
- **ACP client surface.** Proxying `fs`/`terminal` from the ACP child back to the parent (a shared-workspace mode) is future work; the first cut advertises neither, so the child self-serves in its own process.

View File

@@ -34,7 +34,7 @@ The child is a separate process, so it inherits an environment. Credential-shape
Designed at every tier the backend touches, per the root AGENTS.md rule that a new capability shape names its coverage at every tier at plan time:
- **Keyless unit/integration** (`subagent-acp.spec.ts`): spawns a scripted mock ACP server subprocess (`tests/mock-acp-server.ts`) and drives it through the real backend over real ACP stdio. Covers: the prompt round-trip + output accumulation; every StopReason mapping; cancellation via `run.cancel()` and via the request signal; the already-aborted-before-start case; the cancel-races-ahead-of-newSession case; a torn-pipe-after-cancel (child crashes on cancel) settling `aborted`; permission auto-answer under both policies (including the allow-policy-no-allow-option fallback); a non-message update consumed but not accumulated; a nonexistent-command spawn failure settling `error`; HMR provider cleanup; and the namespace export shape. 100% per-file coverage.
- **Keyless unit/integration** (`subagent-acp.spec.ts`): spawns a scripted mock ACP server subprocess (`tests/mock-acp-server.ts`) and drives it through the real backend over real ACP stdio. Coverage includes the prompt round-trip and output accumulation; every StopReason mapping; cancellation through the required request signal and through disposal; already-aborted and cancel-races-ahead-of-newSession starts; a torn pipe after cancellation settling `aborted`; permission auto-answer under both policies; non-message updates; nonexistent-command startup failure with process reaping; provider HMR; and the namespace export shape.
- **With-key e2e** (`subagent-acp.e2e.ts`): the harness drives ITSELF — the backend spawns the real `acp-agent` example process and a real model in that child answers a prompt (PONG) and does real file work (writes `proof.txt`, verified on disk). Self-skips without `DEEPSEEK_API_KEY`. This is the "talk to our own process" smoke and the out-of-process analogue of the in-process spawn e2e.
- **Snapshot**: deferred as `TODO(acp-subagent-replay)`. An ACP child is a distinct replay shape — each child is its own PROCESS with its own single-agent replay (booted under `DSH_SNAPSHOT=replay` with its own sessions-root + fixture), unlike the in-process per-session keying that [the per-session replay RFC](../testing/2026-06-22-subagent-snapshot-replay.md) added. The keyless mock-server tests give deterministic coverage of the backend in the meantime; the snapshot follow-up would record the parent driving a real-but-replayed ACP child.

View File

@@ -6,13 +6,13 @@ Status: implemented
The hooks subsystem ([interception seams RFC](2026-06-30-interception-seams.md)) lets a plugin observe and gate the agent at lifecycle points. Claude Code and Codex both expose **SubagentStart / SubagentStop** hooks, and CC's carry the subagent's final message. The harness already emits `subagent/start` and `subagent/end` lifecycle events ([the subagent capability-seam](2026-06-21-subagent-capability-seam.md)), but their payloads were minimal (`provider`, `id`, and on end `stopReason`) — not enough for a hooks bridge to report WHAT a subagent produced without separately reaching for the live run.
This RFC enriches the end payload. It is deliberately **observe-only**: no control-flow change, no waterfall, no `start()` restructure. A run-affecting subagent-stop decision (continuation, injection that changes the run) is a separate, larger redesign and stays out of scope.
This RFC enriches the end payload. It is deliberately **observe-only**: no control-flow change and no waterfall. A run-affecting subagent-stop decision (continuation, injection that changes the run) is a separate, larger redesign and stays out of scope.
## Decision
**Add `lastAssistantMessage` — the child's final output — to `SubagentRunEndInfo`.** On the settle path it is a DEEP CLONE of `SubagentResult.output` (so an observer sees WHAT the subagent produced without holding the run). On the REJECT path (an infrastructure fault where no `SubagentResult` was produced — the seam only knows `stopReason: 'error'`) it is absent. The clone is load-bearing for observe-only: the `subagent/end` emit fires from a detached `.then` registered *before* `start()` returns, i.e. before the caller's own `await run.result` continuation — handing listeners the same array reference would let a mutating listener corrupt the caller's `SubagentResult.output`. `structuredClone` makes the event a read-only view (a regression test mutates the event's array and asserts the caller's result is untouched); a clone failure is contained (logged, the event still fires without `lastAssistantMessage`) rather than becoming an unhandled rejection on the detached `.then`.
**Add `lastAssistantMessage` — the child's final output — to `SubagentRunEndInfo`.** On the settle path it is the readonly typed `SubagentResult.output`, so an observer sees what the child produced without holding the run. On an infrastructure rejection where no `SubagentResult` exists, it is absent and the event reports `stopReason: 'error'`. Providers and listeners are trusted same-process collaborators and honor the borrowed immutable payload contract.
Both events stay plain **`emit`s**. The service waits for `run.started` before firing `subagent/start`; an in-process listener can therefore reach the published child via `ctx.agents.get(info.id)` and `inject()` into it, while a remote provider need not have a local registry entry. It observes `run.result` immediately, snapshots the end payload before the caller can mutate it, and emits `subagent/end` only after start; readiness rejection emits neither event. The callbacks remain observe-only and per-listener containment keeps one bad subscriber from stranding a live run, surfacing as an unhandled rejection, or starving later listeners.
Both events stay plain **`emit`s**. Async `SubagentService.start()` attaches result observation to the ready provider run, emits `subagent/start`, and then returns the run; an in-process listener can therefore reach the published child via `ctx.agents.get(info.id)`, while a remote provider need not have a local registry entry. A rejected provider start emits neither event. The callbacks remain observe-only and per-listener containment keeps one bad subscriber from stranding a live run or starving later listeners.
## Alternatives considered

View File

@@ -26,7 +26,7 @@ One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferre
**Why node:worker_threads**: one run uses one unpooled worker because a workflow run is already heavyweight relative to thread startup. The script runs in a vm context inside the worker, keeping the script-visible surface to the hook contract instead of exposing a bare worker realm, while `agent()` bridges by message-port RPC to I/O-bound child loops on the host. This keeps `start()` from blocking the host on the script's synchronous slice, makes the post-cancel deadline end in a real `worker.terminate()`, and gives cross-thread values a serialization boundary by construction. isolated-vm was rejected for its maintenance state, required `--no-node-snapshot` consumer flag on Node ≥ 20, and node-gyp fallback.
Host-side meta validation and body pre-parsing preserve the seam's synchronous errors, and private enum-keyed payload maps define the wire protocol. Readiness admission, the two child-cancellation channels, worker-death reaping, result precedence, and disposal quiescence preserve the subagent run contract across that wire; the [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#workflow-integration-preserves-the-subagent-contract) owns those race algorithms. Coverage uses an in-process `MessageChannel` for worker-side logic that main-process V8 coverage cannot see and separately proves the built `lib/worker.js`—a second tsdown entry sanctioned by the `"./worker"` subpath export—under plain Node in the built-bin smoke gate.
Host-side meta validation and body pre-parsing preserve the seam's synchronous errors, and private enum-keyed payload maps define the wire protocol. Pending async starts, published child records, one host cancellation signal, worker-death reaping, result precedence, and disposal quiescence preserve the subagent run contract across that wire; the [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#workflow-children-are-pending-starts-or-published-records) owns those race algorithms. Coverage uses an in-process `MessageChannel` for worker-side logic that main-process V8 coverage cannot see and separately proves the built `lib/worker.js`—a second tsdown entry sanctioned by the `"./worker"` subpath export—under plain Node in the built-bin smoke gate.
**Meta as data, never evaluated**: the meta block reaches the seam as a plain JSON request field (the tool's schema-validated `meta` parameter) and the engine only shape-validates it, every violation named. This is a host-isolation invariant, not a convenience: evaluating a meta literal host-side — even one contractually "pure", in an empty timed vm context — hands script-controlled getters a host stack with no timeout the moment the result is READ, defeating the exact spin isolation the worker thread buys.
@@ -42,7 +42,7 @@ A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, awai
An output schema makes a schema-valid committed capture mandatory for successful child completion. The scoped runtime preserves the canonical capture tool and instruction, commits only a successful final outcome—including the enclosing `run_code` outcome for an SDK call—denies later side effects after capture becomes pending, and stops the child without another model step after commit. A validation failure remains a retryable tool error; clean completion without a committed capture settles as an error.
`StructuredOutputSchema` is the raw enforceable JSON-Schema subset in `dsh-tools` (single-string `type`, `properties`/`required`/`additionalProperties`, `items`, scalar `enum`/`const`), and unsupported keywords fail loudly because that wire data becomes the capture tool's parameters verbatim. The [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#structured-output-is-a-child-owned-terminal-protocol) owns the assembly, commit, guard, and terminal-stop correctness algorithms.
`StructuredOutputSchema` is the raw enforceable JSON-Schema subset in `dsh-tools` (single-string `type`, `properties`/`required`/`additionalProperties`, `items`, scalar `enum`/`const`), and unsupported keywords fail loudly because that wire data becomes the capture tool's parameters verbatim. The [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#structured-output-commits-only-authoritative-outcomes) owns the assembly, commit, guard, and terminal-stop correctness algorithms.
## Deferred (documented non-goals of this cut)

View File

@@ -49,11 +49,11 @@ The `escalation-rejected` twin ends in `{"outcome": "rejected"}` instead: nothin
#### The seam: mechanism and policy split
After request validation and a successful `approval/asked` append, the answerer phase always resolves to a closed `ApprovalOutcome``allowed-once` / `rejected` / `cancelled` / `unavailable`. The service synchronously snapshots and shallow-freezes the accepted request before its first asynchronous boundary: scalar fields are copied while the agent and `AbortSignal` remain exact identity capabilities, so later caller mutation cannot redirect scope, payload, cancellation, or either audit event. The service dispatches the `approval/request` waterfall, races the captured signal (abort settles `cancelled`; a late answer is discarded, never double-audited), contains a throwing answerer as `unavailable`, normalizes a rogue non-vocabulary return to `unavailable`, and lands the log-only audit pair `approval/asked`/`approval/decided` (paired by the branded `ApprovalRequestId`) on the captured agent's captured session log. Request acceptance and either pre-commit audit append may still reject; returning a decision that could not be logged would violate the pair. Session owns post-commit observer containment, so a callback failure cannot turn an authoritative audit append into a rejected request or suppress the matching event. Grants are one-shot by definition: `allowed-once` authorizes the single asked-about action, never a class of future ones, and the service stores nothing between requests. `request()` also throws before appending anything when the agent's session has no open turn — the audit pair must be turn-enclosed, the turn being the durable log's commit/replay boundary (a bare event between turns is dropped as crash tail on reload); every ask path runs mid-turn already, and idle asks are a deferred design.
After request validation and a successful `approval/asked` append, the answerer phase always resolves to a closed `ApprovalOutcome``allowed-once` / `rejected` / `cancelled` / `unavailable`. `ApprovalRequest` is a readonly same-process contract, so the service borrows its routing identity and cancellation signal instead of copying the record or capturing a parallel callback bundle. It dispatches the `approval/request` waterfall, races the request signal (abort settles `cancelled`; a late answer is discarded, never double-audited), contains a throwing answerer as `unavailable`, normalizes a rogue non-vocabulary return to `unavailable`, and lands the log-only audit pair `approval/asked`/`approval/decided` (paired by the branded `ApprovalRequestId`) on the request agent's session log. Request acceptance and either pre-commit audit append may still reject; returning a decision that could not be logged would violate the pair. Session owns post-commit observer containment, so a callback failure cannot turn an authoritative audit append into a rejected request or suppress the matching event. Grants are one-shot by definition: `allowed-once` authorizes the single asked-about action, never a class of future ones, and the service stores nothing between requests. `request()` also throws before appending anything when the agent's session has no open turn — the audit pair must be turn-enclosed, the turn being the durable log's commit/replay boundary (a bare event between turns is dropped as crash tail on reload); every ask path runs mid-turn already, and idle asks are a deferred design.
Answerers are the policy, and they are `approval/request` waterfall listeners. The waterfall buys exactly what the seam needs: with zero listeners the dispatch falls through to the caller-supplied default — `unavailable`, so fail-closed needs no configuration and no code in any deployment; a listener that recognizes the request's agent answers by returning an outcome without calling `next()` (the decision slot is single-occupancy, first answer wins — the same documented semantics as the `fs/write-intent` gate); a listener that does not recognize the agent MUST delegate via `next()` so another answerer or the default gets the question; and listeners dispose with their owning fiber, so an unloaded UI plugin degrades the next ask to `unavailable` instead of leaving a dangling channel. Registration order across sibling plugins is not load-order deterministic (the loader starts siblings concurrently), so a deployment composes ONE terminal answerer and reserves `prepend` listeners for decide-or-delegate gates.
`ApprovalRequest` carries the asking `agent` (routes the question; receives the audit events), the `toolName`, the optional exact `callId`, the asker's human-readable `reason`, and the optional `signal`. The caller owns this input record; `request()` owns its frozen acceptance snapshot. The vocabulary is deliberately self-contained — it names the tool-call by the `CallId` brand from `dsh-llm` and never imports `dsh-tools` — because `dsh-tools` depends on `dsh-user-approval` (the ask routing) and a `ToolCallView` import would close a package cycle. It deliberately does NOT carry tool arguments: a UI answerer attaches the prompt to the already-streamed tool call via `callId` instead of re-rendering the call.
`ApprovalRequest` carries the asking `agent` (routes the question; receives the audit events), the `toolName`, the optional exact `callId`, the asker's human-readable `reason`, and the optional `signal`. The caller retains ownership and honors the readonly contract for the duration of `request()`. The vocabulary is deliberately self-contained — it names the tool-call by the `CallId` brand from `dsh-llm` and never imports `dsh-tools` — because `dsh-tools` depends on `dsh-user-approval` (the ask routing) and a `ToolCallView` import would close a package cycle. It deliberately does NOT carry tool arguments: a UI answerer attaches the prompt to the already-streamed tool call via `callId` instead of re-rendering the call.
#### Ask routing in dsh-tools
@@ -79,7 +79,7 @@ One package, no cycles: `dsh-user-approval` peers on `cordis`, `dsh-session` (ev
### Testing
Unit tier: the service's outcome branches (fail-closed default, first-wins slot, delegation, containment, rogue-value normalization, abort-before and abort-during with late-answer discard, fresh ids, fiber-disposal degradation), accepted-request mutation across agent scopes, post-append observer throws on both audit events, and the policy tier (both values × dispatch/decide, a `'never'` decision unbypassable even by an answerer prepended AFTER the service, audit pair intact) in `dsh-user-approval`; the ask routing matrix (grant dispatches; three non-grant reasons pinned verbatim; unmounted and agent-less degrades; the registry's own exhaustiveness backstop against a non-conforming stand-in) in `dsh-tools`; the answerer (wire shape of the prompt, outcome mapping, unknown-option conservatism, foreign-agent and call-less delegation) driven through a real bridge + scripted client in `dsh-acp`.
Unit tier: the service's outcome branches (fail-closed default, first-wins slot, delegation, containment, rogue-value normalization, abort-before and abort-during with late-answer discard, fresh ids, fiber-disposal degradation), scoped routing, post-append observer throws on both audit events, and the policy tier (both values × dispatch/decide, a `'never'` decision unbypassable even by an answerer prepended AFTER the service, audit pair intact) in `dsh-user-approval`; the ask routing matrix (grant dispatches; three non-grant reasons pinned verbatim; unmounted and agent-less degrades; the registry's own exhaustiveness backstop against a non-conforming stand-in) in `dsh-tools`; the answerer (wire shape of the prompt, outcome mapping, unknown-option conservatism, foreign-agent and call-less delegation) driven through a real bridge + scripted client in `dsh-acp`.
Snapshot tier: the harness accepts scripted permission answers (`permissionAnswers` in a scenario's `input.json`, consumed FIFO; an unscripted prompt answers `cancelled`, fail closed). The seam's wire is recorded end to end in the sandbox example's suite: both escalation branches drive `session/request_permission` through this seam over scripted answers (grant and rejection), and the recorded `mode-switching` scenario pins the `'never'` prompt sentence and the policy-switch notice ([the sandbox RFC](2026-07-06-sandbox.md) § Testing).
@@ -105,7 +105,7 @@ The implemented contract is pinned by the suites in Testing:
- With an ApprovalService and an answerer composed, a hook's `ask` reaches a human and `allowed-once` dispatches the tool; every other outcome denies with its distinct reason.
- A `'never'` session auto-rejects every ask without prompting anyone, states the policy in its prompt, and narrates switches (the shared switching mechanics are pinned in [the sandbox RFC](2026-07-06-sandbox.md)).
- Every unanswerable path fails closed to `unavailable`: no service, no listener, a foreign or agent-less request, a throwing answerer, a rogue return value, or a dead client connection.
- Every `request()` snapshots its routing identity and lands exactly one `approval/asked`/`approval/decided` pair on that agent's captured log, replayable and invisible to the model transcript; post-append observer failures cannot split the pair.
- Every `request()` routes through its readonly agent identity and lands exactly one `approval/asked`/`approval/decided` pair on that agent's log, replayable and invisible to the model transcript; post-append observer failures cannot split the pair.
- Prompts route per-session through the bridge's ownership map; one session's prompt can never reach another session's editor.
- A deployment with no ApprovalService emits no approval prompt or approval audit events and denies every `ask` request.

View File

@@ -41,7 +41,7 @@ Resolution follows these rules:
3. Child-scoped tools are added after global filtering and may shadow an admitted global tool.
4. Reserved `run_code` presentation and other scope-local protocol contributions are outside the global filter.
Configuration fails loudly when a filter is empty or names a tool that is unknown, scope-local, or reserved at setup time. This catches misspellings and prevents configuration from appearing effective when it cannot affect the named entry.
Configuration fails loudly when a filter supplies neither `allow` nor `deny`, or names something outside the current global restrictable set, including a scope-local-only or reserved name. `allow: []` is valid and deliberately hides every global tool. These checks catch misspellings and prevent configuration from appearing effective when it cannot affect the named entry.
The global registry remains live. A deny-only filter admits a later global name unless it explicitly denies that name; an allow-list excludes a later global name unless it explicitly allows that name. Removing a global tool removes it from every resolved view. These semantics preserve hot registration while making the difference between allow and deny explicit.