diff --git a/.agents/skills/dsh-find-simplifications/SKILL.md b/.agents/skills/dsh-find-simplifications/SKILL.md index 8fad167a60..59dde3df0d 100644 --- a/.agents/skills/dsh-find-simplifications/SKILL.md +++ b/.agents/skills/dsh-find-simplifications/SKILL.md @@ -41,6 +41,14 @@ Use parallel subagents when the user asks for breadth or many candidates. Give e If subagents are unavailable, simulate the same breadth yourself. Do not let the first good candidate stop the survey. +Start with the largest production-code deltas. A broad simplification audit that stops after obvious unused symbols can miss the files where duplicated lifecycle or defensive machinery carries most of the cost. + +## Audit Trust And Lifecycle Boundaries + +Classify every defensive copy, freeze, validator, and callback capture by the boundary it crosses. Same-process typed service/plugin calls ordinarily borrow readonly values; parser/config, queue, model/tool JSON, durable/file, worker, process, and wire boundaries own or validate data. Tests built around hostile getters, fake typed objects, callback replacement, or mutation after a same-process handoff are evidence of a potentially speculative contract, not automatic justification for keeping it. + +For complex asynchronous code, draw the ownership graph and map each sentinel, readiness promise, cancellation path, disposer, and state flag to a distinct owner or transition. When several mechanisms mirror the same liveness or settlement fact, propose one transaction or lifecycle controller instead. Preserve separate machinery where it protects a real boundary: synchronous publication and rollback, callback containment, first-terminal-outcome arbitration, worker/process ownership, or dispose-to-quiescence. + ## Prove Or Reject Each Candidate For every symbol or behavior, classify consumers before writing: @@ -60,7 +68,7 @@ Reject or downgrade a candidate when: ## Write The RFC -Create one file per durable proposal under `docs/rfc/proposed/yyyy-mm-dd-topic.md` and add it to the Proposed table in `docs/rfc/README.md`. Keep prose paragraphs on one physical line and use relative Markdown links. +Create one file per durable proposal under `docs/rfc///yyyy-mm-dd-topic.md`, following the lifecycle/classification contract in `docs/rfc/README.md`. Regenerate `docs/rfc/INDEX.md`; never add a manual RFC table to the README. Keep prose paragraphs on one physical line and use relative Markdown links. Prefer this shape, adjusting when the idea needs it: diff --git a/docs/architecture.md b/docs/architecture.md index 15a1142ac3..0976277aaf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -93,7 +93,7 @@ forever: checkpoint persistence and notify idle/running status ``` -Prompt assembly is single-path: `renderPrompt(assemble({ agent }))` IS the system prompt sent to the model. Plugins contribute ordered sections (static or computed from the per-call `AssembleContext`), tool schemas, and named variables interpolated as `{{name}}` at render — strictly, so an unknown or valueless reference fails the turn instead of shipping a hole. `dsh-system-prompt` owns the openers — the static `harness:identity` section (order −100) and the deployment's persona (order 0, its `persona` config, shared context-wide) — while the shipped loop registers the `model`/`cwd` variables; prompt-fact ownership is pinned by the [prompt-variables RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). +Prompt assembly is single-path: the loop sends `renderPrompt(await assemble(assembleContextFor(agent)))`; the helper couples the explicit agent and scope. Plugins contribute ordered sections, tool schemas, and named variables interpolated as `{{name}}` at render — strictly, so an unknown or valueless reference fails the turn instead of shipping a hole. `dsh-system-prompt` owns the openers — the static `harness:identity` section (order −100) and the deployment's persona (order 0, its `persona` config, shared context-wide) — while the loop registers the `model`/`cwd` variables; prompt-fact ownership is pinned by the [prompt-variables RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). Post-tool context lands after all tool results so tool-call/result adjacency stays stable. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved. @@ -109,7 +109,7 @@ Every session event is turn-enclosed. Reloading a crashed session preserves the ### Agent Scope -Every live agent owns a scope context, `agent.ctx` ([`dsh-scope`](../packages/core/scope/README.md), key = the agent). Registrations through it — tools, prompt sections/variables, listeners, `tools.restrict()` masks — are visible to that agent alone, SHADOW same-named global contributions for it (per-agent personas and tool variants), and unwind with the agent; an `agent.ctx` listener hears only that agent's dispatches, while events about one agent dispatch with its scope carrier. `CreateAgentOptions.setup(agentCtx)` composes a child's scoped world at creation (the subagent seam's `persona`/`toolFilter`) — setup registers, never drives. Dev invariants enforce carrier/subject identity; `verify-scoped-dispatch` pins enforced ⇔ documented. Rationale: [agent-scope RFC](rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md). +Every live agent owns `agent.ctx` ([`dsh-scope`](../packages/core/scope/README.md), keyed by the agent). Its registrations are visible only to that agent, shadow same-named globals, and unwind with it. Its listeners hear only that agent's dispatches; an opaque carrier routes while the real subject stays explicit. `CreateAgentOptions.setup(agentCtx)` composes this world before publication and does not drive. Dev invariants and `verify-scoped-dispatch` keep carrier/subject identity aligned with event declarations. Rationale: [agent-scope RFC](rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md); subagent `persona`, `toolFilter`, and `maxDepth` are the separate [composition-controls feature](rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). ## State @@ -160,6 +160,7 @@ New behavior should attach to a documented extension point; changing the shipped The [extension cookbook](cookbook/extension-cookbook.md) carries plugin skeletons and the feature-to-seam map; step-by-step guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [vendored packages](cookbook/adding-a-vendored-package.md). ## Quick Reference +- Domain terms in the [glossary](glossary.md) - Type definitions in [core-data-structures/](core-data-structures/core.md) - Exact event and service signatures in [events](cordis-catalog/events.md) - [services](cordis-catalog/services.md) catalogs diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 57f8bc961a..4e92f0a0a2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -113,30 +113,15 @@ Source: [`packages/core/agent-core/src/index.ts:87`](../packages/core/agent-core Requires: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` ```ts config-catalog -/** - * Plugin config: the agents to create — or resume, via `resumeSessionId` — - * declaratively at startup, so a cordis.yml deployment needs no code. - */ +/** Plugin configuration for declarative startup agents. */ export interface Config { - /** Agents created from configuration at startup. */ + /** Agents created or resumed at plugin startup. */ agents: (AgentOptions & { - /** Agent id to register under; also seeds the fresh per-run session id (`${id}-session-`). */ + /** Registry identity for the live agent. */ id: AgentId - /** Optional workspace cwd for the config-created fresh session. */ + /** Optional workspace for a fresh session. */ cwd?: string - /** - * If set, the config agent RESUMES this persisted session id instead of - * starting a fresh `${id}-session-`. Sourced from an env var in - * cordis.yml (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`), so a - * demo can continue a prior conversation without code changes. Requires a - * `dsh-session-persistence` backend; the resume is deferred until that - * service is available (via `ctx.inject`) and the loaded session's events - * seed the live session so history continues. - * - * The schema accepts a plain string at runtime (cordis.yml values are - * untyped); the brand is compile-time only — the config format is the - * boundary where an id enters, so the TYPE declares the brand here. - */ + /** Persisted session to resume instead of creating a fresh session. */ resumeSessionId?: SessionId })[] } @@ -144,7 +129,7 @@ export interface Config { Depends on: [`AgentId`](../packages/core/agent/src/index.ts) · [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts) -Source: [`packages/core/agent-loop/src/index.ts:119`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:335`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -560,12 +545,12 @@ Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:5 ```ts config-catalog /** Skill registry configuration. */ export interface Config { - /** Maximum number of completed cwd/provider catalog snapshots kept in memory. */ - collectCacheMaxEntries?: number + /** Maximum number of completed cwd/provider catalogs kept in memory. */ + readonly collectCacheMaxEntries?: number } ``` -Source: [`packages/skill/skill/src/index.ts:114`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:113`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` @@ -729,7 +714,7 @@ export interface Config { Depends on: [`SubagentCapabilities`](../packages/subagent/subagent/src/index.ts) · [`SubagentStopReason`](../packages/subagent/subagent/src/index.ts) -Source: [`packages/support/subagent-mock/src/index.ts:87`](../packages/support/subagent-mock/src/index.ts) +Source: [`packages/support/subagent-mock/src/index.ts:97`](../packages/support/subagent-mock/src/index.ts) ## `@deepseek-ai/dsh-subagent-spawn` @@ -792,7 +777,7 @@ export interface Config { } ``` -Source: [`packages/core/system-prompt/src/index.ts:315`](../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:257`](../packages/core/system-prompt/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -973,7 +958,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:409`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:407`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-user-approval` @@ -986,7 +971,7 @@ export interface Config { * (fail-closed with none); `'never'` auto-rejects every ask without * prompting (the deterministic CI/unattended stance). */ - policy?: ApprovalPolicy + readonly policy?: ApprovalPolicy } /** @@ -1004,7 +989,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/ui/user-approval/src/index.ts:281`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:270`](../packages/ui/user-approval/src/index.ts) ## `@deepseek-ai/dsh-web` diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 3832f44dd0..2912fde6e4 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -34,7 +34,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w ## Rules of the execute() contract - **Args are validated for you.** `defineTool` validates the model-generated `arguments` against the `SchemaSpec` before `execute` runs (type, required keys, enum membership, nested objects/arrays — [runtime arg validation](../rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md)), so inside `execute` the args already match `InferArgs`. You still hand-check value constraints the DSL can't express (non-empty strings, positive numbers, cross-field rules); throw a descriptive Error for those. Raw JSON-Schema tools registered directly (MCP) are NOT validated by the harness — they validate their own input. -- **Registration snapshots your definition.** Parameters must be losslessly JSON-serializable; the registry reads them once and materializes the detached stored value in one recursive pass, copies the scalar fields, binds each callback once to your definition as its method receiver, and freezes the stored record. Reassigning `definition.execute` after registration does not hot-swap the tool—dispose and register a new definition through the owning effect instead. Deliberate mutable state inside the callback's closure or receiver remains ordinary plugin state. +- **Registration borrows your readonly definition.** A typed same-process contribution is not a serialization boundary; do not mutate its schema or replace callbacks after registration. `schemas()` materializes only the explicit model-facing projection. To hot-swap a tool, dispose its owning effect and register the replacement; mutable state inside the callback's closure remains ordinary plugin state. - **Execution identity is protected.** The registry materializes `arguments` as detached lossless JSON in one recursive pass, freezes that value before policy starts, and assigns an opaque `exec.token`; `callId`, `name`, `arguments`, `agent`, `token`, and an optional enclosing-transport `parent` token stay immutable through dispatch. `parent` is identity-only and exposes no live outer execution. Treat `args` as readonly input. An around-dispatch wrapper may add, replace, or remove only `exec.signal` to impose cancellation or a deadline. - **Throwing or returning non-JSON data means isError.** The registry catches anything `execute()` throws and materializes the complete post-policy result as lossless JSON before final observers run. A throw, malformed result, or non-JSON content/context/meta becomes `{isError: true}` so the live outcome cannot succeed and then fail at the durable log. Use errors for infrastructure failures (bad input, spawn errors, aborts), but report domain failures in the result text instead (for example, tool-bash returns `[exit code: 9]` with `isError: false` because the model decides what a failing command means). - **Honor `exec.signal`.** Cancel in-flight work when it fires. diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index a16cda8799..f15ef37e9d 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -56,7 +56,7 @@ export function apply(ctx: Context) { ## A client-driver plugin (external protocol bridge) -A *client driver* is a UI plugin whose "user" is another program speaking a wire protocol rather than a human at a terminal. It owns the process's stdio (so it must run with **no stdout logger** — every non-protocol byte corrupts the stream), creates/resumes agents on demand through the `dsh-agent` factory seam, translates harness events (`session/event`, `agent/*`) into outbound protocol messages, and translates inbound requests back into `agent.send()` / `agent.cancel()`. Two harness-specific contracts make it correct: resolve each request exactly once from the durable `turn/end` session event, using `agent/status` only as defensive reconciliation against the canonical log, and tear each agent down through its `AgentHandle.dispose()` (which stops the loop, `await`s its exit, and unregisters), not just `cancel()` — disposal must *reach* quiescence, not merely request it. +A *client driver* is a UI plugin whose "user" is another program speaking a wire protocol rather than a human at a terminal. It owns the process's stdio (so it must run with **no stdout logger** — every non-protocol byte corrupts the stream), creates/resumes agents on demand through the `dsh-agent` factory seam, translates harness events (`session/event`, `agent/*`) into outbound protocol messages, and translates inbound requests back into `agent.send()` / `agent.cancel()`. Two harness-specific contracts make it correct: correlate and settle each request exactly once from the durable `turn/end` session event even if rendering fails, and tear each agent down through its `AgentHandle.dispose()` (which stops the loop, `await`s its exit, and unregisters), not just `cancel()` — disposal must *reach* quiescence, not merely request it. `packages/ui/acp` is the worked example: it bridges the agent to the Agent Client Protocol (JSON-RPC over stdio) so Zed and other ACP editors can drive it. See its README for the full method surface and the permission-prompt answerer it registers on the approval seam. @@ -97,7 +97,7 @@ Every product feature maps to a listener on a documented extension seam — the | Dynamic workflow | `ctx.workflows` + the worker-thread engine + the `workflow` tool; structured in-process children enforce output with scoped prompt protection, a monotonic tool guard, final `tools/result` commit (including enclosing `run_code`), and terminal `agent/turn-stop` | | Queued + steering messages | core `Agent.send()` / `Agent.steer()` | | Context compaction (auto + manual) | the `ctx.compact` seam + a backend (`dsh-compact-basic`) on the serial `agent/pre-step` seam; auto = token-pressure check before each step; a manual trigger invokes the same `ctx.compact` routine ([compaction RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) — the model-facing `/compact` consumer tool is deferred) | -| System prompt configurability | `ctx.systemPrompt.section()` with ordering; an owner uses `systemPrompt.protect()` only when its canonical section/tool presence is a correctness invariant | +| System prompt configurability | `ctx.systemPrompt.section()` with ordering; a protocol owner sets `ownerFinal: true` on the section or tool only when canonical presence is a correctness invariant | | AGENTS.md (root) | a section provider reading the file | | AGENTS.md (subdir, on-touch) + file-change notices | `agent.inject()` from a watcher / tool-result listener | | Built-in tools | `ctx.tools.register()`; schemas flow into the assembly automatically — the `dsh-tool-*` families (bash, fs, web, subagent, todo) are the shipped examples | diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 21e69f7a25..e0149c7c67 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -15,7 +15,7 @@ Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `n ### `agent/created` — emit -An agent's fully composed scoped world was published in the AgentRegistry. Its session is already live in the session store, but concrete factories may keep driving verbs locked until the subsequent `agent/session-start` boundary; that event is the first supported place to inject or queue work during startup. A synchronous listener throw vetoes publication and rollback emits the matching disposal edges; returned-promise rejection is observed and logged but cannot retroactively veto this synchronous boundary. A synchronous listener that requests the advanced registry detach does not remove the entry immediately: removal and the paired `agent/disposed` edge wait until the creation dispatch unwinds, so no later creation listener observes a disposal that preceded its own creation callback. +An agent's fully composed scoped world was published in the AgentRegistry. Its session is already live in the session store. Setup is composition-only by contract; the subsequent `agent/session-start` boundary is the first supported place to inject or queue startup work. A synchronous listener throw vetoes publication and rollback emits the matching disposal edges; returned-promise rejection is observed and logged but cannot retroactively veto this synchronous boundary. A synchronous listener that requests the advanced registry detach does not remove the entry immediately: removal and the paired `agent/disposed` edge wait until the creation dispatch unwinds, so no later creation listener observes a disposal that preceded its own creation callback. ```ts cordis-catalog 'agent/created'(this: Scoped, agent: Agent): void @@ -47,7 +47,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:608`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:606`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -61,7 +61,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:440`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:438`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -73,11 +73,11 @@ Waterfall: decide what happens to ONE drained queued message before it becomes a Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:458`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:456`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit -A message entered the agent's inbox (queued or steering). Content and the resolved source are the detached, deeply-frozen values retained by the inbox; the `info` wrapper is frozen too, so one listener cannot rewrite what another listener observes. `source` has defaults applied and is not the caller's raw options. +A message entered the agent's inbox (queued or steering). Content and the resolved source are the detached, deeply-frozen values retained by the inbox. `source` has defaults applied and is not the caller's raw options. ```ts cordis-catalog 'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void @@ -85,7 +85,7 @@ A message entered the agent's inbox (queued or steering). Content and the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:362`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:360`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -97,7 +97,7 @@ Waterfall: shape the step's call configuration — model switching, sampling ove Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:487`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:485`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -113,7 +113,7 @@ The seed is a frozen empty list; a contributing listener returns a NEW array — Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:539`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:537`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -125,7 +125,7 @@ The agent's session lifecycle began, fired once before its first turn. `source` Types: [Agent](../core-data-structures/core.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:383`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:381`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -149,7 +149,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:554`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:552`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -161,7 +161,7 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:572`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:570`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -173,13 +173,13 @@ Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` wat Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:591`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:589`](../../packages/core/agent/src/types.ts) ## `approval/*` ### `approval/request` — waterfall -Waterfall asking the composed answerers to decide one approval request. Dispatched only from ApprovalService.request — callers go through the service (which owns cancellation and the audit events), never through `ctx.waterfall` directly. A listener that can answer for this request's agent returns an outcome WITHOUT calling `next()` (the decision slot is single-occupancy, first listener to answer wins); a listener that does not recognize the agent MUST call `next()` so another answerer — or the fail-closed default `'unavailable'` — gets the question. Throwing is contained by the service and yields `'unavailable'`. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `req.agent`: a listener registered through `agent.ctx` receives only that agent's questions, while a plain-context listener receives every agent's. `req` is the service's shallow-frozen acceptance snapshot: later caller mutation cannot redirect the question, while the `agent` and `signal` identity capabilities remain exact. +Waterfall asking the composed answerers to decide one approval request. Dispatched only from ApprovalService.request — callers go through the service (which owns cancellation and the audit events), never through `ctx.waterfall` directly. A listener that can answer for this request's agent returns an outcome WITHOUT calling `next()` (the decision slot is single-occupancy, first listener to answer wins); a listener that does not recognize the agent MUST call `next()` so another answerer — or the fail-closed default `'unavailable'` — gets the question. Throwing is contained by the service and yields `'unavailable'`. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `req.agent`: a listener registered through `agent.ctx` receives only that agent's questions, while a plain-context listener receives every agent's. `req` is a readonly same-process value borrowed from the caller. ```ts cordis-catalog 'approval/request'(this: Scoped, req: ApprovalRequest, next: () => Promise): Promise @@ -187,7 +187,7 @@ Waterfall asking the composed answerers to decide one approval request. Dispatch Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) -Source: [`packages/ui/user-approval/src/index.ts:72`](../../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:70`](../../packages/ui/user-approval/src/index.ts) ## `fs/*` @@ -295,7 +295,7 @@ A skill provider became resolvable in the `ctx.skills` registry. Consumers can o 'skill/provider-added'(provider: SkillProvider): void ``` -Source: [`packages/skill/skill/src/index.ts:132`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:131`](../../packages/skill/skill/src/index.ts) ### `skill/provider-removed` — emit @@ -305,49 +305,49 @@ A skill provider left the registry because its plugin fiber was disposed. 'skill/provider-removed'(name: string): void ``` -Source: [`packages/skill/skill/src/index.ts:138`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:137`](../../packages/skill/skill/src/index.ts) ## `subagent/*` ### `subagent/end` — emit -A started subagent run settled — emitted when SubagentRun.result resolves (any stop reason) or rejects (reported as `error`). Paired with Events['subagent/start']; a run whose readiness rejected emits neither event. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by the DELEGATING PARENT — a listener registered through the parent's `agent.ctx` observes only its own delegations; a plain plugin listener observes every run. +A ready child settled. Scope-filtered dispatch uses the same delegating parent carrier as `subagent/start`, so the lifecycle pair reaches the same scoped audience. ```ts cordis-catalog 'subagent/end'(this: Scoped, info: SubagentRunEndInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:134`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:90`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit -A provider became resolvable in the SubagentService registry. Consumers that derive state from a named provider (e.g. the model-facing tool wording in `dsh-tool-subagent`) react HERE instead of assuming load order — the cordis Loader starts sibling plugins concurrently, so "listed earlier in cordis.yml" does not mean "registered earlier". +A provider became resolvable in the registry. ```ts cordis-catalog 'subagent/provider-added'(provider: SubagentProvider): void ``` -Source: [`packages/subagent/subagent/src/index.ts:95`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:66`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit -A provider left the registry (its plugin's fiber was disposed — an unload or an HMR reload). Consumers holding provider-derived state drop it here; a reload re-fires `subagent/provider-added` with the fresh provider. Delivered with per-listener containment: a throwing subscriber is logged, never starves later subscribers, and never disrupts the provider's teardown. +A provider left the registry. Accepted runs remain holder-owned. ```ts cordis-catalog 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:106`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:72`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit -A subagent run started — emitted only after SubagentRun.started fulfills, when the provider has established a live child. For an in-process provider, `ctx.agents.get(info.id)` is therefore guaranteed to resolve during this notification. A readiness rejection emits neither lifecycle event; every emitted start is paired with Events['subagent/end']. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by the DELEGATING PARENT — a listener registered through the parent's `agent.ctx` observes only its own delegations; a plain plugin listener observes every run. +A provider established a ready child. For in-process providers, `ctx.agents.get(info.id)` resolves during this notification. Scope-filtered dispatch keys the carrier by the delegating parent, so a parent-scoped listener observes only its own delegations. Paired with `subagent/end`. ```ts cordis-catalog 'subagent/start'(this: Scoped, info: SubagentRunInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:121`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:82`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` @@ -359,17 +359,17 @@ Waterfall around prompt assembly — mutate or extend the PromptAssembly (sectio 'system-prompt/assemble'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:46`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:45`](../../packages/core/system-prompt/src/index.ts) ### `system-prompt/change` — emit -A section, tool provider, variable provider, or protection was registered or unregistered (the assembly inputs changed — possibly for one scope only). An UNFILTERED registry-subject notification, deliberately not scope-filtered dispatch: a global change concerns every agent's next assembly, so a scoped listener subscribing here sees every change, not just its own scope's. +A section, tool provider, or variable provider was registered or unregistered (the assembly inputs changed — possibly for one scope only). An UNFILTERED registry-subject notification, deliberately not scope-filtered dispatch: a global change concerns every agent's next assembly, so a scoped listener subscribing here sees every change, not just its own scope's. ```ts cordis-catalog 'system-prompt/change'(): void ``` -Source: [`packages/core/system-prompt/src/index.ts:56`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:55`](../../packages/core/system-prompt/src/index.ts) ## `tools/*` @@ -381,7 +381,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:176`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:173`](../../packages/core/tools/src/index.ts) ### `tools/execute` — waterfall @@ -393,23 +393,23 @@ Around-dispatch waterfall wrapping the registry's core tool dispatch, between th Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:131`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:128`](../../packages/core/tools/src/index.ts) ### `tools/post-execute` — waterfall Waterfall AFTER a tool runs — where hook plugins inspect the result and accept it (optionally REPLACING the model-facing content, and/or attaching `additionalContext` for the next request) or block it with corrective `feedback` (Claude Code's `PostToolUse`). Listeners receive `(exec, result, next)`: call `next()` to delegate to the default (accept unchanged), or return a PostToolDecision to override. Core tool dispatch runs earlier as the base `next()` of the `tools/execute` waterfall, all inside `execute`'s outer try/catch (and the tool body keeps its own inner try/catch, so a thrown tool still reaches `post-execute` as an `isError` result). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by `exec.agent` — a listener registered through `agent.ctx` fires only for that agent's calls; a plain plugin listener fires for every call (including agent-less ones, which dispatch subject-less). ```ts cordis-catalog -'tools/post-execute'(this: Scoped, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise): Promise +'tools/post-execute'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise ``` Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:151`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:148`](../../packages/core/tools/src/index.ts) ### `tools/pre-execute` — waterfall -Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code's `PreToolUse`). Listeners receive `(exec, next)`: call `next()` to delegate to the default (allow), or return a PreToolDecision without calling `next()` to short-circuit. A `deny` skips dispatch and yields an `isError` result; the tool body never runs. Input rewrite is deliberately NOT offered here (see PreToolDecision); `ask` is serviced by the `ctx.approval` seam when one is mounted, and degrades to deny otherwise. The returned union is validated as an exact runtime shape before approval or guards run; a malformed JavaScript/casted decision fails closed as an `isError` result and the tool body never runs. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `exec.agent`: a listener registered through `agent.ctx` fires only for that agent's calls, while a plain plugin listener fires for every call (including agent-less ones, which dispatch subject-less). +Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code's `PreToolUse`). Listeners receive `(exec, next)`: call `next()` to delegate to the default (allow), or return a PreToolDecision without calling `next()` to short-circuit. A `deny` skips dispatch and yields an `isError` result; the tool body never runs. Input rewrite is deliberately NOT offered here (see PreToolDecision); `ask` is serviced by the `ctx.approval` seam when one is mounted, and degrades to deny otherwise. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `exec.agent`: a listener registered through `agent.ctx` fires only for that agent's calls, while a plain plugin listener fires for every call (including agent-less ones, which dispatch subject-less). ```ts cordis-catalog 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise @@ -417,7 +417,7 @@ Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook pl Types: [ToolExecution](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:104`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:101`](../../packages/core/tools/src/index.ts) ### `tools/result` — parallel @@ -429,7 +429,7 @@ Awaited notification of the authoritative FINAL tool outcome, after the complete Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:166`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:163`](../../packages/core/tools/src/index.ts) ## `workflow/*` @@ -441,17 +441,17 @@ One `agent()` call settled (clean result, child failure, or run cancellation). P 'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:98`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:96`](../../packages/workflow/workflow/src/index.ts) ### `workflow/agent-start` — emit -One `agent()` call established a ready child run. Paired with Events['workflow/agent-end'] by `agent.seq`. A call that never crosses the provider's publication/readiness boundary emits neither event in this pair. +One `agent()` call established a ready child run. Paired with Events['workflow/agent-end'] by `agent.seq`. A call that never receives a ready run from the provider emits neither event in this pair. ```ts cordis-catalog 'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:87`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:85`](../../packages/workflow/workflow/src/index.ts) ### `workflow/end` — emit @@ -461,7 +461,7 @@ A workflow run settled (any stop reason). Fired when WorkflowRun.result resolves 'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:108`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:106`](../../packages/workflow/workflow/src/index.ts) ### `workflow/log` — emit @@ -471,7 +471,7 @@ The script emitted a narration line (a `log(message)` call). 'workflow/log'(info: WorkflowRunInfo, message: string): void ``` -Source: [`packages/workflow/workflow/src/index.ts:77`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:75`](../../packages/workflow/workflow/src/index.ts) ### `workflow/phase` — emit @@ -481,7 +481,7 @@ The script entered a phase (a `phase(title)` call) — progress grouping for obs 'workflow/phase'(info: WorkflowRunInfo, title: string): void ``` -Source: [`packages/workflow/workflow/src/index.ts:70`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:68`](../../packages/workflow/workflow/src/index.ts) ### `workflow/start` — emit @@ -491,7 +491,7 @@ A workflow run started — the script's meta block validated, the body about to 'workflow/start'(info: WorkflowRunInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:62`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:60`](../../packages/workflow/workflow/src/index.ts) ## Inherited events (cordis core + loader/hmr/timer) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 67a6b7a725..29e440776d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -11,9 +11,7 @@ The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary ## `ctx.agentLoop` — `AgentLoop` -The agent-loop plugin (`ctx.agentLoop`): creates ReactLoopAgents, runs their loops, and registers them in `ctx.agents`. Also implements the AgentFactory seam, so plugins create/resume agents through `ctx.agents` (the interface) without depending on this concrete package. - -The loop itself is deliberately thin — every behavior beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy declared in @deepseek-ai/dsh-agent. +Concrete ReactLoopAgent factory and driver service. ```ts cordis-catalog create(id: AgentId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent @@ -21,27 +19,26 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:153`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:348`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package. Agent *creation* is provided by whichever plugin implements the AgentFactory (`@deepseek-ai/dsh-agent-loop`), registered via setFactory. ```ts cordis-catalog -reserve(id: AgentId): AgentRegistrationReservation setFactory(factory: AgentFactory): () => Promise | void async create(options: CreateAgentOptions): Promise async resume(options: ResumeAgentOptions): Promise register(agent: Agent): () => Promise | void -enter(agent: Agent, reservation?: AgentRegistrationReservation): () => void +enter(agent: Agent): () => void announce(agent: Agent): void get(id: AgentId): Agent | undefined list(): Agent[] ``` -Types: [Agent](../core-data-structures/core.md) · [AgentRegistrationReservation](../core-data-structures/core.md) +Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:250`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:203`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` @@ -55,7 +52,7 @@ async request(req: ApprovalRequest): Promise Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) -Source: [`packages/ui/user-approval/src/index.ts:305`](../../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:294`](../../packages/ui/user-approval/src/index.ts) ## `ctx.bash` — `BashExecutor` (abstract seam) @@ -211,10 +208,9 @@ In-memory session store (`ctx.sessions`). Persistence is intentionally not implemented here — persistence plugins subscribe to `session/event` and flush on `session/flush` / dispose. ```ts cordis-catalog -reserve(id: SessionId): SessionRegistrationReservation create(id?: SessionId, options?: CreateSessionOptions): Session prepare(id?: SessionId, options?: CreateSessionOptions): Session -enter(session: Session, reservation?: SessionRegistrationReservation): () => void +enter(session: Session): () => void announce(session: Session): void async flush(session: Session): Promise get(id: SessionId): Session | undefined @@ -222,9 +218,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Types: [SessionRegistrationReservation](../core-data-structures/session.md) - -Source: [`packages/core/session/src/index.ts:761`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:591`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` @@ -237,55 +231,52 @@ async list(options: SkillLookupOptions = {}): Promise async get(name: string, options: SkillLookupOptions = {}): Promise ``` -Source: [`packages/skill/skill/src/index.ts:159`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:158`](../../packages/skill/skill/src/index.ts) ## `ctx.subagents` — `SubagentService` -The `subagents` service: a registry of named SubagentProviders and a capability-checked start surface. +Named provider registry and capability-checked start surface. ```ts cordis-catalog registerProvider(provider: SubagentProvider): () => Promise | void getProvider(name: string): SubagentProvider | undefined list(): string[] -start(name: string, request: SubagentStartRequest): SubagentRun +async start(name: string, request: SubagentStartRequest): Promise ``` -Source: [`packages/subagent/subagent/src/index.ts:180`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:123`](../../packages/subagent/subagent/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` -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). +Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, named prompt variables, and owner-final contributions; 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 tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise | void variable(name: string, provider: (context: AssembleContext) => string | undefined): () => Promise | void -protect(protection: PromptProtection): () => Promise | void async assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:430`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:372`](../../packages/core/system-prompt/src/index.ts) ## `ctx.tools` — `ToolRegistry` Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute` → `tools/result` pipeline. The registry contributes its schemas into the system-prompt assembly — WHICH schemas is governed by its `mode` config (see Config.mode); under a non-native mode it also owns the reserved `run_code` presentation transport and the `tools:sdk` prompt section. -Two registration layers (`@deepseek-ai/dsh-scope`): a registration through a plain plugin context is GLOBAL (visible to every agent); one through a scoped context (`agent.ctx`) is filed in that scope's layer — visible to that agent alone, disposed with the scope, and SHADOWING a global tool of the same name for that agent (most-specific-wins; within one layer a duplicate name still throws). restrict masks the global layer per scope. One visibility function (visible) feeds prompt assembly, get, and execute — and, under a non-native mode, the SDK section and `run_code`'s bindings — so what the model is shown, what a presenter renders, what a program can call, and what dispatches can never disagree. +Two registration layers (`@deepseek-ai/dsh-scope`): a registration through a plain plugin context is GLOBAL (visible to every agent); one through a scoped context (`agent.ctx`) is filed in that scope's layer — visible to that agent alone, disposed with the scope, and SHADOWING a global tool of the same name for that agent (most-specific-wins; within one layer a duplicate name still throws). restrict masks the global layer per scope. One private visibility resolver feeds prompt assembly, get, and execute — and, under a non-native mode, the SDK section and `run_code`'s bindings — so what the model is shown, what a presenter renders, what a program can call, and what dispatches can never disagree. ```ts cordis-catalog register(definition: ToolDefinition): () => Promise | void restrict(filter: ToolRestriction): () => Promise | void guard(guard: ToolGuard): () => Promise | void -visible(scope?: ScopeKey): ToolDefinition[] get(name: string, scope?: ScopeKey): ToolDefinition | undefined schemas(scope?: ScopeKey): ToolSchema[] -knownNames(scope?: ScopeKey): string[] 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:484`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:500`](../../packages/core/tools/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` @@ -327,7 +318,7 @@ Abstract workflow execution service. Subclass, implement start, and load the sub Semantics every implementation must honor: - start throws synchronously for a request that cannot begin (an unparseable script, an invalid meta block). Once it returns a WorkflowRun, `result` NEVER rejects — every failure resolves with `stopReason: 'error'` (or `'cancelled'`) — and once the run is cancelled, `result` SETTLES within the implementation's bounded grace even if the script itself never settles (a consumer awaiting `result` must never be wedged past a cancellation). -- The `workflow/*` events fire through emitWorkflowEvent (data snapshots, per-listener containment); `workflow/end` fires exactly once per started run, after `result` is settled or as it settles. +- The `workflow/*` events fire through emitWorkflowEvent (borrowed immutable data, per-listener containment); `workflow/end` fires exactly once per started run, after `result` is settled or as it settles. - `dispose()` reaches quiescence within a bounded grace: it cancels, waits for the script to settle AND its started children to finish disposing, and abandons whatever is left rather than hanging its caller (the engine documents what abandonment leaves behind). - Runs are HOLDER-OWNED: the engine hands control (`cancel`/`dispose`) to the `start()` caller and does not track its live runs — disposing the engine's own fiber mid-run deliberately leaves those runs to their holders' teardown, so an engine reload cannot yank a run out from under the consumer awaiting it. @@ -335,7 +326,7 @@ Semantics every implementation must honor: abstract start(request: WorkflowStartRequest): WorkflowRun ``` -Source: [`packages/workflow/workflow/src/index.ts:214`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:211`](../../packages/workflow/workflow/src/index.ts) ## Inherited `ctx` members (cordis core + loader/hmr/timer) diff --git a/docs/core-data-structures/approval.md b/docs/core-data-structures/approval.md index 997f4e7ab2..c5fa1fe13b 100644 --- a/docs/core-data-structures/approval.md +++ b/docs/core-data-structures/approval.md @@ -39,21 +39,21 @@ interface ApprovalRequest { * UI answerer only answers for agents it owns) and receives the audit * events on its session log. */ - agent: Agent + readonly agent: Agent /** The tool the question is about (presentation and audit). */ - toolName: string + readonly toolName: string /** * The exact tool call being decided, when the asker has one — lets a UI * attach the prompt to the tool call it already streamed. */ - callId?: CallId + readonly callId?: CallId /** The asker's human-readable explanation of WHY it is asking. */ - reason?: string + readonly reason?: string /** * Aborting withdraws the question: the request settles `'cancelled'` * immediately and a late answer from a still-pending answerer is discarded. */ - signal?: AbortSignal + readonly signal?: AbortSignal } ``` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index bd533fe5c4..ecf075b20f 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -355,19 +355,6 @@ interface Agent { `AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`) is merge-extensible — plugins add creation options by declaration merging; the persona is NOT an agent option but the `dsh-system-prompt` plugin's `persona` config, shared context-wide. The `agent/*` event taxonomy (lifecycle emits incl. `agent/session-start`, serial `agent/pre-step`/`agent/turn-stop` checkpoints, and the `agent/prompt-submit`/`agent/request`/`agent/session-prefix`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy); turn/step boundaries are durable `session/event` records, not `agent/*` emits. -### `AgentRegistrationReservation` — unpublished identity ownership - -An agent factory reserves its public `AgentId` before awaiting setup, so setup code cannot publish either the intended agent or a replacement under that id ahead of the transaction. The opaque capability authorizes exactly the later `enter()` call. Its `release` function is the exact Cordis owner effect disposer, letting the lifecycle adopt it by identity and place release after scope quiescence while owner disposal remains the abandoned-transaction backstop. Ordinary plugins use `register()` and never hold this type. - -Source: [`packages/core/agent/src/index.ts`](../../packages/core/agent/src/index.ts) - -```ts type-equiv -interface AgentRegistrationReservation { - readonly id: AgentId - release(): void -} -``` - ## Interception decisions Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. They share one envelope for model-facing context, `HookContext`, which is `inject()`ed as a `context/message` and so carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 4dbb8fb2af..7bf102924b 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -25,15 +25,15 @@ interface SessionHeader { * session is created. A persistence backend rejects any other version on load * (no migration — see the constant). */ - version: number + readonly version: number /** The session's id (mirrors the {@link Session}'s id). */ - id: SessionId + readonly id: SessionId /** Unix epoch milliseconds when the session was created. */ - createdAt: number + readonly createdAt: number /** Absolute working directory the session was created in (if any). */ - cwd?: string + readonly cwd?: string /** The session this one was forked from (seed lineage), if any. */ - parentSession?: SessionId + readonly parentSession?: SessionId /** * How many leading events were INHERITED via a seed rather than produced by * this session — the seed boundary. Set when a fork seeds a child with a @@ -43,7 +43,7 @@ interface SessionHeader { * harness can skip the inherited prefix when deriving the child's OWN script * (the seeded events are the parent's, not this child's model calls). */ - seedLength?: number + readonly seedLength?: number } ``` @@ -54,7 +54,7 @@ Creating a `Session` through the store takes a `seed` (replay/fork an existing e ```ts type-equiv interface CreateSessionOptions { /** Events to seed the new session with (replay/fork). */ - seed?: SessionEvent[] + readonly seed?: readonly SessionEvent[] /** * Creation metadata. The store fills in `version`/`id` and defaults * `createdAt` to now; the caller supplies the storage-level fields (validated @@ -67,7 +67,12 @@ interface CreateSessionOptions { * length, not the original boundary — the caller must pass the persisted * boundary back. A fresh fork passes its actual seeded-prefix length. */ - meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } + readonly meta?: { + readonly cwd?: string + readonly parentSession?: SessionId + readonly createdAt?: number + readonly seedLength?: number + } } ``` diff --git a/docs/core-data-structures/scope.md b/docs/core-data-structures/scope.md index 9ccbfcc1fe..66d5f40de9 100644 --- a/docs/core-data-structures/scope.md +++ b/docs/core-data-structures/scope.md @@ -1,6 +1,6 @@ # Scoped Registration -The [scope package](../../packages/core/scope) supplies the identity and carrier vocabulary that makes one registration context mean both per-agent visibility and shared lifetime ownership. It is a library primitive rather than a Cordis service; the [agent-scope runtime-design RFC](../rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#scope-mechanism-context-key-and-lifetime) owns the implementation rationale, while the package [README](../../packages/core/scope/README.md) owns the callable API and filtering semantics. +The [scope package](../../packages/core/scope) supplies the identity and carrier vocabulary that makes one registration context mean both per-agent visibility and shared lifetime ownership. It is a library primitive rather than a Cordis service; the [agent-scope runtime-design RFC](../rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#scope-routing-one-opaque-key-selects-one-layer) owns the implementation rationale, while the package [README](../../packages/core/scope/README.md) owns the callable API and filtering semantics. Source: [`packages/core/scope/src/index.ts`](../../packages/core/scope/src/index.ts). @@ -12,10 +12,10 @@ Source: [`packages/core/scope/src/index.ts`](../../packages/core/scope/src/index type ScopeKey = object ``` -`Scoped` is the compile-time brand on the proxy returned by `scopeTarget(base, key)`. Scope-filtered event declarations require this carrier as their `this` type, preventing an ordinary subject object from type-checking as the dispatch carrier. +`Scoped` is the compile-time brand on the opaque routing receiver returned by `scopeTarget(base, key)`. Scope-filtered event declarations require this carrier as their `this` type, while the real event subject remains an explicit argument. ```ts type-equiv -type Scoped = T & { readonly [ScopedBrand]: 'dsh.scope.carrier' } +type Scoped = object & { readonly [ScopedBrand]: T } ``` ## Owned registration context diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 3ed02b41a9..1b12c3fc48 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -196,20 +196,6 @@ export interface SurfaceNode { } ``` -## `SessionRegistrationReservation` — unpublished identity and construction ownership - -A session factory reserves its public `SessionId` before awaiting persistence load or scoped setup, so concurrent code cannot create, prepare, or enter a session under that id ahead of the transaction. The opaque capability may construct exactly one unpublished `Session` and authorizes exactly that object at `enter()`. Its `release` function is the exact Cordis owner effect disposer, letting the lifecycle adopt it by identity and place release after scope quiescence while owner disposal remains the abandoned-transaction backstop. Ordinary session consumers use `create()` and never hold this type. - -Source: [`packages/core/session/src/index.ts`](../../packages/core/session/src/index.ts) - -```ts type-equiv -interface SessionRegistrationReservation { - readonly id: SessionId - prepare(options?: CreateSessionOptions): Session - release(): void -} -``` - ## Derived history: `deriveMessages()` and `deriveEventMessage()` `Session.deriveMessages()` projects the event log into the `Message[]` the model sees — cached (each surface node projected once, when first seen; a surface rewrite rebuilds) and frozen (a fresh array per call over shared, deep-frozen messages, so mutating logged history through a projection is unrepresentable). `deriveEventMessage(event)` is the per-node pure function the fold applies — public so external reconstructors and the dev invariant project a log prefix with exactly the same rules and cannot disagree with the cache. The projection rules: diff --git a/docs/core-data-structures/skills.md b/docs/core-data-structures/skills.md index 0c5bfcae13..56ac91a6da 100644 --- a/docs/core-data-structures/skills.md +++ b/docs/core-data-structures/skills.md @@ -6,13 +6,13 @@ Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/ind ## Provider registry -`ctx.skills` is a multi-provider registry. Providers can represent local directories, embedded plugin data, HTTP catalogs, or another source. Provider plugins register synchronously during `apply()`; remote initialization, authentication, and discovery are awaited by `list()`. Each lookup snapshots its read-only options before provider work, and each provider candidate becomes registry-owned data while its opaque locator retains provider-owned identity. The registry validates candidates, resolves duplicate skill names first-wins by rank/provider order/local order, and sorts the final summaries by `name` for deterministic consumers. A provider `list()` rejection is logged and skipped without caching the degraded catalog; malformed candidates still fail fast because they violate the provider contract. +`ctx.skills` is a multi-provider registry. Providers can represent local directories, embedded plugin data, HTTP catalogs, or another source. Provider plugins register synchronously during `apply()`; remote initialization, authentication, and discovery are awaited by `list()`. Provider objects, lookup options, and candidates are readonly same-process contracts, so the registry borrows them instead of manufacturing defensive snapshots. The registry still validates semantic fields, resolves duplicate skill names first-wins by rank/provider order/local order, and sorts the final summaries by `name` for deterministic consumers. A provider `list()` rejection is logged and skipped without caching the degraded catalog; malformed candidates still fail fast because they violate the provider contract. ```ts type-equiv interface SkillProvider { - name: string - list(options: SkillLookupOptions): Promise - get(candidate: SkillCandidate, options: SkillLookupOptions): Promise + readonly name: string + readonly list: (options: SkillLookupOptions) => Promise + readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise } ``` @@ -44,13 +44,13 @@ type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | ' ```ts type-equiv interface SkillSummary { - name: string - description: string - whenToUse?: string - disableModelInvocation?: boolean - source: SkillSource - provider: string - resourceBase?: SkillResourceBase + readonly name: string + readonly description: string + readonly whenToUse?: string + readonly disableModelInvocation?: boolean + readonly source: SkillSource + readonly provider: string + readonly resourceBase?: SkillResourceBase } ``` @@ -58,10 +58,10 @@ interface SkillSummary { ```ts type-equiv interface SkillCandidate extends SkillSummary { - rank: number - locator: unknown - path?: string - metadata?: Record + readonly rank: number + readonly locator: unknown + readonly path?: string + readonly metadata?: Readonly> } ``` @@ -69,16 +69,16 @@ interface SkillCandidate extends SkillSummary { ```ts type-equiv type SkillResourceBase = - | { kind: 'directory'; path: string } - | { kind: 'url'; url: string } - | { kind: 'opaque'; description: string } + | { readonly kind: 'directory'; readonly path: string } + | { readonly kind: 'url'; readonly url: string } + | { readonly kind: 'opaque'; readonly description: string } ``` ```ts type-equiv interface SkillDefinition extends SkillSummary { - content: string - path?: string - metadata?: Record + readonly content: string + readonly path?: string + readonly metadata?: Readonly> } ``` @@ -86,13 +86,13 @@ Runtime skills use the same complete shape and participate in the same first-win ```ts type-equiv type SkillRegistration = Omit & { - provider?: string + readonly provider?: string } ``` ## Lookup and configuration -Skill lookup is cwd-sensitive because providers may expose workspace-local skills, and its optional signal cancels provider work for the caller. The registry captures both fields once and providers receive the same read-only snapshot used for cache identity and loading. Cancellation is checked before and after catalog selection, including cache hits, and races both discovery and full-definition loading. If no git root is found, the local provider treats the supplied cwd itself as the project root. +Skill lookup is cwd-sensitive because providers may expose workspace-local skills, and its optional signal cancels provider work for the caller. Providers receive the same readonly options object used for cache identity and loading. Cancellation is checked before and after catalog selection, including cache hits, and races both discovery and full-definition loading. If no git root is found, the local provider treats the supplied cwd itself as the project root. ```ts type-equiv interface SkillLookupOptions { @@ -105,7 +105,7 @@ The registry owns only its discovery-cache bound. The local provider owns filesy ```ts type-equiv interface Config { - collectCacheMaxEntries?: number + readonly collectCacheMaxEntries?: number } ``` diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 2f3e089a22..5454f326b3 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -12,10 +12,10 @@ A provider advertises its **start-time** features on a static descriptor the ser ```ts type-equiv interface SubagentCapabilities { - outputSchema: boolean - depthLimit: boolean - toolFilter: boolean - persona: boolean + readonly outputSchema: boolean + readonly depthLimit: boolean + readonly toolFilter: boolean + readonly persona: boolean } ``` @@ -25,26 +25,28 @@ What a caller asks for when starting a subagent. The tool layer builds this from ```ts type-equiv interface SubagentStartRequest { - prompt: ContentBlock[] - parent: Agent - signal?: AbortSignal - agentOptions?: AgentOptions - outputSchema?: StructuredOutputSchema - maxDepth?: number - toolFilter?: { allow?: string[]; deny?: string[] } - persona?: string + readonly prompt: ContentBlock[] + readonly parent: Agent + readonly signal: AbortSignal + readonly agentOptions?: AgentOptions + readonly outputSchema?: StructuredOutputSchema + readonly maxDepth?: number + readonly toolFilter?: ToolRestriction + readonly persona?: string } ``` +`signal` is the single cancellation channel before and after readiness. The [subagent composition-controls RFC](../rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) owns the persona, live global-tool filter, absolute-depth, and visibility-not-authority rationale. + ## The terminal result: `SubagentResult` The outcome of a run, resolved by `SubagentRun.result`. `structured` is present only after a requested `outputSchema` was successfully satisfied; requesting a schema does not guarantee it, and a provider may return `stopReason: 'error'` when the child fails or finishes without a valid capture. A non-`completed` `stopReason` means `output` may be partial — the consumer maps it to an `isError` tool result rather than reporting partial output as success. ```ts type-equiv interface SubagentResult { - output: ContentBlock[] - structured?: unknown - stopReason: SubagentStopReason + readonly output: ContentBlock[] + readonly structured?: unknown + readonly stopReason: SubagentStopReason } ``` @@ -62,17 +64,15 @@ interface SubagentStopReasonMap { ## A live run: `SubagentRun` -The handle the consumer holds while a child executes. `started` is the provider's publication boundary: it resolves only after an in-process agent is live in `ctx.agents` or a remote transport has created its child session, and rejects when the attempt fails or is cancelled before that point. The consumer normally awaits `result`, may `cancel` mid-flight, and MUST `dispose` on every path to reach child quiescence (no leaked idle child / session). `result` does NOT reject on a child-level failure — a model/transport failure resolves with `stopReason: 'error'` — so the consumer maps a non-`completed` reason to an `isError` result; it rejects only on an infrastructure fault the seam cannot represent. `sendMessage` and `resume` are OPTIONAL: a provider that supports the runtime capability defines the method; one that doesn't omits it. +The handle the consumer holds after a provider has established a ready child. The consumer awaits `result` and MUST `dispose` on every path to cancel remaining work and reach child quiescence. `result` does NOT reject on a child-level failure — a model/transport failure resolves with `stopReason: 'error'` — so the consumer maps a non-`completed` reason to an `isError` result; it rejects only on an infrastructure fault the seam cannot represent. `sendMessage` and `resume` are OPTIONAL: a provider that supports the runtime capability defines the method; one that doesn't omits it. ```ts type-equiv interface SubagentRun { readonly id: AgentId - readonly started: Promise readonly result: Promise - cancel(reason?: string): void dispose(): Promise sendMessage?(content: ContentBlock[]): void - resume?(content: ContentBlock[]): SubagentRun + resume?(content: ContentBlock[]): Promise } ``` @@ -85,15 +85,15 @@ interface SubagentProvider { readonly name: string readonly capabilities: SubagentCapabilities readonly inheritsParentContext: boolean - start(request: SubagentStartRequest): SubagentRun + start(request: SubagentStartRequest): Promise } ``` -The service (`ctx.subagents`) emits `subagent/start` only after `run.started` fulfills and emits the paired `subagent/end` when that started run settles (see the [events catalog](../cordis-catalog/events.md)); a pre-publication readiness rejection emits neither event. For an in-process provider, a start listener can therefore resolve the live child with `ctx.agents.get(info.id)`; a remote provider need not publish into the local registry. `subagent/end` carries `lastAssistantMessage` (the child's final `output`) on the settle path, so an observer sees WHAT the subagent produced without holding the run (absent when the run rejected at the infrastructure level — no result was produced). These are **observe-only** events: both are plain `emit`s, so a subscriber observes but cannot change the run. Result settlement is observed immediately even while readiness is pending, then its cloned end payload is buffered until start has been announced; this prevents an early rejection from becoming unhandled while preserving start-before-end order and protecting the caller's result from listener mutation. Both emits contain a thrown listener **per listener** (logged, never propagated): one bad subscriber can neither strand a live run, surface as an unhandled rejection on the detached settle hook, nor starve the listeners registered after it. +`SubagentProvider.start()` and `ctx.subagents.start()` are the publication boundary: their promises fulfill only with a ready run. The service attaches result observation, emits `subagent/start`, and returns the same holder-owned run; a rejected start has already cleaned provider-owned partial resources and emits neither lifecycle event. For an in-process provider, a start listener can resolve the live child with `ctx.agents.get(info.id)`; a remote provider need not publish into the local registry. `subagent/end` carries `lastAssistantMessage` (the child's final `output`) on the settle path and reports `error` on infrastructure rejection. Both lifecycle events are observe-only emits with per-listener exception containment. ## 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 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: +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 an ordinary `Agent` in the same application. The provider creates it directly through `parent.ctx`, passes the required signal into the core creation transaction, and delegates quiescent disposal to the returned `AgentHandle`. Provider removal prevents new starts but does not revoke an accepted run. The child receives a flat new scope rather than inheriting the parent's registrations. 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). 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/system-prompt.md b/docs/core-data-structures/system-prompt.md index 0406ddb1a3..bfd34b3626 100644 --- a/docs/core-data-structures/system-prompt.md +++ b/docs/core-data-structures/system-prompt.md @@ -16,22 +16,25 @@ interface AssembleContext { ## Tool-provider result -`ToolProviderResult.schemas` is the model-visible set for the current assembly. `knownNames` is the provider's pre-restriction name universe used to distinguish a configured-name typo from a known tool that is deliberately hidden in this scope. +`ToolProviderResult.schemas` is the model-visible set for the current assembly. `knownNames` is the provider's pre-restriction name universe used to distinguish a configured-name typo from a known tool that is deliberately hidden in this scope. `ownerFinalNames` identifies tool contributions whose canonical presence or absence survives the assembly waterfall. ```ts type-equiv interface ToolProviderResult { - schemas: ToolSchema[] - knownNames?: readonly string[] + readonly schemas: readonly ToolSchema[] + readonly knownNames?: readonly string[] + readonly ownerFinalNames?: readonly string[] } ``` -## Canonical contribution protection +## Prompt sections and owner finality -`PromptProtection` names section and tool contributions whose canonical registry output remains authoritative after the assembly waterfall. Either field may be omitted, but a registration with no names is rejected. +`PromptSection` is a readonly same-process registration contract. `ownerFinal` is reserved for protocol-owned instructions whose canonical presence and definition must survive the complete assembly waterfall; ordinary sections remain transformable. Tool definitions declare the equivalent fact on their own contribution, and the tool provider reports the resolved names through `ownerFinalNames` above. ```ts type-equiv -interface PromptProtection { - sections?: readonly string[] - tools?: readonly string[] +interface PromptSection { + readonly name: string + readonly order: number + readonly text: string | ((context: AssembleContext) => string) + readonly ownerFinal?: boolean } ``` diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 1766b4c9ce..4785de84a3 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -19,6 +19,12 @@ interface ToolDefinition extends ToolSchema { * cooperative implementation that can reach quiescence when the signal aborts. */ timeoutMs?: number + /** + * Whether this tool name's canonical wire presence or absence survives the + * complete system-prompt assembly waterfall. Reserved for protocol tools + * whose owner must retain the final definition. + */ + readonly ownerFinal?: boolean /** * Optional: how to present the PENDING state of one call in a UI, derived from * the call's `args` (parsed arguments, `unknown` — the tool validates/narrows @@ -81,16 +87,25 @@ type InferArgs = Simplify< `defineTool({ name, description, parameters, execute, … })` ties it together: `parameters` is a `SchemaSpec`, `execute(args, exec)` gets `args: InferArgs`, and the helper converts the spec to JSON Schema (`schemaSpecToJsonSchema`) for the wire and validates model-generated args (`validateArgs`) before the typed body runs. A mismatch throws `ToolArgsError` (`code: 'INVALID_ARGS'`), which the registry turns into an `isError` result so the model can self-correct. Why a custom DSL and not schemastery: tool parameters need JSON Schema (the LLM wire format), not validation/transformation — the lightweight DSL gives the best authoring DX with the smallest surface. -Registration is a value boundary. `ToolRegistry.register()` reads every top-level field once, validates fixed scalar and callback types, materializes `ToolDefinition.parameters` as detached lossless JSON in one recursive pass, binds the accepted execute/presentation callbacks once to the original definition as their method receiver, and deep-freezes the stored record. Replacing a callback property on the caller-owned definition later does not change dispatch. `get()`/`visible()` expose only that frozen snapshot, while `schemas()` produces detached projections, so the model-visible and executable views cannot drift through a leaked mutable registry object. +Registration is a trusted same-process contract. The registry borrows the typed definition as readonly input and validates only semantic requirements such as a positive finite `timeoutMs`; `schemas()` materializes the explicit model-facing projection at the model boundary so execution and presentation share one resolved definition without leaking callbacks onto the wire. + +## `ToolRestriction` — one scope's live global filter + +`ToolRestriction` applies only to the live deployment-global tool layer. The registry compiles readonly names into private sets, intersects multiple restrictions, then overlays scope-local tools. A deny-only filter admits later unlisted globals, while an allow-list excludes them. + +```ts type-equiv +interface ToolRestriction { + readonly allow?: readonly string[] + readonly deny?: readonly string[] +} +``` ## Execution: extensible waterfalls plus monotonic policy -`ctx.tools.execute()` accepts a caller-owned `ToolExecutionInput`, snapshots it into a pipeline-owned `ToolExecution`, and runs that call through `tools/pre-execute` (the reorderable allow/deny/ask waterfall) → registered monotonic guards → `tools/execute` (around-dispatch wrappers) → `tools/post-execute` (inspect/replace the result) → `tools/result` (the immutable authoritative outcome). The outcome is a `ToolExecutionResult`. +`ctx.tools.execute()` accepts a caller-owned `ToolExecutionInput`, materializes its parsed JSON arguments once into a pipeline-owned `ToolExecution`, and runs that call through `tools/pre-execute` (the reorderable allow/deny/ask waterfall) → registered monotonic guards → `tools/execute` (around-dispatch wrappers) → `tools/post-execute` (inspect/replace the result) → `tools/result` (the immutable authoritative outcome). The outcome is a `ToolExecutionResult`. ```ts type-equiv -interface ToolExecutionToken { - readonly [toolExecutionTokenBrand]: true -} +type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true } ``` ```ts type-equiv @@ -118,7 +133,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 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. +`ToolExecutionToken` is a compile-time opaque fresh `Symbol` at runtime; identity comparison is its only operation. Before policy runs, `ctx.tools.execute()` materializes `arguments` as detached lossless JSON, assigns the token, and deep-freezes the accepted arguments. A non-JSON value is normalized to an error before policy. `token`, `callId`, `name`, `arguments`, `agent`, and the optional `parent` token are readonly throughout the waterfalls, while 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. 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. @@ -158,7 +173,7 @@ interface ToolExecutionResult { } ``` -The registry rebuilds and validates the complete authoritative result after post-policy. Its content, structured error, additional context, and presentation metadata must round-trip losslessly through JSON; a malformed or non-JSON value becomes a JSON-safe `isError` result before `tools/result` observers run, so the live outcome is always safe for the later durable `tool/result` append. +The registry materializes and freezes the final accepted result immediately before `tools/result`. Its content, structured error, additional context, and presentation metadata must round-trip losslessly through JSON; an invalid outcome becomes a JSON-safe `isError` result, so the observed live outcome is safe for the later durable `tool/result` append. Each interception waterfall returns a typed **Decision** (the idiom shared with the `agent/*` seams). `tools/pre-execute` listeners receive `(exec, next)` and return a `PreToolDecision`; `tools/execute` wrappers return a `ToolExecutionResult`; `tools/post-execute` listeners receive `(exec, result, next)` and return a `PostToolDecision`: diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4f2aec7b44..aac25e71b9 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -9,18 +9,18 @@ This matrix shows which packages dispatch each harness-owned event and which pac | --- | --- | --- | --- | --- | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:331`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:608`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:440`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:458`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:362`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:487`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:539`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:383`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:345`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:554`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:572`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:591`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`strictSerial (serial)`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | -| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:72`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:606`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:438`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:456`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:360`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:485`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:537`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:381`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:345`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:552`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:570`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:589`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:70`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | @@ -29,25 +29,25 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - | | `session/event` | `emit` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | -| `skill/provider-added` | `emit` | [`packages/skill/skill/src/index.ts:132`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | -| `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:138`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:134`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:95`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:106`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:121`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | -| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:46`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - | -| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:56`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:176`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:131`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:151`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:104`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `tools/result` | `parallel` | [`packages/core/tools/src/index.ts:166`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | -| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:98`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:87`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:108`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:77`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:62`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `skill/provider-added` | `emit` | [`packages/skill/skill/src/index.ts:131`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | +| `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:137`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:90`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:66`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:82`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:45`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - | +| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:55`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:173`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:128`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:148`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:101`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `tools/result` | `parallel` | [`packages/core/tools/src/index.ts:163`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:96`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:85`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:106`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:75`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:60`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | ## Non-harness or undeclared event strings seen in package source diff --git a/CONTEXT.md b/docs/glossary.md similarity index 78% rename from CONTEXT.md rename to docs/glossary.md index 2b3584aff4..81b9b4f84a 100644 --- a/CONTEXT.md +++ b/docs/glossary.md @@ -1,10 +1,12 @@ -# Context glossary +# Glossary -Domain vocabulary for the DeepSeek Harness SDK — one canonical term per concept. Terms link with `[[name]]`; implementation detail stays in the package READMEs and RFCs. +Domain vocabulary for the DeepSeek Harness SDK uses one canonical term per concept. Terms link to their entries with standard Markdown anchors; implementation detail stays in package READMEs and RFCs. + +FIXME(glossary-completeness): Expand this glossary before the first release so it covers the SDK's other core and capability subsystems, not only agent scope. ## 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: scoped registrations do not inherit down to subagents; subtree behavior is expressed with [[lineage]] data, never scope 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](#scope-key)). Two levels, flat: scoped registrations do not inherit down to subagents; subtree behavior is expressed with [lineage](#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 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. diff --git a/docs/module-graph.md b/docs/module-graph.md index 8d0993b59a..2acee6ffc5 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -120,13 +120,17 @@ flowchart TD pkg_session --> pkg_brand pkg_session --> pkg_llm pkg_session --> pkg_scope + pkg_system_prompt --> pkg_llm + pkg_system_prompt --> pkg_scope pkg_fs --> pkg_brand pkg_fs --> pkg_llm pkg_web --> pkg_llm pkg_sandbox --> pkg_llm - pkg_system_prompt --> pkg_llm - pkg_system_prompt --> pkg_scope - pkg_system_prompt --> pkg_session + pkg_agent --> pkg_brand + pkg_agent --> pkg_llm + pkg_agent --> pkg_scope + pkg_agent --> pkg_session + pkg_agent --> pkg_system_prompt pkg_bash --> pkg_brand pkg_bash --> pkg_sandbox pkg_bash --> pkg_session @@ -146,26 +150,22 @@ flowchart TD pkg_llm_replay --> pkg_session pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox - pkg_agent --> pkg_brand - pkg_agent --> pkg_llm - pkg_agent --> pkg_scope - pkg_agent --> pkg_session - pkg_agent --> pkg_system_prompt pkg_bash_local --> pkg_bash pkg_bash_local --> pkg_timeout + pkg_compact_basic --> pkg_agent + pkg_compact_basic --> pkg_compact + pkg_compact_basic --> pkg_llm + pkg_compact_basic --> pkg_session pkg_hook_protocol --> pkg_bash pkg_hook_protocol --> pkg_session pkg_session_persistence_jsonl --> pkg_session pkg_session_persistence_jsonl --> pkg_session_persistence pkg_session_persistence_sqlite --> pkg_session pkg_session_persistence_sqlite --> pkg_session_persistence - pkg_bash_sandbox --> pkg_bash - pkg_bash_sandbox --> pkg_bash_local - pkg_bash_sandbox --> pkg_sandbox - pkg_compact_basic --> pkg_agent - pkg_compact_basic --> pkg_compact - pkg_compact_basic --> pkg_llm - pkg_compact_basic --> pkg_session + pkg_invariants --> pkg_agent + pkg_invariants --> pkg_llm + pkg_invariants --> pkg_scope + pkg_invariants --> pkg_session pkg_user_approval --> pkg_agent pkg_user_approval --> pkg_brand pkg_user_approval --> pkg_llm @@ -184,6 +184,9 @@ flowchart TD pkg_tools --> pkg_session pkg_tools --> pkg_system_prompt pkg_tools --> pkg_user_approval + pkg_bash_sandbox --> pkg_bash + pkg_bash_sandbox --> pkg_bash_local + pkg_bash_sandbox --> pkg_sandbox pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_llm pkg_agent_loop --> pkg_scope @@ -210,7 +213,6 @@ flowchart TD pkg_subagent --> pkg_agent pkg_subagent --> pkg_llm pkg_subagent --> pkg_scope - pkg_subagent --> pkg_session pkg_subagent --> pkg_tools pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt @@ -229,12 +231,6 @@ flowchart TD pkg_hooks_codex --> pkg_llm pkg_hooks_codex --> pkg_session pkg_hooks_codex --> pkg_tools - pkg_invariants --> pkg_agent - pkg_invariants --> pkg_llm - pkg_invariants --> pkg_scope - pkg_invariants --> pkg_session - pkg_invariants --> pkg_system_prompt - pkg_invariants --> pkg_tools pkg_acp --> pkg_agent pkg_acp --> pkg_bash pkg_acp --> pkg_llm @@ -333,10 +329,11 @@ flowchart TD | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`llm`](../packages/llm/llm) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`llm`](../packages/llm/llm) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm) | -| [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | +| [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`bash`](../packages/bash/bash) | `bash` | [`brand`](../packages/util/brand), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) | @@ -349,28 +346,27 @@ flowchart TD | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | -| [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) | +| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | -| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | -| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | +| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | -| [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index cb0e8ce0dc..245d25fa5f 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -23,7 +23,7 @@ An approval question was put to the answerer chain — log-only audit (like `hoo Types: [CallId](core-data-structures/core.md) -Source: [`packages/ui/user-approval/src/index.ts:86`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:84`](../packages/ui/user-approval/src/index.ts) #### `approval/decided` — log-only @@ -33,7 +33,7 @@ The outcome of a prior `approval/asked` (same `id`) — log-only audit. Exactly 'approval/decided': { id: ApprovalRequestId; outcome: ApprovalOutcome } ``` -Source: [`packages/ui/user-approval/src/index.ts:97`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:95`](../packages/ui/user-approval/src/index.ts) #### `approval/policy` — log-only @@ -43,7 +43,7 @@ The session's approval policy was switched — log-only, durable, replayable, ne 'approval/policy': { policy: ApprovalPolicy } ``` -Source: [`packages/ui/user-approval/src/index.ts:109`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:107`](../packages/ui/user-approval/src/index.ts) ### `assistant/*` @@ -57,7 +57,7 @@ Raw stream chunk — token-level replay fidelity. Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:322`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -69,7 +69,7 @@ Assembled assistant message for one step (derived history uses this). Carries th Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:324`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:329`](../packages/core/session/src/types.ts) ### `bash/*` @@ -129,7 +129,7 @@ In-session context injection (file-change notices, subdir AGENTS.md, skill conte Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:320`](../packages/core/session/src/types.ts) ### `hook/*` @@ -165,7 +165,7 @@ A queued prompt an `agent/prompt-submit` listener VETOED — the durable record Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:309`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:314`](../packages/core/session/src/types.ts) ### `request/*` @@ -177,7 +177,7 @@ Full snapshot of the EpochHeader the NEXT request is built under, with the Reque 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:369`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:374`](../packages/core/session/src/types.ts) #### `request/header-delta` — log-only @@ -187,7 +187,7 @@ Amendment to the folded EpochHeader: at least one of a SystemDelta, a ToolsDelta 'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] } ``` -Source: [`packages/core/session/src/types.ts:386`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:391`](../packages/core/session/src/types.ts) ### `steering/*` @@ -201,7 +201,7 @@ Steering content injected between steps of a running turn. Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:347`](../packages/core/session/src/types.ts) ### `step/*` @@ -213,7 +213,7 @@ Closes step `step` of turn `turn`. 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:296`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -223,7 +223,7 @@ Opens step `step` of turn `turn` — one model call plus the tool executions it 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) ### `todo/*` @@ -239,7 +239,7 @@ NOT a SurfaceEventType: it produces no LLM message and never reaches `deriveMess Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:361`](../packages/core/session/src/types.ts) ### `tool/*` @@ -253,7 +253,7 @@ The model requested one tool invocation: `name` with the raw `arguments` JSON st Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:335`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -277,7 +277,7 @@ A completed tool call's model-facing result, plus an optional tool-private `meta Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:340`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:345`](../packages/core/session/src/types.ts) ### `turn/*` @@ -291,7 +291,7 @@ Closes turn `turn` with the TurnEndReason that ended it. The loop fires the awai Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -303,7 +303,7 @@ Opens turn `turn`. `trigger` records what started it — a drained message batch Types: [TurnTrigger](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:291`](../packages/core/session/src/types.ts) ### `user/*` @@ -317,4 +317,4 @@ A user-visible prompt (queued message drained at turn start). Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts) diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 5e508344fa..1483401835 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -70,6 +70,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [The session prefix — request-only messages in front of the derived history](implemented/feature/2026-07-07-session-prefix.md) | 2026-07-07 | | [Repeat-tool-call guard plugin](implemented/feature/2026-07-08-repeat-tool-guard.md) | 2026-07-08 | | [The self-referential cordis toolset](implemented/feature/2026-07-08-self-referential-cordis-toolset.md) | 2026-07-08 | +| [Configure subagent persona, tool visibility, and depth](implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) | 2026-07-12 | ### Simplification 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 024eb05f82..76c40805f8 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 @@ -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
cleanup follows Agent A"] -->|"registers into"| agentALayer["Agent A layer"] agentBContext["agentB.ctx
cleanup follows Agent B"] -->|"registers into"| agentBLayer["Agent B layer"] - operationA["Operation for Agent A"] -->|"selects"| agentAView["Agent A view
eligible globals plus A local only"] + operationA["Operation for Agent A"] -->|"selects"| agentAView["Agent A view
globals plus A local"] globalLayer --> agentAView agentALayer --> agentAView - operationB["Operation for Agent B"] -->|"selects"| agentBView["Agent B view
eligible globals plus B local only"] + operationB["Operation for Agent B"] -->|"selects"| agentBView["Agent B view
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` 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
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. diff --git a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index 35d1b50c16..786dce250e 100644 --- a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -4,999 +4,390 @@ Status: implemented ## Problem -The [agent-scope contract](2026-07-08-agent-scope-contexts.md) defines the contributor-visible result: registrations made through `agent.ctx` form one flat local layer, operations resolve that layer by their real agent, setup remains unpublished, and teardown preserves the layer until work stops. The implementation must make those claims true inside a cooperative plugin framework and mutable JavaScript runtime. +The [agent-scope contract](2026-07-08-agent-scope-contexts.md) is simple for contributors: register through `agent.ctx`, resolve one global-plus-agent view, publish only after setup, and retain the scope until work stops. The runtime must preserve that contract across a cooperative plugin framework, asynchronous creation, reentrant listeners, durable session commits, and worker or process failure. -Four failure classes interact in the paths this change hardens: +The main design risk is adding a second mechanism for every race. Separate reservations, readiness sentinels, cancellation relays, snapshot layers, and protection registries can mirror the same fact until no reader can tell which one is authoritative. That machinery also encourages the runtime to treat trusted typed calls as hostile serialization boundaries. -| Proof obligation | Failure if implemented locally or incompletely | -|---|---| -| Registration and dispatch select the same key | Prompt data resolves for one agent while behavior listeners run for another | -| Construction and teardown have continuous ownership | Reentrant unload publishes a partial world, leaks IDs, or revokes policy before final work | -| Covered validation and later use observe one accepted value | Stateful accessors or caller mutation make checked, executed, logged, and observed data disagree | -| Protocol invariants survive extensible middleware | Listener ordering removes required prompt state, re-allows denied work, commits a failed result, or forces another model step | - -Cordis already supplies derived contexts, effect ownership, receiver-based filtering, and waterfalls, but none alone establishes all four obligations. Context association is not a scope key, raw disposers are not await-idempotent, waterfall listeners can short-circuit or wrap each other, and JavaScript `readonly` types do not constrain runtime accessors. Async persistence, setup, publication callbacks, subagent providers, and worker messages add reentrancy and race boundaries around those primitives. +The implementation needs enough state to preserve real ownership and settlement boundaries, but no more. A correctness reviewer must be able to follow one fact from acceptance through publication and teardown without reconciling parallel representations. ## Decision -The runtime implements agent scope as four coupled mechanisms rather than one generic framework feature: +The runtime uses one mechanism per independent fact. Scope routing has an opaque carrier; each live registry object has one entry record; each create or resume operation has one transaction; typed same-process calls borrow readonly values; real data boundaries materialize once; and worker/process code retains separate terminal and quiescence state only where different owners can genuinely race. -| Mechanism | Implementation decision | +The design can be skimmed as seven choices: + +| Problem | Authoritative mechanism | |---|---| -| Layer and routing | A scope key tags registration effects; scope-aware contribution registries merge globals plus one layer; dispatch carriers filter listeners by the operation subject | -| Transactional lifetime | Scope, session, registry entry, driver, reservations, caller ownership, and AgentLoop ownership publish and unwind as one ordered transaction | -| Boundary ownership | The hardened acceptance-sensitive paths listed below capture caller fields once and retain stable identities or owner-controlled representations | -| Owner-final policy | Four narrow service-owned boundaries restore named prompt state, deny monotonically, observe final results, and stop terminal turns | +| Select global plus one agent's registrations | Opaque scope key and routing carrier | +| Own one live agent or session | One registry entry captured by its disposer | +| Coordinate create/resume | One `AgentCreationTransaction` | +| Protect durable, queued, model, or wire data | Materialize once at that boundary | +| Pass typed values inside one process | Readonly borrowed contract | +| Preserve an owner's final prompt/tool policy | Contribution-owned finality and one final observer point | +| Coordinate subagent, worker, and process shutdown | One cancellation signal plus the independent terminal/quiescence facts of that boundary | -The same mechanisms carry into in-process subagents and the workflow bridge. Subagents are the composition proof because child setup, structured output, readiness, cancellation, result settlement, and disposal exercise all four obligations concurrently. +The rest of this RFC expands those choices in dependency order. It first explains the Cordis mechanics, then scope routing, creation and session commit, tools and prompts, subagents and workflows, and finally the checks that make the reasoning executable. -This RFC owns the implementation rationale, algorithms, race handling, and correctness enforcement. The [agent-scope contract](2026-07-08-agent-scope-contexts.md) owns contributor-facing behavior, tool-filter semantics, usage examples, and the security non-goal; this document links to that contract rather than redefining authority or public scope inheritance. +The [July 8 RFC](2026-07-08-agent-scope-contexts.md) remains the contributor contract. The separate [subagent composition-controls RFC](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) owns `persona`, `toolFilter`, and `maxDepth`; this document discusses only how their setup fits the lifecycle. -## Implementation model: domain terms and Cordis mechanics +## Cordis model: context, fiber, effect, receiver, and waterfall -Readers need four domain terms and four Cordis mechanics to follow the implementation. Readers already familiar with this codebase and Cordis can skim this section. +Five Cordis ideas are required to understand the implementation. A context selects services and registration ownership; a fiber is one live plugin or child lifecycle; an effect attaches cleanup to a fiber; an event receiver selects listeners; and a waterfall lets listeners transform or veto an operation in sequence. -### Recurring domain terms +### A context is an ownership path through one service graph -Four domain terms keep the rest of the RFC compact. A **Session** is an agent run's append-only event log, from which model history and durable replay are derived. **Lossless JSON** is the JSON subset that can be copied without changing meaning: primitives, dense arrays, and plain objects; cycles, sparse arrays, exotic prototypes, non-finite numbers, negative zero, `undefined`, `bigint`, functions, and symbols are rejected. An **end capability** is an actual callable tool implementation, whether the model sees it as a native schema or a Code Mode binding. **Code Mode** gives the model a generated SDK and a reserved `run_code` transport. Pure `code` presentation replaces native advertisement with that interface; `both` presentation retains native schemas alongside it. +All agents share one Cordis service graph. A derived context does not clone `ToolRegistry`, `SystemPrompt`, persistence, or model adapters; it changes how registrations made through that context are tagged and which effects own their cleanup. -### Four Cordis mechanics +`agent.ctx` is such a derived context. Service calls still reach the shared instances, while a registration can inspect its calling context and store a contribution under the nearest scope key. Ordinary plugin contexts carry no scope key and therefore register globally. -Contexts select service access and registration origin, fibers own effects, waterfalls provide cooperative transformation, and dispatch receivers select listeners. +### Fibers and effects make cleanup structural -| Cordis concept | Meaning in this RFC | -|---|---| -| Context | The object through which a plugin reaches services and registers contributions; a derived context can carry a different registration scope | -| Fiber and effect | The runtime owner and one owned piece of setup/cleanup; disposing the fiber unwinds its effects | -| Waterfall | Ordered around-middleware whose listener calls `next()` to include downstream work and may transform or short-circuit the result | -| Dispatch receiver | The `this` object used by Cordis listener filtering; a scope carrier encodes the operation's agent key | +A Cordis fiber is the live instance created when a plugin or child context is activated. Its state records whether that lifecycle is active, unloading, failed, or disposed. `ctx.effect()` and `ctx.on()` return disposers and also attach those disposers to the registering fiber, so unloading a plugin or agent scope removes everything registered through that context without a separate inventory. -#### Context selects both service access and registration origin +The vendored Cordis fiber implementation establishes ownership before arbitrary setup or `internal/plugin` observers run. A reentrant unload can see the child fiber or effect that has started, reject effects added after unload begins, and join cleanup already started through a public single-shot disposer. Teardown observers are contained individually so one callback cannot prevent structural cleanup. -A Cordis `Context` is the object through which code calls services such as `ctx.tools`, `ctx.systemPrompt`, and `ctx.sessions`. A service can recover the context through which it was accessed, so the same method can register globally from a plain plugin context or locally from `agent.ctx` without adding an `agent` option to every registration API. Cordis implements contextual service access with a **traced receiver**: a proxy that carries the accessing context while forwarding calls to the concrete service object. +These are framework lifecycle guarantees rather than agent-specific policy. Agent creation depends on them because setup can activate arbitrary plugins and synchronously reenter owner disposal. -```js -ctx.tools.register(globalTool) -agent.ctx.tools.register(agentOnlyTool) +### Receivers route listeners; waterfalls compose decisions -ctx.on('tools/result', globalObserver) -agent.ctx.on('tools/result', agentObserver) -``` +Cordis filters listeners using the dispatch receiver (`this`), while harness listeners need an explicit agent, execution, request, or other subject. `Scoped` marks the receiver expected by a scoped event declaration, but the runtime carrier deliberately exposes no subject API. -A context also exposes the dependency view injected into the plugin that minted it. `agent.ctx` therefore carries the agent loop's deliberate service surface; it is not an ambient root context or a security boundary. +Product helpers therefore construct the carrier and pass the domain subject separately. This prevents listener routing from becoming an alternate object model and keeps event signatures understandable without knowledge of carrier internals. -#### Effects make cleanup follow ownership +A Cordis waterfall is middleware-style dispatch. Each listener receives `next()`: calling it delegates to the remaining listeners and base operation, while returning without it vetoes or replaces the downstream result. Waterfalls power prompt assembly and tool policy; ordinary emit events notify synchronously, and parallel events await all listeners without a veto result. -An effect is setup whose cleanup belongs to a fiber. Tool registration, prompt contribution, event subscription, and an agent scope are effects, so normal disposal, failure, and hot module reload all follow the same ownership graph. +## Scope routing: one opaque key selects one layer -```js -ctx.effect(() => { - const resource = openResource() - return async () => { - await resource.close() - } -}) -``` +The scope package implements the smallest object needed for Cordis routing. Its carrier holds only a composed service filter and scope predicate, while the package records the opaque key privately and exposes the scope fiber's quiescent disposer separately. -Cordis also supports generator effects that nest child effects in a chosen teardown order. The lifecycle section explains why construction must become owner-visible before arbitrary callbacks run. +### Scope identity uses object identity -#### Waterfalls remain cooperative extension points +A `ScopeKey` is an opaque object compared by identity. The harness uses the live `Agent` as its own key, but the primitive is domain-neutral and supports other scoped owners. -A waterfall listener wraps downstream work. Calling `next()` includes the remaining listeners and base implementation; returning directly skips that downstream portion. +`createScope(parent, key)` returns a scope whose `ctx` shares the parent's services and whose effects are tagged with that key. `scopeOf(ctx)` reads the nearest registration key. `scopeTarget(base, key)` creates the event receiver whose filter preserves the base receiver's Cordis service filter, then admits unscoped listeners and listeners with that exact key. -```js -ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { - const downstream = await next() - return { - ...downstream, - sections: [...downstream.sections, extraSection], - } -}) +The receiver is a small carrier rather than a transparent proxy for the domain object. Code that needs the agent receives the explicit event argument; code that needs registration ownership receives `agent.ctx`. -ctx.on('system-prompt/assemble', async () => replacementAssembly) -// The direct return skips this listener's downstream/base. An outer listener -// that already awaited next() still resumes around replacementAssembly. -``` +### Registry reads overlay one exact map -This flexibility is intentional for ordinary policy, but it cannot express a fact that must remain true after every wrapper and short-circuit. [Owner-final policy](#owner-final-policy-four-narrow-boundaries) adds only the four final checkpoints that need stronger semantics. +Scope-aware registries store global contributions separately from identity-keyed local contributions. A read resolves the global layer and at most one local layer; it never traverses parentage. -#### Dispatch receivers select scoped listeners +Each service retains its domain rule. Named prompt values and tools use local shadowing, tool restrictions filter globals before local tools are added, and events select listener audiences rather than registered data. Scope supplies identity and ownership, not a universal merge algorithm. -Cordis filters listeners using the dispatch receiver, the object visible as `this` inside a function-style listener. `dsh-scope` builds a receiver carrying the operation's scope key, allowing global listeners plus listeners registered for that exact key while rejecting other agents' listeners. +### Fused dispatch helpers prevent subject drift -The receiver is live coordination state, not durable session data. For example, `tools/result` is a live final-outcome notification, while `tool/result` is an append-only session event used for replay and model history. +`agentEvents(context, agent)` constructs the agent's carrier and injects the same agent as the event subject. Session, tool, approval, prompt, and subagent services likewise derive routing from the object they already own instead of accepting an unrelated key. -## Registration and delivery: global plus exactly one agent layer +The type marker rejects ordinary bare-receiver mistakes, and development invariants cover direct JavaScript or casted dispatch. The subject remains explicit because routing correctness and useful event data are different concerns. -Within services that adopt the agent-scope contract, one scope key controls both registered data and registered behavior. Contribution reads combine the deployment-global layer with exactly one agent layer, while scoped event dispatch admits global listeners plus the listeners for that same agent. +## Agent creation: one transaction owns the complete operation -Scope keys are opaque objects compared by identity; a live `Agent` is its own registration key. There is no name-based equality or parent traversal. +Create and resume are one asynchronous lifecycle with several phases, not several lifecycles. `AgentCreationTransaction` owns caller and factory liveness, optional cancellation, private resources, publication, rollback, and the memoized teardown observed by every owner. -### Scope mechanism: context, key, and lifetime +### Registry entries are the only live identity records -The registration context selects the layer, the scope primitive binds that layer to cleanup, and the nearest scope tag—not an inherited convenience property—selects the key. +AgentRegistry and SessionStore each keep one entry per live object. The entry holds the stable ID, object, scoped carrier, and the small amount of publication or append state that belongs to that object. -#### The calling context selects visibility and cleanup +A detach closure captures its exact entry. It deletes only when the map still points to that entry, so an old disposer cannot delete a later object that reuses the same ID. No registry rereads a mutable caller object to decide identity. -A scope-aware registry method recovers the Cordis context through which its service receiver was accessed and calls `scopeOf(context)` once while installing the registration effect. An absent key selects the global store; a key selects the per-scope store. Cleanup closes over that accepted store and key, so later context mutation or a same-named replacement cannot redirect disposal. Event listeners follow a different Cordis path: `ctx.on()` retains the registering context, and targeted dispatch reads its scope while filtering listeners; `{ global: true }` deliberately bypasses that audience filter without changing cleanup ownership. +There is no reservation API. Caller-supplied IDs are admitted at final entry. Concurrent same-ID operations may both complete private setup; exactly one final `enter()` succeeds, and every loser rolls its private resources back. Sequential reuse is valid after the earlier disposer reaches quiescence. -The [public contract](2026-07-08-agent-scope-contexts.md#registration-origin-selects-visibility-and-cleanup) owns the visibility table, shadowing rule, and `{ global: true }` listener exception. Internally, registry resolution overlays one exact identity-keyed map on the global map and never traverses an ancestry relation: +### The transaction owns preparation before awaiting it -```text -resolveLayer(agentA): - visible = copy(global registrations) - visible.overlay(registrations from agentA.ctx) - return visible -``` +The transaction is installed under both the calling Cordis context and the concrete AgentLoop factory before persistence load or setup can suspend. It also observes an optional create/resume signal until the public operation settles. -The one-overlay algorithm is why the generic primitive needs only opaque identity and effect ownership; parent/child meaning stays outside `dsh-scope`. +Create prepares a new Session. Resume loads and validates the persisted Session before preparing the same live session identity. Both paths then build the scope, agent, and driver and invoke the same setup/publication algorithm. -#### The scope primitive keeps layer and owner together +The factory stores concrete trace targets but invokes them through a caller-bound Cordis trace. This preserves dependency origin and caller ownership without stacking trace proxies. -`dsh-scope` exposes only the operations needed to mint a tagged ownership layer, read its key, target dispatch, and reach quiescent cleanup. For ordinary scope-aware registries, using a separate `{ scope }` option could express “stored in A's layer, disposed with B”; registration through the scoped context makes that mismatch unrepresentable. The `{ global: true }` listener option is an intentional audience exception, and the low-level `scopeTarget(base, key)` primitive still relies on its service-owned caller to supply matching facts. +### Setup is trusted composition inside a private world -| Operation | Responsibility | -|---|---| -| `createScope(context, key)` | Mount an ownership fiber and return its tagged context | -| `scopeOf(context)` | Read the nearest inherited scope key | -| `scopeTarget(base, key)` | Build a scope-filtered dispatch receiver around the existing base receiver | -| `Scope.dispose()` | Return one shared idempotent promise that reaches cleanup quiescence | -| `Scope.rawDispose` | Expose the exact Cordis disposer for ordered generator composition | +Setup receives the full child context and may await plugin activation. It can register tools, prompt sections, restrictions, listeners, and other effects, but the public contract does not support driving or publishing the in-flight agent through casts or internal registry calls. -`Scope.dispose()` and `rawDispose` serve different callers. Cordis raw disposers are single-shot, so a repeated raw call need not wait for an earlier asynchronous teardown; the public method follows the backing fiber's in-flight cleanup and gives racing callers the same completion promise. Generator lifecycles use `rawDispose` because Cordis recognizes nested ownership by exact disposer identity. +The transaction races asynchronous load and setup against deactivation rather than waiting forever for a promise owned by external code. If cancellation or owner unload wins, public creation rejects after transaction-owned cleanup even when the external promise never settles. -The primitive has one essential shape: +### Publication has one ordered commit path -```text -createScope(parentContext, key): - fiber = mount no-op plugin under parentContext - scopedContext = derive fiber.context with nearest-scope-tag = key +Publication admits and announces resources in the order required by observers: - rawDispose = fiber's exact disposer - dispose = memoized operation that: - invoke rawDispose if teardown has not started - follow fiber's in-flight cleanup until quiescent +1. Enter the session. +2. Enter the agent. +3. Announce `session/created`. +4. Announce `agent/created`. +5. Enable public driving. +6. Emit `agent/session-start`. +7. Start the driver. - return { ctx: scopedContext, rawDispose, dispose } -``` +The agent never drives before both registries and creation notifications agree. A synchronous listener may veto or dispose an owner; the transaction records publication in progress and waits for that callback stack to unwind before teardown continues. Every creation announcement that begins has a matching disposal announcement during rollback. -Derived contexts inherit the nearest tag. Mounting a plugin under `agent.ctx` preserves the agent scope; deliberately creating another scope replaces the tag below it. - -#### `ctx.agent` is an association; `scopeOf()` selects the layer - -`agent.ctx.agent` gives setup code convenient access to the associated agent, but the nearest scope tag remains authoritative for resolution. A nested scope can inherit the ergonomic `agent` property while replacing the registration key. - -```js -const auditKey = {} -const auditScope = createScope(agent.ctx, auditKey) - -auditScope.ctx.agent === agent // true: inherited association -scopeOf(auditScope.ctx) === auditKey // true: nearest registration key - -await auditScope.dispose() -``` - -This separation keeps the generic scope package independent of the agent package. - -### Resolution contracts preserve domain semantics - -The shared scope selects two layers, but each registry retains its own merge rules and must keep presentation, lookup, and execution coherent within the view it owns. - -#### Registries retain domain-specific merge rules - -The shared primitive answers “which layer?” and “who owns cleanup?”; each service still defines how its values combine. Prompt sections, variables, and tools use scoped-over-global shadowing by name. Tool-schema providers are additive. Tool lookup and execution receive an agent or scope explicitly, while prompt assembly receives an `AssembleContext` whose `scope` selects the layer. - -Calling a read method through `agent.ctx` does not silently choose an agent subject. For example, `agent.ctx.systemPrompt.assemble()` without an assembly scope still requests the global view. Registration origin and operation subject remain explicit, allowing one shared service to act for any agent. - -#### The tool view is live and executable - -`ToolRegistry` owns one resolver rather than separate presentation and execution stores. It snapshots restriction definitions at registration, applies them to the current global map, overlays the matching scoped map, and derives lookup, dispatch, schemas, SDK bindings, timeouts, inspection, and UI presentation from that resolved map. A filtered global implementation therefore cannot remain executable through a second path. - -The [public RFC](2026-07-08-agent-scope-contexts.md#tool-restrictions-resolve-against-a-live-flat-view) owns the exact allow/deny/future-global/local-overlay behavior. The internal distinction needed here is that `ToolRegistry.knownNames()` validates restrictions against the pre-restriction end-capability universe, while the system-prompt provider validates `toolOrder` against the presentation mode's wire universe. - -Final prompt assembly can include schemas from other `systemPrompt.tools()` providers or assembly listeners. The coherent-view proof therefore covers `ToolRegistry`'s own schemas, bindings, lookup, execution, and presentation; another provider owns coherence for the unrelated schemas it contributes. - -Reserved `run_code` presentation sits outside both registration maps. The [Code Mode RFC](../feature/2026-06-15-code-mode.md) owns its mode, SDK, and `toolOrder` semantics; this design relies only on the fact that the transport is resolved separately from filterable end capabilities. - -### Dispatch contract follows the operation subject - -Service-owned dispatch paths derive or couple the scope key with the operation subject, and a carrier composes that key with the chosen base receiver's existing dispatch behavior. For agent events the agent is both base and operation subject; tool, approval, and prompt dispatch instead wrap their owning service while selecting listeners with the operation's agent key. The low-level primitives can still represent mismatched facts, so helper use and development invariants—rather than the type system alone—protect direct internal callers. - -#### The operation subject selects the listener set - -The [public dispatch rule](2026-07-08-agent-scope-contexts.md#scoped-events-follow-the-operations-real-subject) and generated [event catalog](../../../cordis-catalog/events.md) own listener visibility and the exhaustive event-family mapping. The implementation problem is to prevent each service from choosing its carrier, subject argument, and scope key independently. - -Fused helpers keep values that must agree together. `agentEvents(context, agent)` uses one agent as the subject, scope key, and first event argument. `assembleContextFor(agent)` sets both prompt facts and the scope selector. The session store captures its carrier when a session enters because later appends and flushes may occur without the original agent context. - -#### The carrier preserves base-receiver behavior - -Function-style listeners receive the carrier as `this`. Agent listeners may call methods on the agent base; service listeners rely on the owning service's contextual receiver behavior. The carrier is therefore a proxy that selects listeners while reading, writing, and invoking through the real base receiver. - -The implementation uses a dedicated surrogate proxy target with an immutable composed-filter slot. It combines the base receiver's existing `Context.filter` with the scope predicate instead of replacing it. Methods bind to the real base; callable carriers preserve call and construct shape; descriptor queries normalize configurable flags as required by Proxy invariants; and definitions through the carrier require an explicitly configurable descriptor. Stable built-in references protect the composed filter from accidental `.call` replacement. - -Those mechanics preserve observable JavaScript behavior, including private-field method identity: - -```js -class Base { - #count = 0 - increment() { this.#count += 1 } -} - -const base = new Base() -const key = {} -new Proxy(base, {}).increment() // TypeError: proxy lacks Base's private identity - -const carrier = scopeTarget(base, key) -carrier.increment() // works: method is bound to base -carrier === base // false: carrier has distinct identity -``` - -Together these constraints keep listener selection correct while preserving the base-receiver behavior listeners expect. - -The TypeScript-only `Scoped` marker requires a carrier at typed dispatch sites. Runtime marks and development invariants cover JavaScript, casts, and direct Cordis dispatch; they detect routing mistakes but do not confine hostile same-process code. - -## Lifecycle: compose privately, publish once, tear down in reverse - -Scope, session, registry entry, and driver form one transaction with two owners. Request fields are captured first; AgentLoop tracking and both identity reservations precede asynchronous work; the caller owns the prepared lifecycle before setup; publication proceeds in synchronous observable phases; and every teardown path reaches one reverse-order quiescence boundary. - -Two services split the public API from the implementation. `AgentRegistry`, reached as `ctx.agents`, stores live agents and is the front door for `create()` and `resume()`. Its registered `AgentFactory` is concretely implemented by `AgentLoop`, which constructs and drives agents using its own injected dependencies. The rest of this section calls that concrete co-owner the **AgentLoop factory**. - -| Phase | Public state | Ownership fact | -|---|---|---| -| Reserve | IDs unavailable to competitors | AgentLoop tracking and exact reservations cover the next await | -| Prepare or load | Persistence data is loading, or session, scope, and driver exist privately | Resume's load sentinel covers persistence; the complete caller lifecycle covers setup | -| Setup | `setup(agent.ctx)` may await and register | Neither ID is published | -| Publish and start | Session, agent, and lifecycle notifications appear in order | Liveness is checked between observable phases | -| Dispose | Driver drains, registries detach, scope unwinds, IDs release | All owner paths join one completion promise | - -The [public lifecycle contract](2026-07-08-agent-scope-contexts.md#creation-publishes-after-setup-disposal-revokes-after-work-stops) defines what callers observe. The following sections justify each ownership and ordering fact behind that contract. - -### Reservations precede awaiting; lifecycle ownership precedes setup - -AgentLoop tracking and exact identity reservations precede the first await. Resume adds a caller sentinel across persistence loading; create and resume both establish the complete caller-owned lifecycle before invoking setup. - -#### The prepared lifecycle is owned before setup callbacks - -The caller context owns the work it requested and receives the consumer-facing `AgentHandle`. The AgentLoop factory is a structural co-owner because a live agent continues to depend on its injected services. Either owner can deactivate the transaction; both converge on the same lifecycle disposer. - -| Owner mechanism | Covers | Retires when | -|---|---|---| -| Caller lifecycle sentinel | Caller-fiber loss from lifecycle preparation through live lifecycle | Shared lifecycle reaches quiescence | -| Resume load sentinel | Caller-fiber loss across persistence load and lifecycle handoff | Load rollback or the adopted lifecycle reaches quiescence | -| AgentLoop tracker | AgentLoop unload and structural dependency loss | Transaction and lifecycle settle | -| ID reservations | Competing agent/session insertion | Ordered teardown releases both IDs | - -A **sentinel** is an owner-visible effect that follows work whose final disposer is not yet available. It adopts the exact reservation disposers immediately, then follows the complete lifecycle disposer once preparation establishes it. - -Cordis must make construction owner-visible before setup can reenter teardown. An effect's cleanup wrapper enters its owner list before its setup body runs, a child fiber receives its parent-owned disposer before Cordis's child-plugin notification (`internal/plugin`) announces it, and a fiber already unloading rejects new effects after taking its cleanup snapshot. Teardown observers are contained independently so one callback cannot starve peers or interrupt cleanup. These are domain-neutral lifecycle rules; `dsh-scope` uses them by mounting a no-op plugin fiber as the ownership bucket for one scope. - -#### Caller ownership and factory dependency lookup stay separate - -Factory delegation carries two contexts because ownership and dependency origin are different facts. `ownerCtx` is the caller-bound context whose fiber and optional scope own the requested lifecycle. The factory method receiver is the accepted factory traced through that access so the concrete service retains its own injected dependency view. - -```text -callerCtx.agents.create(options) - ownerCtx = context carrying callerCtx's fiber and scope - factoryThis = concrete accepted factory traced through ownerCtx - Reflect.apply(capturedCreateAgent, factoryThis, [ownerCtx, options]) -``` - -`setFactory()` captures the concrete target and its `createAgent` and `resume` callbacks once. It canonicalizes an already traced service before retracing, avoiding a second proxy layer that would break raw-identity state. Plain factory objects receive the explicit `ownerCtx` without depending on Cordis tracing. - -#### Create and resume reserve identities before awaiting - -Programmatic create and resume reserve both agent and session IDs before any operation can await. Create prepares a new or seeded session; resume loads persisted data while a caller sentinel and AgentLoop load tracker already own the interval in which no `Agent` object exists. - -Reservations are capabilities, not advisory sets. Setup code cannot reserve, prepare, create, register, or enter a substitute under the same IDs. A session reservation prepares at most one exact object, and publication requires the matching factory-held capabilities. A failed or abandoned transaction therefore cannot publish a substitute or wedge an ID indefinitely. - -Resume transfers ownership rather than opening a gap: - -```text -resume(ownerCtx, request): - snapshot request identity, options, and setup callback - reserve agentId and sessionId - install caller sentinel adopting both reservation disposers - track load under AgentLoop - - persisted = await firstOf(persistence.load(sessionId), deactivated) - session = sessionReservation.prepare(reconstruct persisted data) - starting = startOwned(ownerCtx, session, reservations, setup) - caller sentinel follows starting.dispose - return await starting.result -``` - -If deactivation wins, a backend load may still settle internally but has no path back to publication. Preparation failure still returns a rollback-backed lifecycle result, so both owners can wait for actual cleanup instead of mistaking a rejected async result for successful installation. - -### Setup composes an unpublished world - -`setup(agentCtx)` may register tools, prompt state, restrictions, listeners, protections, or child plugins and may await their activation. The new agent is available as `agentCtx.agent`, but neither agent nor session is visible in its global registry. - -The complete rollback skeleton exists before setup runs. If setup throws, rejects, or loses either owner, the scope and prepared resources unwind and the IDs become reusable. After setup settles, a microtask checkpoint and liveness checks let a same-turn owner unload win before publication. - -Setup composes but cannot drive. `send`, `steer`, `inject`, and `cancel` reject until publication reaches the session-start boundary. The driver lock and inbox use runtime-private state, and only factory-held controls enable and start the loop; JavaScript casts cannot call a public start method or write directly into the queue. - -```text -startOwned(ownerCtx, snapshot, preparedSession): - world = prepareLifecycleWithCompleteRollback(ownerCtx, snapshot, preparedSession) - - result = async: - require world active - await firstOf(runOptionalSetup(snapshot.setup, world.agent.ctx), world.deactivated) - await oneMicrotask() - require caller, factory, owner fiber, and owner agent still active - world.publish(snapshot.source) - return handle(world.agent, world.dispose) - - on any error: - await world.dispose() - rethrow -``` - -### Publication is ordered, observable, and rollback-covered - -Publication is one synchronous sequence with liveness checks between three observable notification phases. Both registry entries exist before the first listener runs, but driving stays locked until immediately before `agent/session-start`. - -1. Enter the session store and capture its scope carrier. -2. Enter the agent registry without announcing it. -3. Recheck caller and factory liveness. -4. Emit `session/created`. -5. Recheck liveness. -6. Emit `agent/created`. -7. Recheck liveness. -8. Enable driving. -9. Emit `agent/session-start`. -10. Recheck liveness. -11. Start the driver. - -```text -publish(world): - world.beginSynchronousPublication() - try: - world.detachSession = sessions.enter(world.session, sessionReservation) - world.detachAgent = agents.enter(world.agent, agentReservation) - require callerAndFactoryActive - sessions.announce(world.session) - require callerAndFactoryActive - agents.announce(world.agent) - require callerAndFactoryActive - world.driver.enableDrivingVerbs() - emitNonVetoing(agent/session-start) - require callerAndFactoryActive - world.driver.start() - finally: - world.endSynchronousPublication() -``` - -#### Creation is paired, not atomic - -Observers run between publication steps, so the sequence is not described as atomic. Effects already performed by an earlier listener cannot be retracted if a later listener throws. Instead, each registry marks a creation announcement as begun before dispatch and emits exactly one matching disposal edge during rollback. An entered object that was never announced has no disposal notification because no observer was told it existed. - -A detach requested during `session/created` or `agent/created` is deferred until that dispatch unwinds. Stable captured carriers and exact-object guards prevent a later listener from observing `disposed` before `created` or a stale detach from deleting a replacement with the same ID. The outer publication barrier likewise prevents caller or AgentLoop teardown from removing the other registry entry or unwinding `agent.ctx` while an announcement remains on the stack. - -Creation listener synchronous throws remain vetoes. Returned promise rejections are observed and logged but not awaited: publication has no asynchronous gap in which such a result could roll back safely. Disposal notifications and `agent/session-start` are non-vetoing and independently contain both synchronous throws and returned-promise rejections so one listener cannot block cleanup or later observers. - -### Teardown stops work before revoking registrations - -Every owner path reaches one memoized reverse-order transaction. It marks the lifecycle inactive, waits for an in-progress synchronous publication phase, stops the driver through actual exit and final durability work, detaches the agent and session, unwinds the scope, and releases IDs last. - -Final turn events, the turn-ending flush, and any outstanding session flush started while the agent was idle therefore run while the session and scoped listeners still exist. `agent/disposed` observes an already quiescent and unregistered concrete agent while its session remains live; `session/disposed` follows after event feed detachment and store removal. Both use the stable carrier captured for their matching creation edge. - -```text -disposeOwnedAgent(world): - mark world inactive - await world.synchronousPublicationIfRunning() - await world.stopDriver() # loop exit plus agent-started flushes - world.detachAgent() - world.detachSession() - await world.scope.dispose() - world.releaseSessionReservation() - world.releaseAgentReservation() -``` - -`AgentHandle.dispose()` gives repeated and racing consumers the same completion promise. The lifecycle-long caller sentinel follows that promise even when handle disposal wins first, while the AgentLoop ledger independently stops new transactions and waits for every structurally dependent agent before the service disappears. - -AgentLoop co-ownership follows dependency shape, not a blanket “creator owns every returned value” rule. An AgentLoop-created agent continues to depend on the loop's services, so AgentLoop unload stops it. - -## Boundary ownership: hardened paths accept once and own the accepted value - -The acceptance-sensitive paths enumerated below read caller-owned fields once and retain only owner-controlled identities or snapshots before crossing asynchronous, reentrant, model-visible, or durable-log code. This is a boundary-by-boundary implementation property, not a blanket claim about every public API. The protection is independent of TypeScript: `readonly` annotations vanish at runtime, and JavaScript accessors can return a different value on every read. - -The shared shape distinguishes identity-bearing references from data. Agent objects and abort signals are retained by identity after one read. Boundaries whose contract requires lossless JSON—such as session events and subagent payloads—validate and materialize it in one traversal; other boundaries use their own owned representation, such as `structuredClone` for agent options. Scalars and callbacks are captured once, then each boundary applies the validation promised by its API before downstream use. - -```text -accept(input): - read every relevant top-level field exactly once - retain identity-bearing references without rereading them - validate acceptance-time fields from those captures - copy or pin data in the representation owned by this boundary - bind accepted callbacks once when method receiver state is intentional - expose only owner-controlled identities, frozen records, or detached results -``` - -Capture does not imply uniform eager callback type-checking. Agent `setup` is captured once and any invocation failure enters rollback; a tool guard is likewise captured, and an invalid cast becomes a normalized execution error. The invariant is that later work never rereads caller fields to choose a different value. - -| Boundary | Identity retained | Data detached or pinned | -|---|---|---| -| Tool and `SubagentProvider` registration | Original callback receiver | Name, flags, schemas, scalar config | -| Agent create/resume | Caller context, setup callback | IDs, options, session metadata and seed | -| Agent send/steer | None | Content blocks and resolved message source | -| Approval request | Agent and abort signal | Tool name, call ID, and reason | -| Tool execution | Agent, signal, registry-minted parent token | Call identity and arguments | -| Session append/load | Session identity | Header and event envelopes | -| Subagent start/result | Parent and signal | Prompt, filters, schema, options, result | - -Before agent setup can run, the concrete agent pins its accepted ID, options, and session and binds `ctx` once. Registry detach closures likewise close over their accepted keys instead of rereading mutable public fields. - -`send()` and running `steer()` resolve the message source once and materialize `{ content, source }` as one detached, deeply frozen lossless-JSON record before `agent/queued` or inbox insertion. The notification and FIFO share that accepted content and source; its metadata wrapper is frozen separately, so neither retained caller references nor an earlier notification listener can rewrite what a later listener, the session log, or the model sees. Invalid content or source throws synchronously without notification, enqueue, or loop wakeup; idle `steer()` delegates to the same `send()` boundary. The later `agent/prompt-submit` waterfall can still replace a queued prompt by returning new content; ownership forbids in-place mutation, not the explicit rewrite protocol. - -The inbox path makes that accepted-value boundary concrete. Getter evaluation happens during materialization, so liveness is rechecked before the accepted record crosses into an inbox FIFO: +The sequence diagram isolates the non-obvious race: a synchronous creation listener can request disposal while the publication call stack still owns both registry entries. Teardown must deactivate immediately but wait for that stack to unwind before stopping and detaching anything. ```mermaid -flowchart TB - callerInput["Caller-owned content and source"] --> initialCheck["Require a live, drive-enabled agent"] - initialCheck --> accept["Resolve source once; materialize and deep-freeze one record"] - accept -->|"invalid lossless JSON"| invalidReject["Throw synchronously; no inbox insertion, agent/queued, or loop wakeup"] - accept -->|"accepted"| liveness["Recheck disposal after caller getters"] - liveness -->|"disposed reentrantly"| disposedReject["Throw disposed; do not insert or announce the message"] - liveness -->|"still live"| inbox["Insert the record into the queued or steering FIFO"] - inbox -->|"same frozen content and source"| queued["Emit agent/queued with a frozen metadata wrapper"] - inbox -->|"if later drained, read the same owned record"| drain["Loop-owned delivery"] - inbox -->|"cancel before drain"| cancelled["Clear the pending record without delivery"] - inbox -->|"disposal wins before drain"| disposed["Stop delivery; the disposed agent may retain the pending record"] - drain -->|"queued prompt"| prompt["agent/prompt-submit may block or explicitly replace"] - drain -->|"steering consumed by an active turn"| steering["Append steering/message"] +sequenceDiagram + participant Tx as AgentCreationTransaction + participant Registries + participant Listener as Synchronous listener + participant Driver + + Tx->>Tx: mark publication in progress + Tx->>Registries: announce agent/created + Registries->>Listener: invoke inside the same call stack + Listener->>Tx: dispose reentrantly + Tx->>Tx: deactivate, teardown waits for publication + Tx-->>Listener: disposal request accepted + Listener-->>Registries: return + Registries-->>Tx: announcement unwound + Tx->>Tx: resolve publication settlement + Tx->>Driver: stop and drain + Tx->>Registries: detach agent, then session + Tx->>Tx: dispose scope and resolve teardown ``` -A stateful getter shows why validation and ownership must use the same capture: +### Teardown preserves work before revoking registrations -```js -let reads = 0 -const input = { - get name() { - reads += 1 - return reads === 1 ? 'safe_tool' : 'different_tool' - }, -} +Every teardown request joins one memoized path. The order is: -// Wrong: validation and storage observe different values. -validateName(input.name) -storeName(input.name) +1. Deactivate creation or driving and let synchronous publication finish. +2. Stop and drain the driver, including idle injection flushes. +3. Detach the agent. +4. Detach the session. +5. Dispose the agent scope. +6. Retire transaction ownership tracking. -// Right: one accepted value drives both. -reads = 0 -const acceptedName = input.name -validateName(acceptedName) -storeName(acceptedName) -``` +This order lets final agent and session events use the matching scoped listeners and keeps persistence observers attached through the final flush. Scope disposal comes last because registration revocation is the externally visible lifetime boundary. -### Registered definitions are frozen snapshots +## Session append: materialize, validate, commit, notify -Tool registration creates the stored definition identity once; changes occur through explicit unregister/register effects rather than mutation of a caller-retained object. Parameters are materialized in one traversal, callbacks bind once to the accepted definition receiver, and the stored record is deep-frozen. +Session events cross a durable boundary, so append owns their data. The rest of the algorithm uses one attached entry and one commit point. -The first-party `defineTool()` helper applies the same boundary before registration. It captures each option once, materializes the authoring `SchemaSpec`, and derives both the wire schema and later execution/presentation validation from that owned spec. +### Durable data is materialized once -```text -defineTool(options): - accepted = read each option exactly once - parameterSpec = snapshotLosslessJson(accepted.parameters) - wireSchema = snapshotLosslessJson(convertToJsonSchema(parameterSpec)) - build execute and presentation validation over parameterSpec +Session headers, seeds, and appended events are lossless JSON data. The Session constructor or append path materializes and validates them before storage and exposes frozen snapshots, so later caller mutation cannot change persistence, replay, or model reconstruction. -registerTool(context, definition): - accepted = read each definition field exactly once - stored = deepFreeze({ - accepted name, description, timeout, - parameters: snapshotLosslessJson(accepted.parameters), - execute: bind accepted.execute to definition, - presentation callbacks: bind accepted callbacks when present - }) - layerFor(scopeOf(context)).add(stored.name, stored) -``` +This is a real ownership boundary: the values leave the caller, may be persisted, and must reconstruct the same request later. It is intentionally stricter than a typed same-process callback or registry definition. -`get()` and `visible()` return the frozen stored definitions; `schemas()` returns detached projections. Replacing `definition.execute` after registration has no effect, while a callback can deliberately read live state from its closure or original receiver. +### Pre-commit listeners can veto; post-commit observers cannot -Factory and backend registration use different reentrancy orderings around the same ownership rule. `AgentFactory` registration claims its single slot before reading callback accessors. `SubagentProvider` registration first snapshots the provider fields, then its effect checks and enters the accepted name. Both capture callback identity and intentional receiver state once, and hot-reload cleanup closes over the accepted slot or key instead of rereading a mutable public property. +Append follows one sequence: -### Durable session ownership carries the scope key +1. Materialize the durable event and surface intent. +2. Claim the SessionEntry and reject reentrant append on that entry. +3. Resolve scoped callbacks and run internal invariant validation. +4. Push exactly once; this is the commit point. +5. Notify each observer independently, containing synchronous and asynchronous failures. +6. Release append state and honor a detach requested during publication. -The [session-immutability RFC](2026-06-11-dev-invariants-over-deep-readonly.md#session-owns-immutable-history) owns header, event, and snapshot semantics. Agent-scope correctness adds one requirement: the store keeps append publication, accepted registry IDs, and captured scope carriers in private owner state rather than caller-writable fields. Outside JavaScript therefore cannot rename a stored session or redirect later `session/event` delivery by mutating visible state. +No observer error makes a committed event look uncommitted, and one bad listener cannot starve later listeners. Session invariants stage their transition before commit and apply it only when the same event reaches the contained post-commit observer. -An entered session treats append as one synchronous acceptance-and-publication boundary: +`flush()` starts every persistence listener and awaits every result before reporting failure. This deliberate all-settled behavior prevents a synchronous failure from starving another backend or final flush. -1. Capture the current store attachment and its private attachment epoch, keep the attachment live, then materialize and deep-freeze the caller's event data. -2. Reject if caller getters changed either value; the epoch catches even a transient attach-then-detach that restores the original hook lookup. The event must not become live without the store hooks that accepted it. -3. Resolve the exact scoped `session/event` callback list before commit. Cordis runs `internal/dispatch` during this step, so development invariants can still reject a bad candidate while the log is unchanged. Resolution uses a throwaway mutable argument array; replacing its accepted session or event rejects before commit, and product callbacks later receive a fresh fixed tuple. -4. Push the event into the log. This is the commit point. -5. Invoke the captured callbacks with per-listener containment and best-effort non-throwing failure reporting, then release the attachment barrier and honor any detach requested during acceptance or publication. +## Trust boundaries: copy only when ownership actually changes -The boundary rejects a reentrant `append()` until the outer callback list drains. Without that guard, an early observer could append event N+1 before a later persistence observer had received event N, reversing delivery relative to the log. Detach is deferred for the same interval, so no event can commit after `session/disposed` or lose its publication hooks. Once the push occurs, synchronous observer throws and returned-promise rejections are logged and contained rather than escaping as a false append failure or starving later observers. +The runtime distinguishes typed in-process contracts from serialization and durability boundaries. This is the main simplification rule for values and callbacks. -`SessionStore.flush()` uses the same pre-dispatch fixed-tuple check but remains an awaited durability barrier rather than an observe-only publication. It starts every captured listener synchronously, converts a synchronous throw into that listener's rejected result so later listeners still start, waits for every result to settle, and only then rejects with the first failed listener in registration order. One broken backend therefore cannot make the caller return while another backend is still flushing. - -Approval requests follow the same async boundary at smaller scale: one capture preserves exact agent/signal identities, copies scalar fields, captures the session once, and drives `approval/asked`, scoped policy, cancellation, and `approval/decided` from that record. - -### Tool execution has pipeline-owned identity - -The [interception-seams RFC](../feature/2026-06-30-interception-seams.md) owns the public tool-pipeline contract. For agent-scope correctness, `ctx.tools.execute(input)` must turn caller-owned input into one pipeline-owned `ToolExecution` before any scoped policy or dispatch runs. It first reads `callId` and `name` once and requires strings; a failure there rejects because even an error result would lack trustworthy correlation identity. Once those strings are accepted, later input failures can become normal final error outcomes. - -Arguments are materialized once and deep-frozen. The registry assigns a frozen property-free `ToolExecutionToken`; callers cannot choose it. `token`, `callId`, `name`, `arguments`, `agent`, and optional opaque `parent` token become non-writable and non-configurable before policy. `signal` is the only operational field an around-dispatch wrapper may replace or remove. - -```text -prepareExecution(input): - callId = read input.callId exactly once - name = read input.name exactly once - require both are strings - - accepted = read arguments, agent, parent, and signal exactly once - require parent is absent or a registry-minted token - arguments = deepFreeze(snapshotLosslessJson(accepted.arguments)) - - execution = { - token: new frozen property-free object, - callId, name, arguments, - agent: accepted.agent, - parent: accepted.parent, - signal: accepted.signal - } - protect every field except signal - return execution -``` - -Stable execution identity prevents middleware from changing which tool or scope policy accepted. It also gives structured-output commit a safe `WeakMap` key when an adapter reuses a string call ID. Code Mode correlates an SDK sub-call with its enclosing `run_code` using only the outer execution's opaque token, never a mutable reference to the live outer object. - -Result boundaries apply the same ownership rule. Each transform returns data that is captured field-by-field, validated, materialized, and ultimately deep-frozen for final observers; malformed outcomes normalize to JSON-safe error results rather than reaching the session log as apparent success. - -## Owner-final policy: four narrow boundaries - -Waterfalls remain the ordinary extension mechanism; each of four protocol invariants runs after the last extension point capable of violating that specific invariant. Each owner-final API has the weakest one-way power that can preserve its guarantee. - -Here **canonical** means the named registry or tool-schema-provider output assembled before the waterfall—not “all output the service approves.” Protection restores only the names its owner declares. - -| Invariant | Cooperative extension point | Owner-final boundary | Guarantee | -|---|---|---|---| -| Named prompt/tool contribution | `system-prompt/assemble` waterfall | `systemPrompt.protect()` finalization | Canonical presence, absence, definition, and local anchor survive | -| Non-overridable tool denial | `tools/pre-execute` allow/deny/ask waterfall | Synchronous `tools.guard()` | A denial cannot become allow | -| Authoritative live outcome | Execute and post-execute waterfalls | Awaited `tools/result` notification | Observers receive one immutable final result | -| Terminal protocol completion | Continuation waterfall and pending steering | Serial `agent/turn-stop` | No middleware or late steering creates another step | - -### Prompt protection restores named canonical contributions - -`systemPrompt.protect({ sections, tools })` snapshots the requested names and restores their canonical registry or tool-schema-provider output after the complete assembly waterfall. Global and matching scoped protections compose by set union; a waterfall failure still fails assembly rather than triggering recovery. - -Protection covers both presence and absence. If the canonical assembly omits a protected name, finalization removes a listener-fabricated entry; this is how Code Mode keeps a native schema absent while preserving the SDK/transport form. Tool providers likewise expose one captured coherent record for schemas and optional known names, so a stateful getter cannot validate one name and display another. - -#### Global section protection reserves its name - -A globally protected section name cannot be shadowed by a scoped section. Scoped registration under an already protected name fails, and adding protection fails if a scoped shadow already exists. This check occurs before assembly because scoped-over-global merge would otherwise make the shadow itself appear canonical. - -Tool-schema protection does not create a blanket reservation for unrelated schema names. Providers are additive and may deliberately contribute other executable schemas; the owner-final guarantee covers only the named canonical contribution. - -#### Restoration preserves a useful local anchor - -Protection does not reset the whole assembly. It removes protected names from the waterfall result and reinserts each canonical entry before the first surviving later unprotected canonical neighbor, or at the end if none survives. Unprotected entries retain the order and definitions chosen by middleware. - -```text -assemble(context): - assembly = assemble registries for context.scope - canonical = snapshot protected section/tool inputs - transformed = await systemPromptAssembleWaterfall(assembly) - - for each protected canonical name: - remove every transformed entry with that name - if canonical includes the name: - insert before first surviving later canonical neighbor, else append - - return transformed -``` - -Code Mode globally protects `tools:sdk` and reserved `run_code`; structured output adds scoped protection for its instruction and capture schema. - -### Tool guards deny monotonically - -`ctx.tools.guard()` installs a global or scoped synchronous check after the complete `tools/pre-execute` waterfall and before dispatch. A guard returns a denial reason or `undefined`; it has no allow result. - -Pre-execute hooks still compose ordinary allow, deny, and ask decisions. An ask resolves through the optional approval service, where only `allowed-once` becomes allow and absence or any non-grant becomes deny. Guards run afterward, so listener order cannot convert their denial into dispatched work. - -```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 the guard. A denied call still becomes an error outcome that flows through result transformation and final observation. - -### `tools/result` observes the final live outcome - -For a successfully prepared execution, the live pipeline is `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute` → `tools/result`. Malformed non-identity input instead takes the error-shell path directly to final observation, as the algorithm below shows. The first, execute, and post stages are transformable waterfalls; `tools/result` is an awaited observe-only notification after every transform and outer error normalization. - -Every observer receives the same frozen execution and a separate deep-frozen snapshot of the owned result returned to the caller. Listener failures are contained independently, so they cannot change that returned result or starve peers. Routing uses `execution.agent`. - -`tools/result` is not the durable `tool/result` session event. The live notification also fires for direct programmatic executions and is the source of truth for in-process commit logic. The agent loop later appends the durable event for replay, UI reconstruction, and model history. - -```text -execute(input): - accept trustworthy callId and name - try to prepare pipeline-owned execution - on preparation failure: - create an identity-bearing error shell - ownedResult = owned error result - freeze execution - observerResult = deepFreeze(snapshotLosslessJson(ownedResult)) - await every tools/result observer independently with observerResult - return ownedResult - - gate = await tools/pre-execute(execution) - resolve ask through approval when needed - denial = policy denial or first guard denial - - if denied: - result = errorResult(denial) - else: - result = await tools/execute(execution, dispatchRegisteredTool) - - result = await tools/post-execute(execution, result) - ownedResult = normalize into owned lossless JSON - freeze execution - observerResult = deepFreeze(snapshotLosslessJson(ownedResult)) - await every tools/result observer independently with observerResult - return ownedResult -``` - -Waterfalls transform only at their named stages; guards only deny; final observers only observe. - -### `agent/turn-stop` makes continuation terminal - -Steering is input for another model step inside the current turn; queued prompts wait for a future turn. Ordinary continuation remains extensible: the loop computes a default, runs `agent/turn-continuation`, records any force-continue reason as steering, and treats pending steering as a reason to continue. - -The scoped serial `agent/turn-stop` checkpoint runs after that folding. A listener returns `{ action: 'stop' }` or abstains with `undefined`; malformed values and throws close the current turn with an error. A stop is terminal, so later listeners and steering cannot restore continuation. - -The loop uses `strictSerial` because ordinary Cordis serial dispatch treats `null` and `false` as abstentions. This terminal protocol permits only `undefined` to abstain, making accidental return values fail closed. - -Terminal state remains active through `turn/end` and the durability flush. Steering added by continuation, turn-close, or flush listeners is discarded after a terminal stop, while the ordinary queued-prompt FIFO remains untouched. - -```text -afterSuccessfulStep(turn): - decision = await agent/turn-continuation(defaultDecision) - record decision.reason as steering when present - if steering is pending: decision = continue - - terminal = await strictSerial(agent/turn-stop) - if terminal == stop: - discard steering - terminalStopped = true - decision = stop - - append turn/end - await session/flush - - if terminalStopped: - discard steering added by turn/end or flush listeners - else: - move leftover steering to the next-turn queue -``` - -This stronger control is reserved for terminal protocols such as a completed structured child; ordinary continuation policy remains cooperative. - -## Subagents: the composition proof - -In-process subagents add no second scoping model. They create a fresh flat child scope during unpublished setup, install ordinary scoped persona/filter/protocol registrations, own the child through a run handle, and use the same owner-final checkpoints for structured output. - -The roles and phases are explicit: - -| Role | Responsibility | +| Boundary | Ownership rule | |---|---| -| Caller | Supplies parent, prompt, optional child configuration, and eventual disposal | -| `SubagentService` | Validates capabilities, owns the public wrapper, normalizes result and lifecycle telemetry | -| `SubagentProvider` backend | Chooses transport and creates one run | -| In-process driver | Owns child creation, setup, prompt drive, result read, cancellation, and teardown | -| Child `Agent` | Uses the ordinary agent lifecycle and its fresh `agent.ctx` | +| Typed service/plugin call in the same process | Borrow readonly values and callbacks | +| Parsed plugin configuration or external file | Validate semantic and structural input | +| Queued inbox message | Materialize before asynchronous consumption | +| Model/tool JSON input or output | Materialize at the model/tool boundary | +| Durable session or persistence data | Materialize and validate before commit | +| Worker, process, or wire message | Serialize, validate, and own the decoded value | -```text -recommended caller order: start -> await run.started -> await run.result -> await run.dispose() -internal observation: started and result may settle in either order; lifecycle publication waits for started -ownership: dispose may race any phase and joins one cleanup promise -``` +Tests that fabricate hostile getters, replace typed callbacks after handoff, or cast fake service objects do not define a production contract by themselves. The runtime keeps checks where data crosses a parser, queue, model, durable, file, worker, process, or wire boundary and relies on readonly types plus plugin discipline inside the trusted process. -The [agent-scope contract](2026-07-08-agent-scope-contexts.md#subagents-use-the-same-composition-rule) gives the contributor-facing example, and the [subagent capability RFC](../feature/2026-06-21-subagent-capability-seam.md) owns the public `SubagentRun` contract. This section follows only the in-process ownership and terminal-protocol implementation. +Callback containment is separate from data ownership. Listeners are arbitrary extension code and can throw even when their arguments are trusted; publication and post-commit paths still contain failures according to their event contract. -### The child world uses ordinary registrations +## Tools and prompts: one view, one execution identity, explicit finality -A child persona is a scoped `deployment:persona` section. Its tool filter is a scoped restriction over the live global tool layer. Structured output is a bundle of scoped tool, prompt, protection, guard, and listener registrations. +Tool presentation and execution share one private resolver, while prompt/tool owners declare the few contributions that cooperative middleware may not alter finally. No second registry mirrors ownership. -```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) - } -} -``` +### One resolver defines the tool view -The driver creates one run-owner fiber under `parent.ctx` and calls the child factory through it. Parent teardown, `spawn` backend teardown, and manual run disposal reach the same node, but the child still receives a new registration key. Lifetime inheritance therefore does not imply registration inheritance. +The private resolver applies the current presentation mode, live global restrictions, exact local overlay, and local shadowing. Schemas, lookup, execution, Code Mode SDK generation, restriction validation, and owner-final name derivation all use that resolver or its pre-restriction global-name view. -### Structured output is a child-owned terminal protocol +The [subagent composition-controls RFC](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md#tool-filtering-is-one-live-global-view-rule) owns the user-visible allow/deny semantics. The implementation requirement is agreement: a filtered-away global cannot remain executable through a different lookup path, and a locally shadowed definition is the same definition presented and executed. -A structured child registers a real-schema `structured_output` tool and instruction in its own scope. Concurrent children can use different schemas without a global placeholder, reference count, or remove-for-everyone pass. +`ToolRestriction` accepts readonly allow/deny names and compiles them into internal sets. Multiple restrictions intersect. Public `visible()` and `knownNames()` methods are unnecessary because only the registry needs the intermediate views. -The [Code Mode RFC](../feature/2026-06-15-code-mode.md) owns advertised wire routes and SDK behavior. The correctness distinction here is execution nesting: a native capture has one tool execution, while an SDK capture is an inner execution whose parent token identifies the enclosing `run_code`. Tool mode is presentation rather than an execution allowlist, so a direct unadvertised capture still follows the native commit path; a deployment that forbids that route uses an execution guard. +### Tool execution owns identity and boundary materialization -Named protection restores this child's canonical capture contribution and instruction without erasing unrelated schemas deliberately added by another assembly provider. +The registry assigns every execution a fresh branded `Symbol` token. Nested Code Mode calls carry the outer token as `parent`, so structured output can correlate an inner capture with its enclosing `run_code` result by identity. -#### Native calls commit once; Code Mode SDK calls commit twice +A fresh registry-assigned Symbol provides collision-free execution identity without a WeakSet membership registry. Callers cannot supply the execution's own token through `ToolExecutionInput`; they only receive the pipeline-owned `ToolExecution` after the registry creates it. This is a trusted typed contract, not a runtime defense against arbitrary casts or JavaScript callers. -The capture body validates and stages a cloned value by stable `ToolExecution` identity. The scoped final-result observer commits a native capture only if that exact execution's final result succeeds. +Arguments are materialized once where model/tool JSON enters the pipeline. Pre-, around-, and post-execute listeners operate on the typed execution and decisions. Call ID correlation, approval, monotonic guards, and Code Mode nesting remain explicit relational checks. -A schema-validation failure becomes the ordinary `INVALID_ARGS` tool result, so the model can correct the value and call the capture tool again within the same turn. +After the last post-execute listener, the registry materializes and freezes the accepted final result once. Every `tools/result` observer receives that exact committed object, and observer failures are awaited and contained individually. An outer pipeline failure is normalized into a committed error result, so observers can discard staged work against the same authoritative boundary. -```text -structured_output.body(value, execution): - validate value against this child's schema - staged[execution] = clone(value) - return ordinary success +### Contribution-owned finality protects only named invariants -on tools/result(execution, finalResult): - if execution is staged: - value = staged.remove(execution) - if finalResult succeeded: - captured = value -``` +Most prompt assembly remains a cooperative waterfall: listeners may reorder, replace, or remove ordinary sections and schemas. A contribution sets `ownerFinal: true` only when its owner must retain final control over that named entry. -For a Code Mode SDK call, successful inner observation records a pending value against the opaque outer `run_code` token. Commit waits for the outer transport's own successful final result because an inner side effect can succeed while the program or its post-policy still fails. +Prompt sections carry owner-finality directly. Tool definitions carry it through the tool provider's `ownerFinalNames`, including canonical absence when a presentation mode intentionally omits a tool. `tools:sdk`, `run_code`, and structured-output instruction/schema contributions use this flag. -```text -on tools/result(innerStructuredCall, innerResult): - if innerStructuredCall is staged: - value = staged.remove(innerStructuredCall) - if innerResult succeeded: - pending = { outerToken: innerStructuredCall.parent, value } +An owner-final name is reserved across the global and scoped layers: a scoped shadow cannot be added beneath a global owner-final contribution, and a global contribution cannot become owner-final while any scoped shadow already exists. This makes the registered owner definition unambiguous before assembly begins. -on tools/result(outerRunCodeCall, outerResult): - if pending.outerToken == outerRunCodeCall.token: - value = pending.value - pending = none - if outerResult succeeded: - captured = value -``` +Assembly takes one private canonical snapshot before the waterfall. After listeners finish, it restores only owner-final names to their canonical presence, absence, definition, and relative anchor among surviving entries. Unrelated listener additions and reordering remain untouched. -Once capture is staged against an outer transport or committed, the scoped guard denies later calls in that response. After commit, `agent/turn-stop` ends the turn after ordinary continuation and steering fold. A child that otherwise completes cleanly without a committed capture returns an error rather than being re-prompted; requesting a schema makes output mandatory, not guaranteed. +Attaching finality to the owning contribution has two benefits. Registration and cleanup cannot drift from a separate protection registry, and the reader can see why a particular prompt/tool entry is special at its definition. -### The run protocol separates acceptance, readiness, result, and disposal +### Structured output commits only authoritative outcomes -`SubagentService.start()` returns synchronously, but `run.started` is the publication boundary. Callers treat the child as live only after readiness, consume `result`, and always dispose the run. +Structured output uses the final prompt/tool boundaries as a two-phase commit. The child-scoped `structured_output` tool and its instruction are owner-final; the tool body validates a candidate and stages it by the current `ToolExecution`, but successful capture is decided only by immutable `tools/result` observations. -Pre-readiness cancellation of an in-process run deactivates the run-owner fiber, prevents publication, rejects `started`, resolves `result` as `aborted`, and emits neither subagent lifecycle edge. +For a native call, the observer deletes the stage and commits its value only when that exact execution's final result succeeds. A post-execute block or outer pipeline failure therefore cannot leave a captured value behind. -`SubagentProvider` registration captures name, capability flags, the `inheritsParentContext` conversation-history descriptor, and the bound start callback once. The descriptor says whether completed parent turns seed the child's conversation; it says nothing about scope, services, tools, or authority. +For a Code Mode SDK call, the inner successful result records `{ parentToken, value }` rather than committing. The observer waits for the `run_code` execution whose token matches `parentToken` and commits only if that outer final result also succeeds. Program failure, runtime abort, or outer post-policy denial discards the pending value. -Starting a run captures every request field once. Parent and abort signal remain identity references; prompt, filter, schema, and options are detached lossless JSON; fixed `persona` and absolute `maxDepth` values validate before backend ownership. The in-process backend separately snapshots its optional session seed, and the service snapshots the terminal result when it settles. +Once a value is pending or committed, a scoped monotonic guard denies later tool calls. After commit, the ordinary serial `agent/turn-stop` listener returns a stop decision after continuation and steering have already folded. A schema-validation failure remains an ordinary `INVALID_ARGS` tool error and leaves the child able to retry within the same turn. -Depth validation repeats at each public entry while one helper owns the accepted domain: +Pure Code Mode omits `structured_output` from native wire schemas and exposes it through the generated SDK. Contribution-owned finality preserves that canonical absence, preventing an assembly listener from fabricating a second native route while keeping the instruction and SDK declaration intact. -```text -tool-subagent plugin load: - assertSubagentMaxDepth(config.maxDepth) +### Four final boundaries have four narrow powers -SubagentService.start(request): - capture and validate request.maxDepth +Owner-final behavior is not a general priority system. Four domain owners need four different one-way powers after cooperative extension points: -startInProcessRun(request): - capture and validate request.maxDepth - parentDepth = validated depthOf(parent) - childDepth = parentDepth + 1 - reject if childDepth is not a safe integer - reject if maxDepth exists and childDepth > maxDepth -``` - -Only `undefined` means parent depth zero. Present depth and cap values must be non-negative safe integers and must not be negative zero; derived overflow rejects even when no request cap exists. - -The service does not expose the backend-owned run handle directly. It captures `id`, `started`, `result`, and methods once; binds methods to that handle; wraps result in one detached frozen record; and installs a shared disposal promise before calling untrusted backend cleanup. Once a callable backend disposer has been captured, a malformed later field triggers rollback; if no callable disposer can be captured, rollback is impossible and acceptance fails immediately. A backend disposer that directly returns the wrapper's reentrant promise is rejected as a cycle instead of hanging. - -```text -startInProcessRun(backendContext, acceptedRequest): - install backend ownership - attach accepted abort signal - create run-owner fiber under accepted parent.ctx - create child through runOwner.ctx.agents with unpublished setup - - started = child creation publication - result = after started: - send accepted prompt - await child idle - derive owned terminal result - dispose = dispose run owner and await quiescence - -SubagentService.start(...): - backendRun = backend.start(detached request) - serviceRun = freeze accepted id, readiness, bound methods, normalized result - observe result immediately - after readiness: - emit subagent/start, then buffered/eventual subagent/end - on readiness failure: - emit neither lifecycle edge -``` - -The service observes result settlement immediately even while readiness is pending, preventing an early rejection from becoming temporarily unhandled. Lifecycle listeners receive one frozen payload; their throws and returned-promise rejections are contained independently and cannot veto the run. - -## Workflow integration preserves the subagent contract - -The [dynamic-workflows RFC](../feature/2026-07-05-dynamic-workflows.md) owns workflow behavior. The agent-scope concern is whether the worker bridge preserves the same readiness, terminal-claim, and bounded-cleanup boundaries across a message port. It never announces an unready child, never lets cleanup rewrite an already chosen result, and never suppresses disposal merely because another terminal fact already won. - -The worker executes the workflow script and exchanges protocol messages; the host owns `SubagentService`, which invokes `SubagentProvider` backends and returns normalized run wrappers that the host retains. Their lifetimes follow dependency shape: an AgentLoop-created agent stops when its loop unloads, while a workflow run captures its holder-bound `SubagentService` at start, so unloading the workflow engine prevents new runs without revoking an already returned run. - -Three state dimensions remain separate: - -| Dimension | Question | Winning rule | +| Boundary | Final power | Why ordinary listener order is insufficient | |---|---|---| -| Admission | May a worker message still start or announce a child? | Closed admission refuses the exact run and cleans it up | -| Terminal claim | Which external result does the workflow expose? | Earlier accepted external cancellation wins; otherwise first result/death claim wins | -| Physical cleanup | Which registered children and worker resources remain? | Every path may still dispose survivors through per-call gates | +| Prompt assembly | Restore named canonical contributions | A later listener can remove or replace an invariant schema or instruction | +| Tool pre-policy | Deny monotonically | A later listener must not re-allow an already denied call | +| Tool result | Observe the immutable committed outcome | Structured output must commit only the result that actually escaped the pipeline | +| Turn continuation | Stop after ordinary continuation folding | A committed terminal output must end the turn | -### Child admission waits for readiness +`ToolGuard` remains the monotonic policy registry. Final tool observation is the contained `tools/result` point described above. Terminal structured output listens on the ordinary serial `agent/turn-stop` fold after normal continuation and steering decisions; no public `strictSerial()` dispatcher is needed for the typed listener contract. -After `SubagentService.start()` returns its normalized wrapper, the host registers that exact wrapper before awaiting, attaches result observers immediately, and rechecks admission both then and when `started` settles. A closed boundary claims cancellation and disposal for that exact entry, removes it only when disposal settles, and reports `ChildStartError` only while the worker reply channel remains open. +### Skill and approval services trust typed callers -The backend's nested `start()` may synchronously reenter workflow cancellation before the service wrapper reaches the host registry. The immediate post-start check and exact-wrapper identity guard close that interval; a backend that later fulfills its own readiness cannot resurrect workflow admission. +Skill registry definitions and approval policies are readonly same-process contracts. Their services do not clone callback objects or defend against post-handoff callback replacement. -```text -after subagents.start returns its run wrapper: - register exact wrapper for cancellation - observe and snapshot result immediately - if admission closed: refuse and clean exact wrapper - else await run.started +Skill still validates external skill files and parsed provider output, routes catalogs through the calling agent's tool view, and disposes registrations exactly. Approval still resolves policy, observes cancellation, routes `approval/request` by `request.agent`, records the durable audit pair, and contains answerer and post-commit observer failures. - on ready: - if admission closed: refuse and clean exact run - else send ChildStarted, then buffered/eventual outcome +## Subagents: readiness is the start promise - on readiness failure: - send ChildStartError only if the worker reply channel remains open - dispose exact wrapper if still registered -``` +Subagent startup has one ownership transfer. The provider owns partial resources until its start promise fulfills with a ready published run; the caller owns the returned run and must dispose it. -### Each terminal contender claims before its own callbacks +### The service contract has one cancellation channel -Each terminal path records the state it owns before invoking its own callback fanout. External `cancel()` records the accepted cancellation reason before invoking child cancellation. On the Result path, the worker queues its `Result` message before settlement cleanup messages on the same port, and the host records the winning result before any Result-triggered abort or cancellation. Reentry therefore observes the fact that already won instead of rewriting it. +`SubagentProvider.start()` and `SubagentService.start()` return `Promise`. The promise fulfills only after the backend has established the child it promises, so callers and `subagent/start` observers never need a second `run.started` readiness promise. -```text -on workflow Result: - cancellationWasAlreadyAccepted = external cancellation is in flight - claim chosen result: - if earlier external cancellation and result is not cancelled: - cancelled result - else: - worker result +`SubagentStartRequest.signal` is required. Aborting it requests cancellation during startup and after readiness. `SubagentRun.dispose()` also requests cancellation and awaits quiescence. There is no separate public `run.cancel()` channel. - if not cancellationWasAlreadyAccepted: - abort shared child-request signal - cancel every registered child through its at-most-once gate - settle chosen result -``` +Optional `sendMessage()` supports a live backend that can accept steering. Optional `resume()` returns `Promise` because the resumed child has the same asynchronous readiness boundary. -The worker may also send a later `ChildCancel`; host fanout and the worker message share one per-call cancellation gate, so an arbitrary backend's `cancel()` need not be idempotent. Each callback is contained independently. +The service validates provider capabilities and request semantics before calling the provider. A provider rejection cleans any partial resources before the rejection escapes and emits no `subagent/start`/`subagent/end` pair. After fulfillment, the service attaches result observation, emits scoped start, and returns the run. Provider removal prevents later starts but does not revoke a run already accepted by the provider. -### Worker death, exit, and disposal remain separate +### In-process providers reuse the core transaction -The first worker death signal closes message admission, claims a death result unless an earlier terminal fact won, cancels and disposes registered children, and synthesizes missing lifecycle ends. A queued message can arrive between Node's `error` and `exit`, so the logical admission barrier—not physical exit—prevents late child creation or narration. +Spawn and fork share one in-process driver. It creates the child through `parent.ctx`, passes the required signal into the core creation transaction, and installs persona, tool restriction, and structured-output contributions during unpublished setup. -Physical exit performs a final disposal-only sweep without repeating explicit cancellation. A cancellation grace period bounds how long the host waits for cooperative settlement before terminating the worker; a grace result can already be chosen while exit cleanup still needs to dispose surviving child handles. The bound is real: after grace expires, public disposal may return after invoking child disposal and reaping host resources even if a slow backend disposer has not reached quiescence. +The provider awaits creation and returns only the published run. At the handoff, core creation detaches its creation-only abort listener; the provider immediately rechecks the signal before installing the live-run listener, so an abort in that narrow interval disposes the new handle instead of escaping cancellation. Parent teardown follows the child because the operation belongs to `parent.ctx`; provider unload blocks new starts but does not become a second revocation owner for accepted runs. The run disposer cancels the child and awaits the AgentHandle's ordered teardown. -Public `handle.dispose()` claims its shared promise before invoking cancellation or child callbacks. Each `disposeChild` likewise claims its call-ID promise before invoking the backend disposer. Public-first reentry joins the public promise; worker-first reentry lets the holder traversal join the already claimed child promise. Settled `dispose()` still drives a host-side reap before awaiting quiescence, so a fire-and-forget child cannot remain alive merely because workflow result settlement already occurred. +Spawn uses an empty session seed. Fork uses a validated completed-turn prefix. Conversation seeding changes history only and does not import scope, tools, services, or authority. -Together these rules ensure `workflow/agent-start` names only ready children, external result precedence is stable, and every surviving child reaches disposal. +### ACP providers own the process until readiness or cleanup + +An ACP provider crosses a real process and wire boundary, so it retains validation, environment scrubbing, message serialization, abort/process races, and kill-to-exit quiescence. + +Start resolves only after `initialize` and `newSession` succeed. Abort, spawn failure, RPC failure, or invalid startup response reaps the process before rejection. After readiness, result maps the ACP prompt outcome and streamed output; dispose requests cancellation, closes the connection, and awaits process exit through one memoized path. + +## Workflows and ACP UI: retain only independent async facts + +Worker and editor bridges need more state than same-process registries because messages, process death, and rendering can settle independently. Their state is organized around those real facts rather than duplicate cancellation protocols. + +### Workflow children are pending starts or published records + +The workflow host keeps pending provider-start promises and published child records. A child moves from pending to published only when async `SubagentService.start()` fulfills; rejected starts clean their partial provider work and produce no child lifecycle pair. + +One host-owned AbortController supplies the required signal to pending and live children. Closing workflow admission aborts that signal, so there is no duplicate `ChildCancel` worker RPC or explicit host-side `run.cancel()` fanout. Quiescence waits for both pending starts and published child disposal. + +The worker boundary still serializes requests and outcomes. The host retains first-terminal-outcome arbitration, exact child accounting, worker-death handling, grace termination, late/duplicate message rejection, and bounded cleanup because result receipt, worker exit, and child quiescence are genuinely independent facts. + +### Terminal result and physical cleanup remain separate + +The workflow result records the first accepted terminal outcome according to the public precedence rules. Cleanup can continue after that result is chosen: live children still need disposal, a worker still needs termination, and a slow external backend may outlive the configured grace bound. + +Public disposal claims its memoized promise before invoking callbacks. Worker death closes admission before processing any queued late child request, synthesizes missing lifecycle ends, and starts child/process cleanup without rewriting an outcome already claimed. + +### ACP prompt settlement does not depend on rendering success + +The ACP UI correlates a prompt with its observed turn directly. It does not scan from a `logWatermark` or use session status as a second reconciliation oracle. + +Prompt handling settles correlation in a `finally` around transcript rendering. A rendering failure can fail presentation, but it cannot skip prompt settlement or leave the session permanently in flight. Concurrent loads of the same persisted caller-supplied session ID remain excluded because that is a real persistence identity race, not a UUID collision concern. ## Correctness enforcement -The runtime rule is checked at four escape boundaries: API shape couples related subjects, TypeScript marks typed dispatch, development invariants inspect actual dispatch, and repository gates keep declarations aligned with enforcement. +The design is enforced at types, runtime escape points, generated contracts, and behavioral tests. No one layer is asked to prove what it cannot observe. -### API shape couples values that must agree +### Types make the ordinary path hard to misuse -`agentEvents(context, agent)` couples carrier, subject, and first event argument. `assembleContextFor(agent)` couples prompt facts with scope selection. `SessionStore.flush(session)` owns lookup of the carrier captured when the session entered. +Readonly contracts describe borrowed same-process values. `Scoped` marks event receivers, `agentEvents()` fuses carrier and subject, tool inputs omit registry-owned tokens, and subagent async return types expose readiness directly. -```text -assembleContextFor(agent): - return { agent, scope: agent } +TypeScript cannot govern JavaScript casts, direct Cordis dispatch, process messages, or durable files, so runtime enforcement remains at those escape points. -agentEvents(context, agent): - carrier = scopeTarget(agent, agent) - return dispatcher that always injects agent as the event subject -``` +### Runtime invariants cover cross-service facts -These helpers make a mismatch harder to express than the correct spelling. +The invariants plugin verifies that every declared scoped event uses a marked carrier and that event families exposing a subject use the matching key. Session trace validation stages before append commit and advances after the same event commits. -### Type markers cover every scoped event declaration +The plugin does not police trusted setup by scanning registries or reject prompt assembly objects fabricated through casts. Those checks would turn composition contracts into speculative runtime machinery without protecting a real external boundary. -Scoped agent, approval, tool, prompt, session, and subagent lifecycle events declare a `Scoped` receiver. TypeScript rejects a bare subject at typed dispatch sites, including subagent lifecycle events scoped to the delegating parent. +### Generated artifacts keep public contracts aligned -The marker is compile-time only; JavaScript, casts, and direct Cordis dispatch can bypass it. +The event catalog, service catalog, producer/consumer matrix, configuration catalog, module graph, tool catalog, and type-equivalence blocks are generated or freshness-gated from source. `verify-scoped-dispatch` keeps the declared scoped-event set aligned with runtime invariant coverage. -### Development invariants inspect actual dispatch - -The invariants plugin uses Cordis's internal dispatch as the pre-delivery enforcement point. Every scoped event requires a marked carrier, and events whose arguments expose the subject require the carrier key to be the same object. For `session/event`, callback resolution also precedes the log push: the plugin validates and stages the exact candidate there, then advances its live trace only when the same committed event reaches its contained post-commit listener. A later internal check can therefore veto without advancing either log or trace. Both halves of this oracle are explicitly global, so mounting the plugin under a scoped context cannot stage a foreign event without also applying its committed transition. - -Session and subagent payloads do not expose their owner key directly, so their service centralizes key selection and the invariant proves carrier presence. Additional invariants reject an assembly whose `agent` and `scope` disagree and a turn opened before `agent/session-start`. - -Dedicated `dsh-scope` unit tests cover the carrier's advanced Proxy behavior: private-field method binding, call/construct shape, primordial filter invocation, own-key/descriptor consistency, and explicit configurable definitions. These are implementation tests, not checks performed by the invariants plugin. - -### Repository gates keep declarations and dispatchers aligned - -`verify-scoped-dispatch` compares declared scoped events with the runtime invariant table, and the generated event matrix requires every declaration to name a recognized dispatcher. Source JSDoc generates the [event catalog](../../../cordis-catalog/events.md), which remains the exhaustive signature and mode reference. +Behavioral tests pin scoped routing and disposal, final-entry collision cleanup, publication rollback, ordered quiescence, durable pre/post-commit behavior, live tool filtering across presentation and execution, owner-final Code Mode and structured output, async subagent startup and signal cancellation, worker terminal arbitration, ACP settlement, and process teardown. ## Alternatives considered -The [agent-scope contract](2026-07-08-agent-scope-contexts.md#alternatives-considered) owns the rejected public architectures: explicit agent parameters, event-only filtering, per-agent service graphs, and hierarchical registration inheritance. This RFC records the implementation alternatives rejected after choosing the public contract. +The [July 8 RFC](2026-07-08-agent-scope-contexts.md#alternatives-considered) owns alternatives to the public flat-scope contract. The alternatives here concern implementation shape. -### Publish the agent before running setup +### Use a transparent proxy as the scope carrier -Early publication lets setup find the agent in global registries but lets observers act on a partially configured world. Rollback can remove entries but cannot retract external effects from listeners that already ran. +A proxy that impersonates the subject must preserve property, callable, constructable, private-field, descriptor, and proxy-invariant behavior that listener routing never needs. A small opaque carrier keeps the filter and key while the explicit event argument carries the subject. -The unpublished setup callback already receives `agent.ctx` and `ctx.agent`, so early global lookup is unnecessary. +### Reserve agent and session IDs before setup -### Allow only synchronous setup +Reservations prevent duplicate private setup work but require cross-service capabilities, release ordering, abandoned-reservation cleanup, and prepared-object binding. IDs are caller-supplied and concurrent reuse is caller error; final entry can choose the winner while the losing transaction rolls back cleanly. -Synchronous setup cannot honestly compose child plugins whose activation is asynchronous. TypeScript also permits a promise-returning callback where a void return is expected, so a synchronous-looking type would not reliably contain accidental async work. +### Snapshot every typed same-process argument -Awaited setup makes the transaction explicit and keeps first publication and prompt assembly behind it. +Universal copying defends against stateful getters and callers that violate readonly contracts, but it adds allocation, duplicated validators, and paths that can forget to copy. Materialization belongs at parser, queue, model, durable, worker, process, and wire boundaries where ownership actually changes. -### Validate caller data, then clone it +### Give readiness, cancellation, and disposal separate controllers -Validation followed by a separate clone rereads accessors, so it can approve one value and retain another. A generic JSON clone can also erase or coerce exotic prototypes and unsupported values. The lossless-JSON traversal validates and materializes one captured value in the same operation. +Parallel sentinels can all mirror whether one operation is live. One transaction or start promise owns the operation; separate promises remain only where publication unwind, external work, terminal result, and physical quiescence can settle independently. -### Enforce invariants with prepended waterfall listeners +### Keep synchronous subagent start plus `run.started` -A prepended listener is not permanently outermost: another plugin can prepend later, a short-circuit can skip inner work, and an outer wrapper can replace a downstream result. The same defect appears in prompt assembly, tool decisions, result commit, and turn continuation. +This splits provider acceptance from readiness and forces every consumer to register a partial run, attach result observation, await readiness, and clean up readiness failure. An async start promise makes provider-to-caller ownership transfer the readiness boundary itself. -The four owner-final APIs express the exact one-way power required: restore named canonical data, deny monotonically, observe immutable final outcome, or stop after ordinary continuation folding. +### Keep a separate prompt-protection registry -### Put agent-scope policy inside vendored Cordis +A protection registration mirrors the names and lifetime already owned by prompt sections and tool definitions. `ownerFinal` keeps the exceptional policy on the contribution and lets assembly derive the canonical set directly. -Cordis already supplies derived contexts, effect ownership, and receiver-based filtering. The harness-level primitive composes those domain-neutral mechanisms rather than teaching Cordis about agents, tools, prompts, or global-plus-agent merge rules. +### Remove worker/process lifecycle guards with same-process hardening -The lifecycle hardening remains correctly inside Cordis because effect pre-registration, parent ownership before child publication, and rejection of late effects protect every plugin under reentrant hot reload, not only agent scopes. +Worker messages, process death, and durable input do cross ownership and serialization boundaries. First-outcome arbitration, validation, environment scrubbing, and quiescent process cleanup remain necessary even though hostile same-process callback machinery does not. ## Consequences -The implementation makes the contributor contract locally checkable at each escape boundary. Its cost is explicit runtime machinery for key coherence, continuous ownership, accepted-value stability, and post-middleware finality. +The implementation is smaller and its proof follows the same shape as its ownership graph. One key selects a layer, one entry owns a live registry object, one transaction owns creation, one resolver owns a tool view, and one async promise transfers subagent ownership. -### Correctness properties +### What the design guarantees -The mechanisms compose into five properties: +- A scoped contribution is visible only in its exact agent view and is disposed with that scope. +- Create and resume expose no partially configured handle; final-entry losers and publication failures clean every prepared resource. +- Disposal retains scoped listeners and persistence through driver drain and final session work, then revokes the scope. +- Durable, queued, model, worker, process, and wire values are owned at their real boundary; typed same-process values follow readonly contracts. +- Tool presentation and execution resolve the same live view, and committed results have one immutable observation point. +- Owner-final prompt/tool contributions survive cooperative assembly without freezing unrelated middleware behavior. +- Subagent start returns only a ready run, required signals cancel pending or live work, and disposal reaches the backend's quiescence contract. +- Worker/process result precedence and cleanup remain correct under death, late messages, and bounded teardown. -- Registry layers and event carriers derive from one opaque key, while fused helpers couple subjects that must agree. -- Reservations, sentinels, provider tracking, publication barriers, and reverse teardown cover every asynchronous or reentrant ownership interval. -- At the hardened boundaries listed above, accepted identities and snapshots prevent runtime accessors or later mutation from splitting validation, execution, logging, and observation. -- Prompt protection, guards, final-result observation, and terminal stop each have only the one-way power their invariant requires. -- In-process subagents and workflow runs preserve readiness, terminal precedence, and disposal under provider callbacks, worker death, and racing owners. +### Costs and limits -### Costs and constraints +Scope-aware services still maintain global and identity-keyed maps, and operations must carry their real agent explicitly. Async create/resume and subagent start require callers to await ownership transfer and dispose returned handles. -The proof is not free: +The design trusts typed plugins in the same process. It does not defend against arbitrary casts, stateful getters, mutation that violates readonly contracts, or a plugin deliberately using ambient service access outside the supported composition API. -- Registries keep global and per-scope state, and every scoped dispatcher must preserve the operation subject through a proxy-shaped carrier. -- Programmatic create and resume require reservation capabilities, two-owner tracking, rollback state, ordered publication, and a shared quiescence promise. -- Acceptance boundaries copy, freeze, bind, or retain values according to their contract, increasing allocation and validation work. -- Owner-final behavior uses four explicit APIs instead of relying on ordinary listener ordering. -- Runtime invariants, generated dispatch checks, and focused Proxy/lifecycle/race tests remain necessary because TypeScript cannot enforce direct JavaScript dispatch or runtime reentrancy. - -The direct no-setup `ctx.agentLoop.create()` path remains synchronous for configuration and callers that already have complete options. Programmatic registry create/resume use the full unpublished transaction. - -### Limits of the proof - -The proof covers services and event families that explicitly adopt the agent-scope helpers. It does not make every service call scope-aware, strengthen the ordering contract of a custom agent registered outside AgentLoop, or force an arbitrary external subagent backend to reach quiescence after a workflow grace deadline. - -The [security and authority non-goal](2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals) is part of the public contract. These mechanisms prove composition and ownership behavior inside one trusted process; they do not prove confinement or parent-to-child non-escalation. +The [security and authority non-goal](2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals) remains fundamental. These mechanisms prove registration composition, publication, and lifetime ownership; they do not prove confinement or parent-to-child non-escalation. diff --git a/docs/rfc/implemented/feature/2026-06-15-code-mode.md b/docs/rfc/implemented/feature/2026-06-15-code-mode.md index be18e94ae0..16f42104be 100644 --- a/docs/rfc/implemented/feature/2026-06-15-code-mode.md +++ b/docs/rfc/implemented/feature/2026-06-15-code-mode.md @@ -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 100–199 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 100–199 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; bash(args: …): Promise; … }` — 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. diff --git a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md index f26aa501ef..c24c6102d9 100644 --- a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -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`. 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. diff --git a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md index ec608177b4..518f9dedef 100644 --- a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md +++ b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md @@ -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. diff --git a/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md index 17730458de..aa853edd24 100644 --- a/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md +++ b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md @@ -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 diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md index 47d9ec0473..ed718686ef 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -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) diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md index 0579bf2629..5d82226a96 100644 --- a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md @@ -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. diff --git a/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md b/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md index 9d657f7bd4..5b6eeb4ccb 100644 --- a/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md +++ b/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md @@ -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. diff --git a/packages/AGENTS.md b/packages/AGENTS.md index ac26b926f7..4b7bed4d1c 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -5,6 +5,8 @@ This directory contains all `@deepseek-ai/dsh-*` harness packages. Repo-wide con - **Plugin export shape — namespace OR default, never both.** A *service* package exports the service class as `export default` (the Loader instantiates it). A *function/namespace* plugin exports `name` / `inject` / `Config` / `apply` as separate named exports and **must NOT add `export default`** — the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default export collapses the module to the bare `apply` function and silently discards the `inject`/`name`/`Config` namespace, leaving the plugin with no injected services (it then throws `cannot get property … without inject` at load). See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md). - **Read an optional (non-injected) service via `ctx.get(name)`, not `ctx.`.** For a service a plugin reads opportunistically but deliberately leaves out of `static inject` (e.g. `AgentLoop` reading `sessionPersistence`), the `ctx.` property proxy resolves by an ancestor-only fiber walk that throws when the call arrives through a foreign traceable shadow (the service lives on a sibling fiber). `ctx.get(name)` is the topology-independent global-store lookup, strict by default (an inactive/absent backend reads as `undefined` — prefer it over the `ctx.get(name, false)` overload, which also skips the active-state check). Services that ARE in `static inject` resolve fine via `ctx.`. See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md). - **A plugin shipped via `cordis.yml` needs at least one test through the REAL Loader/export path** — hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape. Full testing policy (tiers, with-key generosity, real-entry-path guards): [docs/testing.md](../docs/testing.md). +- **Typed same-process service and plugin calls are contracts, not serialization boundaries.** Prefer readonly borrowed values; materialize or defensively validate only at parser/config, queued, model/tool JSON, durable/file, worker, process, or wire boundaries. +- **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement boundary; otherwise fold it while preserving rollback, callback containment, and quiescence. Naming notes: diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 88b89f5d3b..3ee6d1081f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -54,7 +54,7 @@ export interface TypeApiEntry { export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'agentLoop', - summary: 'The agent-loop plugin (`ctx.agentLoop`): creates ReactLoopAgents, runs their loops, and registers them in `ctx.agents`.', + summary: 'Concrete ReactLoopAgent factory and driver service.', methods: [ 'create(id: AgentId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent', 'async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise', @@ -65,12 +65,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'agents', summary: 'Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package.', methods: [ - 'reserve(id: AgentId): AgentRegistrationReservation', 'setFactory(factory: AgentFactory): () => Promise | void', 'async create(options: CreateAgentOptions): Promise', 'async resume(options: ResumeAgentOptions): Promise', 'register(agent: Agent): () => Promise | void', - 'enter(agent: Agent, reservation?: AgentRegistrationReservation): () => void', + 'enter(agent: Agent): () => void', 'announce(agent: Agent): void', 'get(id: AgentId): Agent | undefined', 'list(): Agent[]', @@ -156,10 +155,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'sessions', summary: 'In-memory session store (`ctx.sessions`).', methods: [ - 'reserve(id: SessionId): SessionRegistrationReservation', 'create(id?: SessionId, options?: CreateSessionOptions): Session', 'prepare(id?: SessionId, options?: CreateSessionOptions): Session', - 'enter(session: Session, reservation?: SessionRegistrationReservation): () => void', + 'enter(session: Session): () => void', 'announce(session: Session): void', 'async flush(session: Session): Promise', 'get(id: SessionId): Session | undefined', @@ -179,22 +177,21 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'subagents', - summary: 'The `subagents` service: a registry of named SubagentProviders and a capability-checked start surface.', + summary: 'Named provider registry and capability-checked start surface.', methods: [ 'registerProvider(provider: SubagentProvider): () => Promise | void', 'getProvider(name: string): SubagentProvider | undefined', 'list(): string[]', - 'start(name: string, request: SubagentStartRequest): SubagentRun', + 'async start(name: string, request: SubagentStartRequest): Promise', ], }, { key: 'systemPrompt', - 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.', + summary: 'Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, named prompt variables, and owner-final contributions; the agent loop calls `assemble(context)` once per step.', methods: [ 'section(section: PromptSection): () => Promise | void', 'tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise | void', 'variable(name: string, provider: (context: AssembleContext) => string | undefined): () => Promise | void', - 'protect(protection: PromptProtection): () => Promise | void', 'async assemble(context: AssembleContext = {}): Promise', ], }, @@ -205,10 +202,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'register(definition: ToolDefinition): () => Promise | void', 'restrict(filter: ToolRestriction): () => Promise | void', 'guard(guard: ToolGuard): () => Promise | void', - 'visible(scope?: ScopeKey): ToolDefinition[]', 'get(name: string, scope?: ScopeKey): ToolDefinition | undefined', 'schemas(scope?: ScopeKey): ToolSchema[]', - 'knownNames(scope?: ScopeKey): string[]', 'async execute(exec: ToolExecutionInput): Promise', ], }, @@ -389,25 +384,25 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'subagent/end', mode: 'emit', signature: '\'subagent/end\'(this: Scoped, info: SubagentRunEndInfo): void', - summary: 'A started subagent run settled — emitted when SubagentRun.result resolves (any stop reason) or rejects (reported as `error`).', + summary: 'A ready child settled.', }, { name: 'subagent/provider-added', mode: 'emit', signature: '\'subagent/provider-added\'(provider: SubagentProvider): void', - summary: 'A provider became resolvable in the SubagentService registry.', + summary: 'A provider became resolvable in the registry.', }, { name: 'subagent/provider-removed', mode: 'emit', signature: '\'subagent/provider-removed\'(name: string): void', - summary: 'A provider left the registry (its plugin\'s fiber was disposed — an unload or an HMR reload).', + summary: 'A provider left the registry.', }, { name: 'subagent/start', mode: 'emit', signature: '\'subagent/start\'(this: Scoped, info: SubagentRunInfo): void', - summary: 'A subagent run started — emitted only after SubagentRun.started fulfills, when the provider has established a live child.', + summary: 'A provider established a ready child.', }, { name: 'system-prompt/assemble', @@ -419,7 +414,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'system-prompt/change', mode: 'emit', signature: '\'system-prompt/change\'(): void', - summary: 'A section, tool provider, variable provider, or protection was registered or unregistered (the assembly inputs changed — possibly for one scope only).', + summary: 'A section, tool provider, or variable provider was registered or unregistered (the assembly inputs changed — possibly for one scope only).', }, { name: 'tools/change', @@ -436,7 +431,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'tools/post-execute', mode: 'waterfall', - signature: '\'tools/post-execute\'(this: Scoped, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise): Promise', + signature: '\'tools/post-execute\'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise', summary: 'Waterfall AFTER a tool runs — where hook plugins inspect the result and accept it (optionally REPLACING the model-facing content, and/or attaching `additionalContext` for the next request) or block it with corrective `feedback` (Claude Code\'s `PostToolUse`).', }, { @@ -511,10 +506,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentOptions', declaration: 'export interface AgentOptions {\n model?: string;\n}', }, - { - name: 'AgentRegistrationReservation', - declaration: 'export interface AgentRegistrationReservation {\n readonly id: AgentId;\n release(): void;\n}', - }, { name: 'AgentStatus', declaration: 'export type AgentStatus = \'idle\' | \'running\' | \'disposed\';', @@ -525,7 +516,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ApprovalRequest', - declaration: 'export interface ApprovalRequest {\n agent: Agent;\n toolName: string;\n callId?: CallId;\n reason?: string;\n signal?: AbortSignal;\n}', + declaration: 'export interface ApprovalRequest {\n readonly agent: Agent;\n readonly toolName: string;\n readonly callId?: CallId;\n readonly reason?: string;\n readonly signal?: AbortSignal;\n}', }, { name: 'AskUserQuestionAnswer', @@ -653,11 +644,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateAgentOptions', - declaration: 'export interface CreateAgentOptions {\n agentId: AgentId;\n sessionId: SessionId;\n meta?: {\n cwd?: string;\n parentSession?: SessionId;\n seedLength?: number;\n };\n seed?: SessionEvent[];\n agentOptions?: AgentOptions;\n setup?: (agentCtx: Context) => Promise | void;\n}', + declaration: 'export interface CreateAgentOptions {\n readonly agentId: AgentId;\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', }, { name: 'CreateSessionOptions', - declaration: 'export interface CreateSessionOptions {\n seed?: SessionEvent[];\n meta?: {\n cwd?: string;\n parentSession?: SessionId;\n createdAt?: number;\n seedLength?: number;\n };\n}', + declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n };\n}', }, { name: 'DiffCallView', @@ -755,13 +746,9 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PromptAssembly', declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n tools: ToolSchema[];\n variables: Record;\n}', }, - { - name: 'PromptProtection', - declaration: 'export interface PromptProtection {\n sections?: readonly string[];\n tools?: readonly string[];\n}', - }, { name: 'PromptSection', - declaration: 'export interface PromptSection {\n name: string;\n order: number;\n text: string | ((context: AssembleContext) => string);\n}', + declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n readonly ownerFinal?: boolean;\n}', }, { name: 'ReasoningBlock', @@ -769,7 +756,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ResumeAgentOptions', - declaration: 'export interface ResumeAgentOptions {\n agentId: AgentId;\n resumeSessionId: SessionId;\n agentOptions?: AgentOptions;\n setup?: (agentCtx: Context) => Promise | void;\n}', + declaration: 'export interface ResumeAgentOptions {\n readonly agentId: AgentId;\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', }, { name: 'SandboxEnforcement', @@ -809,23 +796,19 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionHeader', - declaration: 'export interface SessionHeader {\n version: number;\n id: SessionId;\n createdAt: number;\n cwd?: string;\n parentSession?: SessionId;\n seedLength?: number;\n}', + declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n}', }, { name: 'SessionId', declaration: 'export type SessionId = Branded<\'SessionId\'>;', }, - { - name: 'SessionRegistrationReservation', - declaration: 'export interface SessionRegistrationReservation {\n readonly id: SessionId;\n prepare(options?: CreateSessionOptions): Session;\n release(): void;\n}', - }, { name: 'SkillCandidate', - declaration: 'export interface SkillCandidate extends SkillSummary {\n rank: number;\n locator: unknown;\n path?: string;\n metadata?: Record;\n}', + declaration: 'export interface SkillCandidate extends SkillSummary {\n readonly rank: number;\n readonly locator: unknown;\n readonly path?: string;\n readonly metadata?: Readonly>;\n}', }, { name: 'SkillDefinition', - declaration: 'export interface SkillDefinition extends SkillSummary {\n content: string;\n path?: string;\n metadata?: Record;\n}', + declaration: 'export interface SkillDefinition extends SkillSummary {\n readonly content: string;\n readonly path?: string;\n readonly metadata?: Readonly>;\n}', }, { name: 'SkillLookupOptions', @@ -833,15 +816,15 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SkillProvider', - declaration: 'export interface SkillProvider {\n name: string;\n list(options: SkillLookupOptions): Promise;\n get(candidate: SkillCandidate, options: SkillLookupOptions): Promise;\n}', + declaration: 'export interface SkillProvider {\n readonly name: string;\n readonly list: (options: SkillLookupOptions) => Promise;\n readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise;\n}', }, { name: 'SkillRegistration', - declaration: 'export type SkillRegistration = Omit & {\n provider?: string;\n};', + declaration: 'export type SkillRegistration = Omit & {\n readonly provider?: string;\n};', }, { name: 'SkillResourceBase', - declaration: 'export type SkillResourceBase = {\n kind: \'directory\';\n path: string;\n} | {\n kind: \'url\';\n url: string;\n} | {\n kind: \'opaque\';\n description: string;\n};', + declaration: 'export type SkillResourceBase = {\n readonly kind: \'directory\';\n readonly path: string;\n} | {\n readonly kind: \'url\';\n readonly url: string;\n} | {\n readonly kind: \'opaque\';\n readonly description: string;\n};', }, { name: 'SkillSource', @@ -849,7 +832,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SkillSummary', - declaration: 'export interface SkillSummary {\n name: string;\n description: string;\n whenToUse?: string;\n disableModelInvocation?: boolean;\n source: SkillSource;\n provider: string;\n resourceBase?: SkillResourceBase;\n}', + declaration: 'export interface SkillSummary {\n readonly name: string;\n readonly description: string;\n readonly whenToUse?: string;\n readonly disableModelInvocation?: boolean;\n readonly source: SkillSource;\n readonly provider: string;\n readonly resourceBase?: SkillResourceBase;\n}', }, { name: 'StreamChunk', @@ -873,23 +856,23 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentCapabilities', - declaration: 'export interface SubagentCapabilities {\n outputSchema: boolean;\n depthLimit: boolean;\n toolFilter: boolean;\n persona: boolean;\n}', + declaration: 'export interface SubagentCapabilities {\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n readonly toolFilter: boolean;\n readonly persona: boolean;\n}', }, { name: 'SubagentProvider', - declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n start(request: SubagentStartRequest): SubagentRun;\n}', + declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n start(request: SubagentStartRequest): Promise;\n}', }, { name: 'SubagentResult', - declaration: 'export interface SubagentResult {\n output: ContentBlock[];\n structured?: unknown;\n stopReason: SubagentStopReason;\n}', + declaration: 'export interface SubagentResult {\n readonly output: ContentBlock[];\n readonly structured?: unknown;\n readonly stopReason: SubagentStopReason;\n}', }, { name: 'SubagentRun', - declaration: 'export interface SubagentRun {\n readonly id: AgentId;\n readonly started: Promise;\n readonly result: Promise;\n cancel(reason?: string): void;\n dispose(): Promise;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): SubagentRun;\n}', + declaration: 'export interface SubagentRun {\n readonly id: AgentId;\n readonly result: Promise;\n dispose(): Promise;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): Promise;\n}', }, { name: 'SubagentStartRequest', - declaration: 'export interface SubagentStartRequest {\n prompt: ContentBlock[];\n parent: Agent;\n signal?: AbortSignal;\n agentOptions?: AgentOptions;\n outputSchema?: StructuredOutputSchema;\n maxDepth?: number;\n toolFilter?: {\n allow?: string[];\n deny?: string[];\n };\n persona?: string;\n}', + declaration: 'export interface SubagentStartRequest {\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: StructuredOutputSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n}', }, { name: 'SubagentStopReason', @@ -937,7 +920,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolDefinition', - declaration: 'export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolExecution): Promise;\n timeoutMs?: number;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}', + declaration: 'export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolExecution): Promise;\n timeoutMs?: number;\n readonly ownerFinal?: boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}', }, { name: 'ToolErrorInfo', @@ -961,7 +944,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolExecutionToken', - declaration: 'export interface ToolExecutionToken {\n readonly [toolExecutionTokenBrand]: true;\n}', + declaration: 'export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n};', }, { name: 'ToolGuard', @@ -969,11 +952,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolProviderResult', - declaration: 'export interface ToolProviderResult {\n schemas: ToolSchema[];\n knownNames?: readonly string[];\n}', + declaration: 'export interface ToolProviderResult {\n readonly schemas: readonly ToolSchema[];\n readonly knownNames?: readonly string[];\n readonly ownerFinalNames?: readonly string[];\n}', }, { name: 'ToolRestriction', - declaration: 'export interface ToolRestriction {\n allow?: string[];\n deny?: string[];\n}', + declaration: 'export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n}', }, { name: 'ToolResult', diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index f1c3e9af9d..71be5b339e 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -272,7 +272,7 @@ describe('agent loop', () => { expect(result.data.meta).toBeUndefined() expect(result.data.content).toEqual([{ type: 'text', - text: 'Error: tools/execute must return a losslessly JSON-serializable ToolExecutionResult', + text: 'Error: tool result must be losslessly JSON-serializable', }]) } // The normalized failure was durably logged and fed back to the model; the diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 559825d404..6332a0a458 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -73,14 +73,17 @@ declare module 'cordis' { /** * A provider established a ready child. For in-process providers, * `ctx.agents.get(info.id)` resolves during this notification. - * Scope-filtered by the delegating parent and paired with `subagent/end`. + * Scope-filtered dispatch keys the carrier by the delegating parent, so a + * parent-scoped listener observes only its own delegations. Paired with + * `subagent/end`. * @param info - the provider and ready child identity. * @mode emit */ 'subagent/start'(this: Scoped, info: SubagentRunInfo): void /** - * A ready child settled. Scope-filtered by the delegating parent and - * paired with `subagent/start`. + * A ready child settled. Scope-filtered dispatch uses the same delegating + * parent carrier as `subagent/start`, so the lifecycle pair reaches the + * same scoped audience. * @param info - the run identity and terminal outcome. * @mode emit */ diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index ae1fa8d170..68139e9ff8 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -81,7 +81,6 @@ const FENCE = 'ts cordis-catalog' */ export const LINK_MAP: Record = { Agent: 'core.md', - AgentRegistrationReservation: 'core.md', ContentBlock: 'core.md', Message: 'core.md', MessageSource: 'core.md', @@ -89,7 +88,6 @@ export const LINK_MAP: Record = { LlmCallConfig: 'core.md', SessionEvent: 'core.md', SessionStartSource: 'core.md', - SessionRegistrationReservation: 'session.md', StreamChunk: 'llm-streaming.md', TurnEndReason: 'session.md', ToolDefinition: 'tools.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index c36a50c244..c4c8186f38 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -588,12 +588,12 @@ function collectEventRelations(): Map { if (method === 'on') { const event = eventArg(node.arguments, method) if (event) ensure(event).listeners.add(leaf) - } else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'strictSerial' || method === 'waterfall') { + } else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'waterfall') { const event = eventArg(node.arguments, method) if (event) { const relation = ensure(event) const methods = relation.dispatchers.get(leaf) ?? new Set() - methods.add(method === 'strictSerial' ? 'strictSerial (serial)' : method) + methods.add(method) relation.dispatchers.set(leaf, methods) } } diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 62111d1551..a219ea1b1d 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -11,7 +11,6 @@ { "doc": "docs/core-data-structures/core.md", "symbol": "LlmCallConfig", "source": "packages/llm/llm/src/call-config.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "Agent", "source": "packages/core/agent/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "AgentRegistrationReservation", "source": "packages/core/agent/src/index.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "HookContext", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationDecision", "source": "packages/core/agent/src/types.ts" }, @@ -23,8 +22,8 @@ { "doc": "docs/core-data-structures/scope.md", "symbol": "Scope", "source": "packages/core/scope/src/index.ts" }, { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "AssembleContext", "source": "packages/core/system-prompt/src/index.ts" }, + { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "PromptSection", "source": "packages/core/system-prompt/src/index.ts" }, { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "ToolProviderResult", "source": "packages/core/system-prompt/src/index.ts" }, - { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "PromptProtection", "source": "packages/core/system-prompt/src/index.ts" }, { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "TokenUsage", "source": "packages/llm/llm/src/types.ts" }, @@ -41,7 +40,6 @@ { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceOp", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceIntent", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceNode", "source": "packages/core/session/src/surface.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SessionRegistrationReservation", "source": "packages/core/session/src/index.ts" }, { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" }, @@ -54,6 +52,7 @@ { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionInput", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolGuard", "source": "packages/core/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolRestriction", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "PreToolDecision", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "PostToolDecision", "source": "packages/core/tools/src/index.ts" },