Merge PR #224 updates into prose cleanup
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -105,11 +105,11 @@ Every session event is turn-enclosed. Reloading a crashed session preserves the
|
||||
|
||||
### Agent Handles
|
||||
|
||||
`ctx.agents` owns live agents and returns an `AgentHandle { agent, dispose() }`. `Agent` is the API other plugins drive: `send()` queues work, `steer()` injects mid-turn content, `inject()` appends context and opens a one-shot injection turn when idle, `cancel()` is the public stop primitive, and `whenIdle()` observes quiescence. Lifecycle owners tear down with `await dispose()`.
|
||||
`ctx.agents` owns live agents and returns an `AgentHandle { agent, dispose() }`. `Agent` is the API other plugins drive: `send()` queues work, `steer()` injects mid-turn content, `inject()` appends context and opens a one-shot injection turn when idle, `cancel()` is the public stop primitive, and `whenIdle()` observes quiescence. The caller fiber and concrete factory provider structurally co-own programmatic lifecycles; a consumer handle is the only non-structural teardown capability, and every owner reaches the same awaited disposer.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface AcpConfig {
|
||||
|
||||
Depends on: `Stream` (`@agentclientprotocol/sdk`)
|
||||
|
||||
Source: [`packages/ui/acp/src/index.ts:214`](../packages/ui/acp/src/index.ts)
|
||||
Source: [`packages/ui/acp/src/index.ts:248`](../packages/ui/acp/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-acp-agent`
|
||||
|
||||
@@ -108,21 +108,15 @@ Source: [`packages/core/agent-core/src/index.ts:40`](../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-<uuid>`). */
|
||||
/** 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-<uuid>`.
|
||||
*/
|
||||
/** Persisted session to resume instead of creating a fresh session. */
|
||||
resumeSessionId?: SessionId
|
||||
})[]
|
||||
}
|
||||
@@ -130,7 +124,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:37`](../packages/core/agent-loop/src/index.ts)
|
||||
Source: [`packages/core/agent-loop/src/index.ts:325`](../packages/core/agent-loop/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-bash-local`
|
||||
|
||||
@@ -329,24 +323,6 @@ export interface Config {
|
||||
|
||||
Source: [`packages/hooks/hooks-codex/src/index.ts:32`](../packages/hooks/hooks-codex/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-invariants`
|
||||
|
||||
Requires: `sessions`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Deep-freeze logged session-event data so mutating a logged event throws.
|
||||
* Default true — this plugin only runs in dev/test, where freezing is the
|
||||
* point. Set false to assert the event contract without freezing.
|
||||
*/
|
||||
freeze?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/support/invariants/src/index.ts:34`](../packages/support/invariants/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-llm-deepseek`
|
||||
|
||||
Requires: `llm`
|
||||
@@ -551,12 +527,12 @@ Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:3
|
||||
```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:112`](../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`
|
||||
|
||||
@@ -703,9 +679,11 @@ export interface Config {
|
||||
/** Which start-time capabilities to advertise (default: all `true`). */
|
||||
capabilities?: Partial<SubagentCapabilities>
|
||||
/**
|
||||
* The context contract to declare ({@link SubagentProvider.inheritsParentContext});
|
||||
* default `false` (spawn-like). Set `true` to exercise the fork-shaped tool
|
||||
* wording in consumer tests.
|
||||
* The conversation-history descriptor to declare
|
||||
* ({@link SubagentProvider.inheritsParentContext}); default `false` (fresh
|
||||
* conversation). Set `true` to exercise seeded/fork wording in consumer
|
||||
* tests. This flag says nothing about tool, service, scope, or authority
|
||||
* inheritance.
|
||||
*/
|
||||
inheritsParentContext?: boolean
|
||||
/**
|
||||
@@ -718,7 +696,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:80`](../packages/support/subagent-mock/src/index.ts)
|
||||
Source: [`packages/support/subagent-mock/src/index.ts:90`](../packages/support/subagent-mock/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-subagent-spawn`
|
||||
|
||||
@@ -740,21 +718,48 @@ Source: [`packages/subagent/subagent-spawn/src/index.ts:20`](../packages/subagen
|
||||
/** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */
|
||||
export interface Config {
|
||||
/**
|
||||
* The deployment's persona — the one deployment-authored fragment of the system prompt,
|
||||
* rendered as the order-0 `deployment:persona` section (after the harness identity, before
|
||||
* all tool guidance).
|
||||
* The deployment's persona — the ONE deployment-authored fragment of the
|
||||
* system prompt, rendered as the order-0 `deployment:persona` section
|
||||
* (after the harness identity, before all tool guidance). Every agent in
|
||||
* the context shares it by default; a per-agent persona is a SCOPED section
|
||||
* of the same name registered through that agent's `agent.ctx` (it shadows
|
||||
* this one for that agent — the subagent seam's `persona` request field does
|
||||
* exactly that). Template, not free-form text:
|
||||
* every complete `{{…}}` group is interpreted strictly against the
|
||||
* registered prompt variables (the shipped agent loop registers `{{model}}`
|
||||
* and `{{cwd}}`), and there is no escape syntax for literal `{{…}}` prose
|
||||
* yet (a deliberate deferral; see the prompt-variables RFC). Defaults to
|
||||
* `''` — the empty section is dropped at render, so a persona-less
|
||||
* deployment opens with the harness identity alone.
|
||||
*/
|
||||
persona?: string
|
||||
/**
|
||||
* Explicit model-facing tool order, as a list of `ToolSchema.name`s: listed tools take their
|
||||
* listed position, and tools absent from the list are inserted at the {@link
|
||||
* TOOL_ORDER_REST} (`'<unlisted-tools>'`) entry in lexicographic name order.
|
||||
* Explicit model-facing tool order, as a list of `ToolSchema.name`s: listed
|
||||
* tools take their listed position, and tools absent from the list are
|
||||
* inserted at the {@link TOOL_ORDER_REST} (`'<unlisted-tools>'`) entry in
|
||||
* lexicographic name order. A configured list must contain the rest entry
|
||||
* exactly once, no duplicate names, and no name without a registered tool —
|
||||
* a misconfigured order blocks work instead of silently reaching a model
|
||||
* request: shape violations throw at load, and an unregistered name rejects
|
||||
* every assembly. `TOOL_ORDER_REST` is reserved for the list marker and may
|
||||
* not be a collected tool name; such a provider output also rejects the
|
||||
* assembly. The single assembly-time validation rejects either failure
|
||||
* before any model request — the earliest moment the registered tool set
|
||||
* exists to check against, since tool plugins register after this service
|
||||
* constructs. When omitted, tools are ordered lexicographically by name.
|
||||
* Applied to the tools
|
||||
* {@link SystemPrompt.assemble} collects, BEFORE the
|
||||
* `system-prompt/assemble` waterfall — like the sections' `order` sort, it
|
||||
* canonicalizes what the registry contributed (registration order is a
|
||||
* plugin-load artifact); a waterfall listener that mutates the tool list
|
||||
* owns the determinism of what it emits. Rationale (and why not per-plugin
|
||||
* weights): docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md.
|
||||
*/
|
||||
toolOrder?: string[]
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/core/system-prompt/src/index.ts:211`](../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`
|
||||
|
||||
@@ -857,8 +862,9 @@ export interface Config {
|
||||
* Recursion cap applied to every child this tool spawns (see
|
||||
* `SubagentStartRequest.maxDepth`): a spawn whose child would sit deeper
|
||||
* than this in the delegation tree is rejected. Requires the provider's
|
||||
* `depthLimit` capability. Omitted ⇒ unbounded (bound it in deployments
|
||||
* that expose this tool to children).
|
||||
* `depthLimit` capability. Must be a non-negative safe integer and is
|
||||
* validated when the plugin loads. Omitted ⇒ unbounded (bound it in
|
||||
* deployments that expose this tool to children).
|
||||
*/
|
||||
maxDepth?: number
|
||||
}
|
||||
@@ -866,7 +872,7 @@ export interface Config {
|
||||
|
||||
Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts)
|
||||
|
||||
Source: [`packages/subagent/tool-subagent/src/index.ts:20`](../packages/subagent/tool-subagent/src/index.ts)
|
||||
Source: [`packages/subagent/tool-subagent/src/index.ts:47`](../packages/subagent/tool-subagent/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tool-web`
|
||||
|
||||
@@ -914,8 +920,18 @@ Requires: `systemPrompt`
|
||||
/** Plugin config: how the registered tools are presented to the model. */
|
||||
export interface Config {
|
||||
/**
|
||||
* The presentation mode. `'native'` (the default) contributes every visible end capability
|
||||
* as a native wire function definition.
|
||||
* The presentation mode. `'native'` (the default) contributes every
|
||||
* visible end capability as a native wire function definition. Under
|
||||
* `'code'` this registry contributes exactly ONE wire tool,
|
||||
* `run_code`, plus the generated `tools:sdk` prompt section declaring every other tool as a
|
||||
* TypeScript API the program calls. `'both'` contributes every native
|
||||
* definition AND `run_code` + the SDK section. Non-native modes require a
|
||||
* loaded `ctx.codeRuntime` whose `language` is `'typescript'` — a missing
|
||||
* or mismatched runtime rejects every prompt assembly with an actionable
|
||||
* error (misconfiguration fails loud, before any model request). A
|
||||
* configured `systemPrompt.toolOrder` naming native tools likewise rejects
|
||||
* every assembly under `'code'` (those names are no longer contributed) —
|
||||
* a deployment switching modes updates its order config or drops it.
|
||||
*/
|
||||
mode?: ToolPresentationMode
|
||||
}
|
||||
@@ -924,7 +940,7 @@ export interface Config {
|
||||
export type ToolPresentationMode = 'native' | 'code' | 'both'
|
||||
```
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:334`](../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`
|
||||
|
||||
@@ -937,7 +953,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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -955,7 +971,7 @@ export interface Config {
|
||||
export type ApprovalPolicy = 'ask' | 'never'
|
||||
```
|
||||
|
||||
Source: [`packages/ui/user-approval/src/index.ts:229`](../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`
|
||||
|
||||
@@ -1104,6 +1120,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
|
||||
- `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
|
||||
- `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts))
|
||||
- `@deepseek-ai/dsh-invariants` — requires `sessions` ([`packages/support/invariants/src/index.ts`](../packages/support/invariants/src/index.ts))
|
||||
- `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts))
|
||||
- `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))
|
||||
|
||||
@@ -34,9 +34,9 @@ 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 validates and clones them, 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.
|
||||
- **Execution identity is protected.** The registry requires `arguments` to survive lossless-JSON validation before and after cloning, freezes the detached 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 validates the complete post-policy result as losslessly JSON-serializable 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).
|
||||
- **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.
|
||||
- **Attach durable card data with `meta` (optional).** `execute` may return `{ content, meta }` instead of a bare `ContentBlock[]` — `meta` is a JSON-serializable payload the core treats as opaque, persisted on the `tool/result` event and handed back to your `presentResult` (so a card that needs more than `args`, like `write`/`edit`'s applied-hunk diff, survives a session replay). Keep UI-only data here, never in the model-facing `content`.
|
||||
- **Use `exec.agent` for async notifications.** `agent.inject(content, {source: {kind: 'plugin', plugin: '<name>'}})` appends durable context the NEXT model request sees — it is not a wake-up (an idle agent stays idle). Guard against disposed agents (try/catch).
|
||||
|
||||
@@ -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 off a settle signal (settle from the durable `turn/end` session event — the boundary is a session event, not an `agent/*` mirror — with `agent/status` as the fallback if a peer listener starved yours), 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 |
|
||||
|
||||
@@ -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.
|
||||
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: Agent): void
|
||||
@@ -23,13 +23,11 @@ An agent's fully composed scoped world was published in the AgentRegistry.
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:173`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:316`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/disposed` — emit
|
||||
|
||||
An agent was removed from the registry after its driver and any in-flight turn reached quiescence.
|
||||
|
||||
Scope-filtered dispatch: keyed to `agent`.
|
||||
An agent was removed from the registry. The concrete AgentLoop lifecycle emits this only after its driver and any in-flight turn reach quiescence; a custom agent registered through the public registry owns its own driver contract, which the registry cannot infer. Ordered teardown may still be detaching the session and unwinding scoped registrations when this runs.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void
|
||||
@@ -37,13 +35,11 @@ Scope-filtered dispatch: keyed to `agent`.
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:182`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:331`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/error` — emit
|
||||
|
||||
A step or turn errored.
|
||||
|
||||
Scope-filtered dispatch: keyed to `agent`.
|
||||
A step or turn errored. The loop reports a failure here (plus the logger) even when the error has no in-turn position for a session `error` event.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: Error): void
|
||||
@@ -51,11 +47,13 @@ Scope-filtered dispatch: keyed to `agent`.
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:319`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:606`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/pre-step` — serial
|
||||
|
||||
Awaited checkpoint for surface mutation before `step/start` snapshots request history. Scope-filtered dispatch: keyed to `agent`.
|
||||
Awaited pre-step surface-mutation checkpoint, fired once per step AFTER `turn/start` (and after the prior step closed) but BEFORE this step's `step/start` — so anything a listener appends lands OUTSIDE the step, between `turn/start`/`step/end` and the upcoming `step/start`. `step` is the number of the step about to start. The loop awaits `ctx.serial('agent/pre-step', …)` after assembling the system prompt, then opens the step and derives the request history ONCE from whatever the surface now holds. This is where compaction belongs: it mutates the session surface in place (shadowing an older range with a summary node) with its log-only `compact/*` records cleanly outside any step, and the single subsequent derive reflects the mutation — so there is no double-derive and no listener can see (or be expected to act on) an assembled `messages` array that does not exist yet.
|
||||
|
||||
Serial (awaited in registration order), not a waterfall: a listener mutates the surface as a side effect; there is nothing to transform, but the loop must wait for the mutation to complete before opening the step and deriving. Cordis `serial` bails early if a listener returns a bail value; this event is typed and documented as `void`, so listeners must not return a semantic veto value. `fullSystemPrompt` is the assembled prompt a listener needs to measure pressure (the system prompt counts toward the budget), and `sessionPrefix` is the instance's composed agent/session-prefix product for the same reason — every request carries it in front of the derived history, and it is composed BEFORE this seam fires precisely so a pressure gate counts the prefix the request will actually send (never a stale logged one). `signal` cancels any in-flight work a listener starts (e.g. a summarization model call). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered through `agent.ctx` fires only for that agent's dispatches; a listener on a plain plugin context fires for every agent. The dispatch `this` is the scope carrier (`Scoped<Agent>`), built by the emitting side via `scopeTarget`/`agentEvents`.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/pre-step'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise<void> | void
|
||||
@@ -63,13 +61,11 @@ Awaited checkpoint for surface mutation before `step/start` snapshots request hi
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:234`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:438`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/prompt-submit` — waterfall
|
||||
|
||||
Waterfall: decide what happens to one drained queued message before it becomes a `user/message` — allow (optionally rewriting the prompt bytes or attaching `additionalContext`) or block it.
|
||||
|
||||
Scope-filtered dispatch: keyed to `agent`.
|
||||
Waterfall: decide what happens to ONE drained queued message before it becomes a `user/message` — allow (optionally rewriting the prompt bytes or attaching `additionalContext`) or block it. Fires inside the already-open turn, per drained message. Maps onto Claude Code's `UserPromptSubmit` hook. Call `next()` to delegate to the default (allow unchanged), or return a PromptDecision without calling `next()` to short-circuit.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/prompt-submit'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>
|
||||
@@ -77,13 +73,11 @@ Scope-filtered dispatch: keyed to `agent`.
|
||||
|
||||
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:246`](../../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). `source` is the resolved source (defaults applied), not the caller's raw options.
|
||||
|
||||
Scope-filtered dispatch: keyed to `agent`.
|
||||
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: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void
|
||||
@@ -91,13 +85,11 @@ Scope-filtered dispatch: keyed to `agent`.
|
||||
|
||||
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:202`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:360`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/request` — waterfall
|
||||
|
||||
Waterfall: shape the step's call configuration — model switching, sampling overrides — by returning a replacement LlmCallConfig (the frozen seed is the config the loop would otherwise use).
|
||||
|
||||
Scope-filtered dispatch: keyed to `agent`.
|
||||
Waterfall: shape the step's call configuration — model switching, sampling overrides — by returning a replacement LlmCallConfig (the frozen seed is the config the loop would otherwise use). Config is ALL a listener shapes here: every request is a pure function of the session log (the reconstructability RFC), so model-visible content flows through the log channels — `inject()`, steering, prompt-submit `additionalContext`, prompt sections via `system-prompt/assemble`, or the header-logged session prefix via agent/session-prefix — never through request mutation, and the loop records whatever config the request actually uses as a `request/header*` event before dispatch. The step's messages are already snapshotted when this fires (the `step/start` boundary): an `inject()` from a listener here lands in the log but joins the NEXT request. For surface mutation that must precede the snapshot (compaction), use agent/pre-step. Call `next()` to delegate, or return an LlmCallConfig without it to short-circuit.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
|
||||
@@ -105,13 +97,15 @@ Scope-filtered dispatch: keyed to `agent`.
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:260`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:485`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/session-prefix` — waterfall
|
||||
|
||||
Waterfall: compose the SESSION PREFIX — request-only messages placed in front of the entire derived history (directly after the provider's system slot) on every request this loop instance sends.
|
||||
Waterfall: compose the SESSION PREFIX — request-only messages placed in front of the ENTIRE derived history (directly after the provider's system slot) on every request this loop instance sends. Fired ONCE per loop instance, lazily before its first step's agent/pre-step seam — BEFORE the pre-step so a token-pressure gate (compaction) counts the prefix this instance will actually send, never a previous instance's logged one. The composed result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the instance's anchoring `'initial'`/`'resume'` header snapshot, and reused verbatim for every subsequent request — never recomputed mid-session, so the provider prefix cache holds by construction (a process restart or `ctx.agents.resume()` is a new instance: it recomposes, and any drift lands attributably on the `'resume'` snapshot). Composition runs outside the step, before the boundary snapshot: a composing listener's session append joins the CURRENT request's derived history. A composition interrupted by a cancel/dispose landing inside the waterfall is discarded — never cached, logged, or sent — and the next turn recomposes under a live signal, so an abort-aware listener's degraded fallback cannot leak into later requests.
|
||||
|
||||
Scope-filtered dispatch: keyed to `agent`.
|
||||
This is the home for session-stable openers the model must always see but that must NOT become durable history — a skills catalog, an AGENTS.md digest, a workspace baseline: `Session.deriveMessages()` never returns the prefix, and the header events are its only durable record, so the request stays reconstructable from the log. Content that CHANGES mid-session belongs in the append-only history channels instead — `agent.inject()`, a `tools/post-execute` decision's `additionalContext`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter.
|
||||
|
||||
The seed is a frozen empty list; a contributing listener returns a NEW array — never an in-place push. The canonical contribution is a PREPEND, `[mine, ...await next()]`: the waterfall unwinds innermost-first (the LAST-registered listener's `next()` resolves first), so prepending yields registration order on the wire, and every plugin using it composes deterministically. The append form `[...await next(), mine]` is legal but places a contribution AFTER every later-registered plugin's — reverse registration order when all contributors append. Call `next()` to delegate, or return a list without it to short-circuit. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered through `agent.ctx` fires only for that agent's dispatches; a listener on a plain plugin context fires for every agent. The dispatch `this` is the scope carrier (`Scoped<Agent>`), built by the emitting side via `scopeTarget`/`agentEvents`.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/session-prefix'(this: Scoped<Agent>, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>
|
||||
@@ -119,27 +113,23 @@ Scope-filtered dispatch: keyed to `agent`.
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:272`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:537`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/session-start` — emit
|
||||
|
||||
The agent's session lifecycle began, fired once before its first turn. `source` says why (SessionStartSource: fresh startup, a resumed persisted session, …).
|
||||
|
||||
Scope-filtered dispatch: keyed to `agent`.
|
||||
The agent's session lifecycle began, fired once before its first turn. `source` says why (SessionStartSource: fresh startup, a resumed persisted session, …). A pure NOTIFICATION (emit, not waterfall): a listener cannot veto by returning a decision or throwing. A listener that wants to seed context does so via `agent.inject()` (a `context/message` the first request sees). A lifecycle owner can still dispose its structural ownership edge during this notification; publication rechecks liveness and then aborts before the driver starts.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/session-start'(this: Scoped<Agent>, agent: Agent, source: SessionStartSource): void
|
||||
```
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
Types: [Agent](../core-data-structures/core.md) · [SessionStartSource](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:215`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:381`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/status` — emit
|
||||
|
||||
Agent status changed (`idle` ⇄ `running`, or → `disposed`).
|
||||
|
||||
Scope-filtered dispatch: keyed to `agent`.
|
||||
Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle off this transition, never off a status you just requested — `send()` does not flip status to `running` before it returns.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void
|
||||
@@ -147,27 +137,23 @@ Scope-filtered dispatch: keyed to `agent`.
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:191`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:345`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/step-result` — waterfall
|
||||
|
||||
Waterfall: post-process the assembled assistant Message before tool dispatch (validation, content rewriting, …).
|
||||
|
||||
Scope-filtered dispatch: keyed to `agent`.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/step-result'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, message: Message, next: () => Promise<Message>): Promise<Message>
|
||||
```
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:284`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:552`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/turn-continuation` — waterfall
|
||||
|
||||
Waterfall: override the turn-continuation decision via a typed ContinuationDecision.
|
||||
|
||||
Scope-filtered dispatch: keyed to `agent`.
|
||||
Waterfall: override the turn-continuation decision via a typed ContinuationDecision. The loop's `defaultDecision` is `continue` when the step had tool calls or steering was injected, else `stop`. Listeners force-continue (`/goal`, `/loop` — optionally attaching a `reason` recorded as next-step steering) or force-stop (budget guards). Call `next()` to delegate to the default, or return a decision to override.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/turn-continuation'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
|
||||
@@ -175,13 +161,11 @@ Scope-filtered dispatch: keyed to `agent`.
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:295`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:570`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/turn-stop` — serial
|
||||
|
||||
Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` waterfall, any `continue.reason`, and the pending-steering continuation override have been folded.
|
||||
|
||||
Scope-filtered dispatch: keyed to `agent`.
|
||||
Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` waterfall, any `continue.reason`, and the pending-steering continuation override have been folded. A listener returns `{ action: 'stop' }` to make this turn terminal, or `undefined` to abstain. Terminal stop is monotonic: listener order and steering cannot resume the turn, and pending steering is discarded rather than becoming another step or turn. A malformed non-undefined result fails the turn closed.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number): Promise<ContinuationStop | undefined> | ContinuationStop | undefined
|
||||
@@ -189,13 +173,13 @@ Scope-filtered dispatch: keyed to `agent`.
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:306`](../../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.
|
||||
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<ApprovalService>, req: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome>
|
||||
@@ -203,7 +187,7 @@ Waterfall asking the composed answerers to decide one approval request.
|
||||
|
||||
Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md)
|
||||
|
||||
Source: [`packages/ui/user-approval/src/index.ts:33`](../../packages/ui/user-approval/src/index.ts)
|
||||
Source: [`packages/ui/user-approval/src/index.ts:70`](../../packages/ui/user-approval/src/index.ts)
|
||||
|
||||
## `fs/*`
|
||||
|
||||
@@ -261,19 +245,27 @@ Source: [`packages/llm/llm/src/index.ts:39`](../../packages/llm/llm/src/index.ts
|
||||
|
||||
### `session/created` — emit
|
||||
|
||||
A session was created in the store. Dispatch uses the session's captured owner scope.
|
||||
A session was created in the store. A synchronous listener throw vetoes publication and rollback emits the matching `session/disposed` edge; returned-promise rejection is observed and logged but cannot retroactively veto this synchronous boundary. A synchronous listener that requests the advanced detach does not remove the entry immediately: removal and the paired `session/disposed` edge wait until the creation dispatch unwinds. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the session's owner scope, captured when the session was ENTERED (an agent's session is entered through `agent.ctx`, so its events dispatch in that agent's scope; a bare `sessions.create()` from a plain plugin dispatches subject-less). A listener registered through `agent.ctx` hears only that agent's sessions; a plain plugin listener hears every session.
|
||||
|
||||
```ts cordis-catalog
|
||||
'session/created'(this: Scoped<Session>, session: Session): void
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:43`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:52`](../../packages/core/session/src/index.ts)
|
||||
|
||||
### `session/disposed` — emit
|
||||
|
||||
A previously announced session left the store. Emitted exactly once on normal detach or publication rollback, and never for a prepared/entered session whose `session/created` announcement did not begin. Listener failures (including returned-promise rejections) are logged and contained per listener so teardown always reaches quiescence. Scope-filtered dispatch uses the same owner carrier captured at entry; agent-scoped listeners hear only their own session's teardown.
|
||||
|
||||
```ts cordis-catalog
|
||||
'session/disposed'(this: Scoped<Session>, session: Session): void
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:64`](../../packages/core/session/src/index.ts)
|
||||
|
||||
### `session/event` — emit
|
||||
|
||||
An event was appended to a session log (sync, fire-and-forget).
|
||||
|
||||
Scope-filtered dispatch: keyed to the session's captured owner.
|
||||
An event was appended to a session log (sync, fire-and-forget). This is the per-append feed a UI or invariant plugin tails. The log push is the commit point; synchronous throws and returned-promise rejections from observers are logged and contained per listener, so they cannot make a committed append appear to fail or starve later listeners. The exact callback list and Cordis internal-dispatch checks resolve before the push; callbacks themselves run only after it. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the session's owner scope, captured when the session was ENTERED (an agent's session is entered through `agent.ctx`, so its events dispatch in that agent's scope; a bare `sessions.create()` from a plain plugin dispatches subject-less). A listener registered through `agent.ctx` hears only that agent's sessions; a plain plugin listener hears every session.
|
||||
|
||||
```ts cordis-catalog
|
||||
'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
|
||||
@@ -281,19 +273,17 @@ Scope-filtered dispatch: keyed to the session's captured owner.
|
||||
|
||||
Types: [SessionEvent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:52`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:83`](../../packages/core/session/src/index.ts)
|
||||
|
||||
### `session/flush` — parallel
|
||||
|
||||
Awaited durability checkpoint.
|
||||
|
||||
Scope-filtered dispatch: keyed to the session's captured owner.
|
||||
Awaited durability checkpoint. The agent loop awaits `ctx.sessions.flush(session)` at every turn end; persistence plugins (JSONL, SQLite) drain their write-behind buffers here and on fiber dispose. Awaited (parallel), not a waterfall: every listener runs and the caller waits for all of them, but none can veto. Dispatch it through SessionStore.flush — the store owns the carrier — never via a raw `ctx.parallel`. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the session's owner scope, captured when the session was ENTERED (an agent's session is entered through `agent.ctx`, so its events dispatch in that agent's scope; a bare `sessions.create()` from a plain plugin dispatches subject-less). A listener registered through `agent.ctx` hears only that agent's sessions; a plain plugin listener hears every session.
|
||||
|
||||
```ts cordis-catalog
|
||||
'session/flush'(this: Scoped<Session>, session: Session): Promise<void> | void
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:60`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:101`](../../packages/core/session/src/index.ts)
|
||||
|
||||
## `skill/*`
|
||||
|
||||
@@ -305,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:130`](../../packages/skill/skill/src/index.ts)
|
||||
Source: [`packages/skill/skill/src/index.ts:131`](../../packages/skill/skill/src/index.ts)
|
||||
|
||||
### `skill/provider-removed` — emit
|
||||
|
||||
@@ -315,73 +305,71 @@ 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:136`](../../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. Dispatch is scoped to the delegating parent. Scope-filtered dispatch: keyed to the delegating parent.
|
||||
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<SubagentService>, info: SubagentRunEndInfo): void
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:80`](../../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:49`](../../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:60`](../../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.
|
||||
|
||||
Scope-filtered dispatch: keyed to the delegating parent.
|
||||
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<SubagentService>, info: SubagentRunInfo): void
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:69`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:82`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
## `system-prompt/*`
|
||||
|
||||
### `system-prompt/assemble` — waterfall
|
||||
|
||||
Waterfall around prompt assembly — mutate or extend the PromptAssembly (sections + tools + variables) before it is rendered.
|
||||
Waterfall around prompt assembly — mutate or extend the PromptAssembly (sections + tools + variables) before it is rendered. Bound to the SystemPrompt service; call `next()` to delegate.
|
||||
|
||||
```ts cordis-catalog
|
||||
'system-prompt/assemble'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
|
||||
```
|
||||
|
||||
Source: [`packages/core/system-prompt/src/index.ts:30`](../../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:40`](../../packages/core/system-prompt/src/index.ts)
|
||||
Source: [`packages/core/system-prompt/src/index.ts:55`](../../packages/core/system-prompt/src/index.ts)
|
||||
|
||||
## `tools/*`
|
||||
|
||||
@@ -393,13 +381,11 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai
|
||||
'tools/change'(): void
|
||||
```
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:125`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:173`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
### `tools/execute` — waterfall
|
||||
|
||||
Around-dispatch waterfall wrapping the registry's core tool dispatch, between the `tools/pre-execute` gate and the `tools/post-execute` seam.
|
||||
|
||||
Scope-filtered dispatch: keyed to `exec.agent`; agent-less calls reach global listeners.
|
||||
Around-dispatch waterfall wrapping the registry's core tool dispatch, between the `tools/pre-execute` gate and the `tools/post-execute` seam. A listener receives `(exec, next)`: call `next()` to delegate to dispatch (returning its ToolExecutionResult, optionally wrapped), or return a replacement result without calling `next()` to short-circuit dispatch. The base `next()` IS the dispatch-with-normalization thunk — a thrown tool (or unknown tool) is already normalized to an `isError` result by the time a listener's `await next()` returns, so a wrapper never sees a raw throw from the tool body. This is the seam a timeout/retry/metrics plugin wraps: it can set or replace the one mutable field, `exec.signal` (e.g. with a per-call deadline), BEFORE `next()`, restore/delete it afterward, and inspect the result AFTER. Call identity (`token`, `callId`, `name`, `arguments`, `agent`, and `parent`) is immutable throughout the pipeline so a wrapper cannot change which tool and scope the pipeline accepted. (Cordis `next()` ignores passed arguments and re-invokes downstream with the shared payload, so a wrapper changes `exec.signal` in place rather than passing a new object to `next()`.) Multiple listeners compose by registration order — an outer one wraps the inner ones plus dispatch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by `exec.agent` — a listener registered through `agent.ctx` wraps only that agent's calls; a plain plugin listener wraps every call (including agent-less ones, which dispatch subject-less).
|
||||
|
||||
```ts cordis-catalog
|
||||
'tools/execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
|
||||
@@ -407,25 +393,23 @@ Scope-filtered dispatch: keyed to `exec.agent`; agent-less calls reach global li
|
||||
|
||||
Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:93`](../../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`).
|
||||
|
||||
Scope-filtered dispatch: keyed to `exec.agent`; agent-less calls reach global listeners.
|
||||
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<ToolRegistry>, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
|
||||
'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
|
||||
```
|
||||
|
||||
Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../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: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`).
|
||||
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<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
|
||||
@@ -433,13 +417,11 @@ 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:84`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:101`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
### `tools/result` — parallel
|
||||
|
||||
Awaited notification of the authoritative final tool outcome, after the complete pre/execute/post pipeline, final lossless-JSON validation, and outer error normalization.
|
||||
|
||||
Scope-filtered dispatch: keyed to `exec.agent`; agent-less calls reach global listeners.
|
||||
Awaited notification of the authoritative FINAL tool outcome, after the complete pre/execute/post pipeline, final lossless-JSON validation, and outer error normalization. Unlike the three waterfalls, this seam cannot transform the result: each listener receives the now-frozen execution object and a deep-frozen result snapshot; listener failures are contained and logged, and ToolRegistry.execute still returns the outcome. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`, using the same carrier as the pipeline.
|
||||
|
||||
```ts cordis-catalog
|
||||
'tools/result'(this: Scoped<ToolRegistry>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): Promise<void> | void
|
||||
@@ -447,7 +429,7 @@ Scope-filtered dispatch: keyed to `exec.agent`; agent-less calls reach global li
|
||||
|
||||
Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:115`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:163`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
## `workflow/*`
|
||||
|
||||
@@ -459,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:82`](../../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:71`](../../packages/workflow/workflow/src/index.ts)
|
||||
Source: [`packages/workflow/workflow/src/index.ts:85`](../../packages/workflow/workflow/src/index.ts)
|
||||
|
||||
### `workflow/end` — emit
|
||||
|
||||
@@ -479,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:92`](../../packages/workflow/workflow/src/index.ts)
|
||||
Source: [`packages/workflow/workflow/src/index.ts:106`](../../packages/workflow/workflow/src/index.ts)
|
||||
|
||||
### `workflow/log` — emit
|
||||
|
||||
@@ -489,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:61`](../../packages/workflow/workflow/src/index.ts)
|
||||
Source: [`packages/workflow/workflow/src/index.ts:75`](../../packages/workflow/workflow/src/index.ts)
|
||||
|
||||
### `workflow/phase` — emit
|
||||
|
||||
@@ -499,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:54`](../../packages/workflow/workflow/src/index.ts)
|
||||
Source: [`packages/workflow/workflow/src/index.ts:68`](../../packages/workflow/workflow/src/index.ts)
|
||||
|
||||
### `workflow/start` — emit
|
||||
|
||||
@@ -509,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:46`](../../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)
|
||||
|
||||
|
||||
@@ -11,17 +11,15 @@ 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<SessionHeader, 'cwd'> = {}): ReactLoopAgent
|
||||
async createAgent(options: CreateAgentOptions): Promise<AgentHandle>
|
||||
async resume(options: ResumeAgentOptions): Promise<AgentHandle>
|
||||
async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>
|
||||
async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>
|
||||
```
|
||||
|
||||
Source: [`packages/core/agent-loop/src/index.ts:62`](../../packages/core/agent-loop/src/index.ts)
|
||||
Source: [`packages/core/agent-loop/src/index.ts:338`](../../packages/core/agent-loop/src/index.ts)
|
||||
|
||||
## `ctx.agents` — `AgentRegistry`
|
||||
|
||||
@@ -40,19 +38,21 @@ list(): Agent[]
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/index.ts:141`](../../packages/core/agent/src/index.ts)
|
||||
Source: [`packages/core/agent/src/index.ts:203`](../../packages/core/agent/src/index.ts)
|
||||
|
||||
## `ctx.approval` — `ApprovalService`
|
||||
|
||||
The `ctx.approval` service: dispatches ApprovalRequests to the `approval/request` waterfall and audits every ask/outcome pair to the requesting agent's session log. Stateless between requests — grants are returned to the caller, never stored here.
|
||||
|
||||
Owns the policy tier too (`effective = fold(the session's 'approval/policy' events) ?? config.policy`): `request()` resolves `'never'` to `'rejected'` before dispatching any interactive answerer, a per-agent prompt section states a `'never'` policy (and only that one in prose — an `'ask'` promise could overclaim an answerer that headless compositions do not have), and an `agent/pre-step` narrator injects at most one coalesced notice when a session's effective policy moved past what the model was last told.
|
||||
|
||||
```ts cordis-catalog
|
||||
async request(req: ApprovalRequest): Promise<ApprovalOutcome>
|
||||
```
|
||||
|
||||
Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md)
|
||||
|
||||
Source: [`packages/ui/user-approval/src/index.ts:244`](../../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)
|
||||
|
||||
@@ -147,6 +147,13 @@ Source: [`packages/sandbox/sandbox/src/index.ts:109`](../../packages/sandbox/san
|
||||
|
||||
Abstract durable session-persistence service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.sessionPersistence` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior).
|
||||
|
||||
Contracts every implementation MUST honor (a DB backend asserts them inside a transaction; a file backend appends at EOF):
|
||||
|
||||
- **Append-only; a crashed turn is closed, not truncated.** Committed events — those at or below a flushed `turn/end` — are never rewritten. A crash can leave an unclosed final turn whose events are real (and possibly large); load preserves them and closes the orphaned turn with synthetic boundary events (see load). Only a never-fully-written torn tail fragment is discarded.
|
||||
- **Contiguous seq.** A persisted log is contiguous: `events[i].seq === i`. load rejects a parse error or a `seq` gap in the COMMITTED region (unloadable); append's first event `seq` MUST equal the backend's stored next-seq (after `load` has balanced any interrupted turn).
|
||||
- **JSON-serializable events.** `SessionEventMap` is merge-extensible, so append materializes each complete batch through the shared lossless-JSON boundary before buffering it. The public `session.events` view is immutable, but persistence still snapshots direct/replay callers at this independent trust boundary.
|
||||
- **Durability.** append returns only once the batch is durable (the file backend fsyncs; a DB commits). create MAY defer the physical write until the first append (lazy materialization).
|
||||
|
||||
```ts cordis-catalog
|
||||
abstract create(meta: SessionHeader): Promise<void>
|
||||
abstract append(id: SessionId, events: readonly SessionEvent[]): Promise<void>
|
||||
@@ -156,7 +163,7 @@ abstract list(): Promise<SessionHeader[]>
|
||||
|
||||
Types: [SessionEvent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/session-persistence/session-persistence/src/index.ts:61`](../../packages/session-persistence/session-persistence/src/index.ts)
|
||||
Source: [`packages/session-persistence/session-persistence/src/index.ts:102`](../../packages/session-persistence/session-persistence/src/index.ts)
|
||||
|
||||
## `ctx.sessions` — `SessionStore`
|
||||
|
||||
@@ -175,7 +182,7 @@ list(): Session[]
|
||||
fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:333`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:591`](../../packages/core/session/src/index.ts)
|
||||
|
||||
## `ctx.skills` — `SkillService`
|
||||
|
||||
@@ -188,53 +195,52 @@ async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]>
|
||||
async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined>
|
||||
```
|
||||
|
||||
Source: [`packages/skill/skill/src/index.ts:157`](../../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> | void
|
||||
getProvider(name: string): SubagentProvider | undefined
|
||||
list(): string[]
|
||||
start(name: string, request: SubagentStartRequest): SubagentRun
|
||||
async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:126`](../../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 authoritative contribution protections; the agent loop calls `assemble(context)` once per step. Registers the harness-owned `harness:identity` and `deployment:persona` sections itself (see Config.persona).
|
||||
Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, named prompt variables, and owner-final 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> | void
|
||||
tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise<void> | void
|
||||
variable(name: string, provider: (context: AssembleContext) => string | undefined): () => Promise<void> | void
|
||||
protect(protection: PromptProtection): () => Promise<void> | void
|
||||
async assemble(context: AssembleContext = {}): Promise<PromptAssembly>
|
||||
```
|
||||
|
||||
Source: [`packages/core/system-prompt/src/index.ts:291`](../../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.
|
||||
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 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> | void
|
||||
restrict(filter: ToolRestriction): () => Promise<void> | void
|
||||
guard(guard: ToolGuard): () => Promise<void> | 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<ToolExecutionResult>
|
||||
```
|
||||
|
||||
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:374`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:500`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
## `ctx.userInteraction` — `UserInteractionService`
|
||||
|
||||
@@ -273,11 +279,18 @@ Source: [`packages/web/web/src/index.ts:79`](../../packages/web/web/src/index.ts
|
||||
|
||||
Abstract workflow execution service. Subclass, implement start, and load the subclass as a plugin — it registers as `ctx.workflows` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior).
|
||||
|
||||
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 (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.
|
||||
|
||||
```ts cordis-catalog
|
||||
abstract start(request: WorkflowStartRequest): WorkflowRun
|
||||
```
|
||||
|
||||
Source: [`packages/workflow/workflow/src/index.ts:157`](../../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)
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ The mode is part of the event's public contract. New harness events document it
|
||||
|
||||
`ctx.waterfall` is around-middleware. A listener receives `(...args, next)`. Call `next()` to delegate the possibly wrapped result to the next service; return without `next()` to short-circuit. Values propagate through `next()`'s return value.
|
||||
|
||||
Cooperative listeners usually mutate a shared request or decision object and then delegate. A listener can also choose to repalce the result entirely and downstream listeners will only see the result after replacement. Use `prepend: true` only when the listener must run before ordinary registrations.
|
||||
Cooperative listeners usually mutate a shared request or decision object and then delegate. A listener can also choose to replace the result entirely and downstream listeners will only see the result after replacement. Use `prepend: true` only when the listener must run before ordinary registrations.
|
||||
|
||||
For single-decision events, short-circuiting is the design. A policy listener can return without `next()` when it owns the decision, while a listener that only annotates or observes must delegate.
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -16,8 +16,10 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
|
||||
| Sub-page | Owns |
|
||||
|---|---|
|
||||
| [llm-streaming.md](llm-streaming.md) | the `StreamChunk` wire protocol + adapter contract, `BlockAssembler`, the `LlmAdapter` seam |
|
||||
| [scope.md](scope.md) | scoped registration identity, dispatch carriers, and the owned `Scope` context |
|
||||
| [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, the turn-enclosure invariant |
|
||||
| [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` |
|
||||
| [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, and canonical contribution protection |
|
||||
| [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, and the guarded execution pipeline |
|
||||
| [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy |
|
||||
| [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit and answerer contracts |
|
||||
@@ -265,12 +267,21 @@ interface Agent {
|
||||
*/
|
||||
readonly ctx: Context
|
||||
|
||||
/** Queue a user message. Starts a turn when idle; otherwise waits for the next turn. */
|
||||
/**
|
||||
* Queue a user message. Starts a turn when idle; otherwise waits for the next
|
||||
* turn. Content and the resolved source are accepted as one detached,
|
||||
* deeply-frozen lossless-JSON record before notification or enqueue, so
|
||||
* caller or `agent/queued` listener in-place mutation cannot change later
|
||||
* log/model input. Throws synchronously when either value is not losslessly
|
||||
* JSON-serializable; `agent/prompt-submit` may still return an explicit
|
||||
* replacement.
|
||||
*/
|
||||
send(content: ContentBlock[], options?: SendOptions): void
|
||||
|
||||
/**
|
||||
* Steer a running turn: content is injected between steps of the current
|
||||
* turn. When idle, behaves like {@link send}.
|
||||
* turn. Uses the same owned-value and synchronous-validation boundary as
|
||||
* {@link send}; when idle, behaves exactly like that method.
|
||||
*/
|
||||
steer(content: ContentBlock[], options?: SendOptions): void
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
31
docs/core-data-structures/scope.md
Normal file
31
docs/core-data-structures/scope.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# 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-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).
|
||||
|
||||
## Identity and dispatch carrier
|
||||
|
||||
`ScopeKey` is an opaque object identity. The shipped loop uses the live `Agent` object as its own key, but the primitive never inspects the object.
|
||||
|
||||
```ts type-equiv
|
||||
type ScopeKey = object
|
||||
```
|
||||
|
||||
`Scoped<T>` 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 extends object> = object & { readonly [ScopedBrand]: T }
|
||||
```
|
||||
|
||||
## Owned registration context
|
||||
|
||||
`Scope` pairs the tagged registration context with two teardown surfaces. `rawDispose` preserves the exact Cordis disposer identity needed by an ordered composite effect; `dispose()` is the public shared quiescence boundary for direct and racing callers.
|
||||
|
||||
```ts type-equiv
|
||||
interface Scope {
|
||||
ctx: Context
|
||||
rawDispose: () => Promise<void> | void
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
```
|
||||
@@ -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()`. 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<SkillCandidate[]>
|
||||
get(candidate: SkillCandidate, options: SkillLookupOptions): Promise<SkillDefinition | undefined>
|
||||
readonly name: string
|
||||
readonly list: (options: SkillLookupOptions) => Promise<readonly SkillCandidate[]>
|
||||
readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise<SkillDefinition | undefined>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -28,7 +28,7 @@ The shipped local provider scans roots in rank order:
|
||||
| 400 | `user-dsh` | `<dshHome>/skills` |
|
||||
| 500 | `user-agents` | `<agentsHome>/skills` |
|
||||
|
||||
The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. When `ctx.fs` is available, the git-root walk probes `.git` through the filesystem service so remote or sandboxed workspaces do not fall back to the host filesystem boundary. The user DSH root skips its `.system` child, and DeepSeek Harness no longer ships built-in system skills from the local provider. Additional built-ins can be supplied later by another provider.
|
||||
The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. When `ctx.fs` is available, the git-root walk probes `.git` through the filesystem service so remote or sandboxed workspaces do not fall back to the host filesystem boundary. The user DSH root skips its `.system` child. The local provider does not ship built-in system skills; deployments supply built-ins through another provider.
|
||||
|
||||
## Skill identity
|
||||
|
||||
@@ -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<string, unknown>
|
||||
readonly rank: number
|
||||
readonly locator: unknown
|
||||
readonly path?: string
|
||||
readonly metadata?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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<string, unknown>
|
||||
readonly content: string
|
||||
readonly path?: string
|
||||
readonly metadata?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -86,18 +86,18 @@ Runtime skills use the same complete shape and participate in the same first-win
|
||||
|
||||
```ts type-equiv
|
||||
type SkillRegistration = Omit<SkillDefinition, 'provider'> & {
|
||||
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. 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 {
|
||||
cwd?: string | undefined
|
||||
signal?: AbortSignal | undefined
|
||||
readonly cwd?: string | undefined
|
||||
readonly signal?: AbortSignal | undefined
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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,38 +64,36 @@ 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<void>
|
||||
readonly result: Promise<SubagentResult>
|
||||
cancel(reason?: string): void
|
||||
dispose(): Promise<void>
|
||||
sendMessage?(content: ContentBlock[]): void
|
||||
resume?(content: ContentBlock[]): SubagentRun
|
||||
resume?(content: ContentBlock[]): Promise<SubagentRun>
|
||||
}
|
||||
```
|
||||
|
||||
## The provider seam: `SubagentProvider`
|
||||
|
||||
One transport for running a child agent. Implementations register under a unique name via `SubagentService.registerProvider`; multiple coexist in one context. The service validates every requested start-time capability before calling `start`, so an implementation may assume e.g. `request.maxDepth` is honorable when present. `inheritsParentContext` is a DESCRIPTIVE fact beside the capabilities (nothing validates against it): whether a child sees the parent conversation (`fork`: true, `spawn`/`acp`: false) — the model-facing consumer derives truthful tool wording from it.
|
||||
One transport for running a child agent. Implementations register under a unique name via `SubagentService.registerProvider`; multiple coexist in one context. The service validates every requested start-time capability before calling `start`, so an implementation may assume e.g. `request.maxDepth` is honorable when present. `inheritsParentContext` is a DESCRIPTIVE fact beside the capabilities (nothing validates against it): whether a child sees the parent conversation (`fork`: true, `spawn`/`acp`: false) — the model-facing consumer derives truthful tool wording from it. It describes conversation history only, not tool registrations, injected services, or authority inheritance.
|
||||
|
||||
```ts type-equiv
|
||||
interface SubagentProvider {
|
||||
readonly name: string
|
||||
readonly capabilities: SubagentCapabilities
|
||||
readonly inheritsParentContext: boolean
|
||||
start(request: SubagentStartRequest): SubagentRun
|
||||
start(request: SubagentStartRequest): Promise<SubagentRun>
|
||||
}
|
||||
```
|
||||
|
||||
`subagent/start` follows successful readiness; `subagent/end` follows settlement of that announced run. Readiness rejection emits neither. In-process children can be resolved through the agent registry, while remote providers may have no local agent. End events carry cloned `lastAssistantMessage` on successful settlement and omit it on infrastructure failure. Both events are observe-only, preserve start-before-end order, and contain subscriber exceptions independently. See the [events catalog](../cordis-catalog/events.md) for signatures.
|
||||
`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 capabilities. Their `started` promise projects the factory's successful publication and the result driver awaits that same promise before sending the prompt. Two pieces of vocabulary ride on the existing agent/session types rather than new core types:
|
||||
The two in-process backends ([dsh-subagent-spawn](../../packages/subagent/subagent-spawn) fresh, [dsh-subagent-fork](../../packages/subagent/subagent-fork) seeded) run the child as 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). The seam owns it — the loop neither sets nor reads it — so a nested spawn reads its parent's depth from `parent.options.subagentDepth` and the `depthLimit` capability caps the tree by refusing a child whose depth would exceed `request.maxDepth`.
|
||||
- **Delegation depth** is a merge-extensible `AgentOptions.subagentDepth` field (`0` for a top-level agent, parent + 1 for a child). Only `undefined` means top level; every stored present value must be a non-negative safe integer. The seam owns it — the loop neither sets nor reads it — so a nested spawn validates its parent's stored depth, rejects a derived child depth outside the safe-integer domain, and applies a defined absolute `request.maxDepth` cap to that child.
|
||||
- **Fork seeding** uses `CreateAgentOptions.seed` (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `resume` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/support/invariants) replay accepts it (the in-flight, unbalanced turn is excluded).
|
||||
|
||||
40
docs/core-data-structures/system-prompt.md
Normal file
40
docs/core-data-structures/system-prompt.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# System Prompt Assembly
|
||||
|
||||
The [system-prompt package](../../packages/core/system-prompt) owns the data exchanged between prompt contributors and one assembly call. The package [README](../../packages/core/system-prompt/README.md) documents registration, ordering, scoping, and rendering behavior; this page pins the literal cross-package shapes that plugins implement or pass.
|
||||
|
||||
Source: [`packages/core/system-prompt/src/index.ts`](../../packages/core/system-prompt/src/index.ts).
|
||||
|
||||
## Assembly context
|
||||
|
||||
`AssembleContext` identifies the scope layer one assembly resolves. It is merge-extensible: `dsh-agent` adds the optional live `agent` field, and `assembleContextFor(agent)` sets that field and `scope` together.
|
||||
|
||||
```ts type-equiv
|
||||
interface AssembleContext {
|
||||
scope?: ScopeKey
|
||||
}
|
||||
```
|
||||
|
||||
## 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. `ownerFinalNames` identifies tool contributions whose canonical presence or absence survives the assembly waterfall.
|
||||
|
||||
```ts type-equiv
|
||||
interface ToolProviderResult {
|
||||
readonly schemas: readonly ToolSchema[]
|
||||
readonly knownNames?: readonly string[]
|
||||
readonly ownerFinalNames?: readonly string[]
|
||||
}
|
||||
```
|
||||
|
||||
## Prompt sections and owner finality
|
||||
|
||||
`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 PromptSection {
|
||||
readonly name: string
|
||||
readonly order: number
|
||||
readonly text: string | ((context: AssembleContext) => string)
|
||||
readonly ownerFinal?: boolean
|
||||
}
|
||||
```
|
||||
@@ -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<S extends SchemaSpec> = Simplify<
|
||||
|
||||
`defineTool({ name, description, parameters, execute, … })` ties it together: `parameters` is a `SchemaSpec`, `execute(args, exec)` gets `args: InferArgs<typeof parameters>`, 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()` validates `ToolDefinition.parameters` as lossless JSON before and after cloning, copies the scalar fields, binds the 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()` requires the caller's `arguments` to be losslessly JSON-serializable, checks again after cloning to contain unstable accessors, assigns a fresh token, and deep-freezes the detached arguments. A cloneable mutable exotic such as `Map` is rejected and normalized to an error before policy. `token`, `callId`, `name`, `arguments`, `agent`, and the optional `parent` token are non-writable throughout all waterfalls, so a listener cannot change which capability or scope was authorized or reach a live enclosing execution; an around-dispatch wrapper may add, replace, or remove only optional `signal`. After the complete pipeline the registry freezes the execution and exposes its stable identity to `tools/result` observers, where the execution remains usable as a `WeakMap` key without mutation races.
|
||||
`ToolExecutionToken` is a compile-time opaque 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`:
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
development.md: bd6f6b561480419abea7a42a44b4078e2c59b1cb
|
||||
development.zh.md: 54bf19765d2b4dc419e6b71684dbcfcd28230541
|
||||
development.md: ea5f2e5d08acbaf1dfce4661530218dbf1a051b9
|
||||
development.zh.md: 50877796f2ff47ad46cc67b35f3cd7b5704c8315
|
||||
|
||||
@@ -67,7 +67,7 @@ These hooks do not exactly mirror CI. Notably, `pre-push` runs unit tests withou
|
||||
|
||||
## CI gates
|
||||
|
||||
The keyless GitHub workflow has eight jobs: five Node 24 lanes run static gates, lint, coverage, snapshot replay, and artifact gates separately, and three compatibility jobs run `pnpm run check:node-compat` on Node 22.19, 24, and 26. The lane schedulers fan out independent gates from `package.json`: constraints, typecheck, lint, coverage, snapshot replay, `doc-sync` members, module-graph freshness, `knip`, and the echo-agent smoke test.
|
||||
The keyless GitHub workflow has eight jobs: five Node 24 lanes run static gates, lint, coverage, snapshot replay, and artifact gates separately, and three compatibility jobs run `pnpm run check:node-compat` on Node 22.19, 24, and 26. The compatibility command runs the TypeScript typecheck and a keyless workflow-workerthread source-launch smoke on every runtime, so the matrix proves that the source graph typechecks and that a real unbuilt Worker loader path executes; the other lane schedulers fan out independent gates from `package.json`: constraints, lint, coverage, snapshot replay, `doc-sync` members, module-graph freshness, `knip`, and the echo-agent smoke test.
|
||||
|
||||
`pnpm run build` feeds the artifact lane, and `publint`, `verify-node-next-types`, and built-bin smoke tests wait for build output. The separate real-API workflow runs `pnpm run test:e2e` with a secret and `DSH_E2E_MAX_WORKERS=14`.
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `v
|
||||
|
||||
## CI 门禁
|
||||
|
||||
keyless GitHub 工作流有八个 job:五个 Node 24 lane 分别运行 static gates、lint、coverage、snapshot replay 和 artifact gates,三个兼容性 job 在 Node 22.19、24 和 26 上运行 `pnpm run check:node-compat`。各 lane 调度器并发运行来自 `package.json` 的独立门禁:constraints、typecheck、lint、coverage、snapshot replay、`doc-sync` 成员、module graph 新鲜度、`knip` 和 echo-agent 冒烟测试。
|
||||
keyless GitHub 工作流有八个 job:五个 Node 24 lane 分别运行 static gates、lint、coverage、snapshot replay 和 artifact gates,三个兼容性 job 在 Node 22.19、24 和 26 上运行 `pnpm run check:node-compat`。兼容性命令会在每个运行时上运行 TypeScript 类型检查和 keyless 的 workflow-workerthread 源码启动冒烟测试,因此该矩阵既证明源码图能通过类型检查,也会实际执行一条未构建的 Worker loader 路径;其他 lane 调度器并发运行来自 `package.json` 的独立门禁:constraints、lint、coverage、snapshot replay、`doc-sync` 成员、module graph 新鲜度、`knip` 和 echo-agent 冒烟测试。
|
||||
|
||||
`pnpm run build` 供给 artifact lane,`publint`、`verify-node-next-types` 和 built-bin 冒烟测试等待 build 输出。单独的真实 API 工作流带密钥运行 `pnpm run test:e2e`,并设置 `DSH_E2E_MAX_WORKERS=14`。
|
||||
|
||||
|
||||
@@ -7,46 +7,47 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
|
||||
| Event | Mode | Declared in | Dispatchers | Listeners |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:173`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:182`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:319`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:234`](../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:246`](../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:202`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:260`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:272`](../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:215`](../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:191`](../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:284`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:295`](../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:306`](../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:33`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) |
|
||||
| `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: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:60`](../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:69`](../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:51`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:43`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`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:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `skill/provider-added` | `emit` | [`packages/skill/skill/src/index.ts:130`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - |
|
||||
| `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:136`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:80`](../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:49`](../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:60`](../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:69`](../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:30`](../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:40`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
|
||||
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:125`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
|
||||
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:93`](../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:104`](../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:84`](../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:115`](../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:82`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:71`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:92`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:61`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:54`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:46`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `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: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
|
||||
|
||||
|
||||
17
docs/glossary.md
Normal file
17
docs/glossary.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Glossary
|
||||
|
||||
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](#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. <a id="scope-key"></a>
|
||||
- **agent context (`agent.ctx`)** — the agent's scoped context; registrations through it are scope-visible AND scope-lifetime (one fact drives both), and listeners on it participate in that agent's scope-filtered dispatches. Registry-subject events may remain deliberately unfiltered under their own event contracts.
|
||||
- **scope carrier** — the `thisArg` a scope-filtered dispatch carries (built by `scopeTarget`); its filter admits untagged listeners plus the subject's own. A *subject-less* carrier (no key) admits untagged listeners only.
|
||||
- **scoped dispatch** — the rule: an event about one agent's activity dispatches with that agent's carrier. Events about a registry itself (a tool was added) are *registry-subject* and stay unfiltered.
|
||||
- **shadowing** — most-specific-wins name resolution: a scoped tool/section/variable replaces its same-named global twin for that scope alone. The per-agent persona and per-agent tool-variant mechanism.
|
||||
- **restriction / scope-local registration** — a restriction (`tools.restrict`) filters the GLOBAL tool surface for one scope (compose by intersection); scope-local registrations are merged after that filter. A filtered-away global tool is absent from the prompt AND refuses execution, indistinguishably from a nonexistent one.
|
||||
- **setup window** — the creation slot where a creator composes an agent's scoped world (`CreateAgentOptions.setup`): after the scope and agent object exist but before the agent or session is published, `agent/session-start` fires, or the first prompt is assembled. Setup registers; it never drives the agent.
|
||||
- **lineage** — parent/child facts carried as data (`parentSession`, `subagentDepth`); never affects visibility. <a id="lineage"></a>
|
||||
@@ -162,6 +162,10 @@ flowchart TD
|
||||
pkg_session_persistence_jsonl --> pkg_session_persistence
|
||||
pkg_session_persistence_sqlite --> pkg_session
|
||||
pkg_session_persistence_sqlite --> pkg_session_persistence
|
||||
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
|
||||
@@ -227,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
|
||||
@@ -289,6 +287,7 @@ flowchart TD
|
||||
pkg_workflow_workerthread --> pkg_agent
|
||||
pkg_workflow_workerthread --> pkg_brand
|
||||
pkg_workflow_workerthread --> pkg_llm
|
||||
pkg_workflow_workerthread --> pkg_session
|
||||
pkg_workflow_workerthread --> pkg_subagent
|
||||
pkg_workflow_workerthread --> pkg_tools
|
||||
pkg_workflow_workerthread --> pkg_workflow
|
||||
@@ -352,6 +351,7 @@ flowchart TD
|
||||
| [`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) |
|
||||
| [`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) |
|
||||
@@ -367,7 +367,6 @@ flowchart TD
|
||||
| [`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) |
|
||||
@@ -378,7 +377,7 @@ flowchart TD
|
||||
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
|
||||
| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
|
||||
| [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) |
|
||||
| [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
|
||||
| [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
|
||||
| [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
|
||||
| [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
|
||||
| [`acp-agent`](../packages/ui/acp-agent) | `ui` | [`acp`](../packages/ui/acp), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
|
||||
|
||||
@@ -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:47`](../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:58`](../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:70`](../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:258`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:267`](../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:265`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:274`](../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:256`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:265`](../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:250`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:259`](../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:294`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:303`](../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:304`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:313`](../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:283`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:292`](../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:243`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:252`](../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:241`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:250`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `todo/*`
|
||||
|
||||
@@ -237,7 +237,7 @@ The agent's whole todo list, carried as a full snapshot and replaced wholesale o
|
||||
|
||||
Types: [TodoItem](core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `tool/*`
|
||||
|
||||
@@ -251,7 +251,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:271`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:280`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `tool/code-dispatch` — log-only
|
||||
|
||||
@@ -275,7 +275,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:281`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:290`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `turn/*`
|
||||
|
||||
@@ -289,7 +289,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:239`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:248`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `turn/start` — log-only
|
||||
|
||||
@@ -301,7 +301,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:233`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `user/*`
|
||||
|
||||
@@ -315,4 +315,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:245`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:254`](../packages/core/session/src/types.ts)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -101,7 +102,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
|
||||
|---|---|
|
||||
| [Provider-neutral content-block vocabulary owned by dsh-llm](implemented/architecture/2026-06-11-content-block-vocabulary.md) | 2026-06-11 |
|
||||
| [Custom typed tool-schema DSL instead of schemastery](implemented/architecture/2026-06-11-custom-schema-dsl.md) | 2026-06-11 |
|
||||
| [Dev-mode invariants over compile-time deep-readonly](implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md) | 2026-06-11 |
|
||||
| [Source-owned session immutability and dev-mode invariants](implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md) | 2026-06-11 |
|
||||
| [Event-sourced sessions with derived message history](implemented/architecture/2026-06-11-event-sourced-sessions.md) | 2026-06-11 |
|
||||
| [Microkernel — extension via Cordis event taxonomy, one concrete loop](implemented/architecture/2026-06-11-microkernel-event-taxonomy.md) | 2026-06-11 |
|
||||
| [Runtime arg validation at the model boundary](implemented/architecture/2026-06-11-runtime-arg-validation.md) | 2026-06-11 |
|
||||
@@ -133,6 +134,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
|
||||
| [A shared timeout/deadline primitive, with hard-kill left to each capability](implemented/architecture/2026-07-06-timeout-deadline-library.md) | 2026-07-06 |
|
||||
| [Tool-call timeout policy as a plugin](implemented/architecture/2026-07-07-tool-call-timeout-policy.md) | 2026-07-07 |
|
||||
| [The agent is a registration scope](implemented/architecture/2026-07-08-agent-scope-contexts.md) | 2026-07-08 |
|
||||
| [Agent-scope runtime design and correctness](implemented/architecture/2026-07-12-agent-scope-runtime-design.md) | 2026-07-12 |
|
||||
|
||||
### Process
|
||||
|
||||
|
||||
@@ -1,29 +1,58 @@
|
||||
# RFC: Dev-mode invariants over compile-time deep-readonly
|
||||
# RFC: Source-owned session immutability and dev-mode invariants
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
The session log is append-only by contract, but the types don't enforce it: `session.events` returns `readonly SessionEvent[]` whose *elements* are mutable, and `deriveMessages()` handed the logged `content` arrays/blocks out by reference. The loop then passes those derived messages into the `agent/request` waterfall and on to adapters, where mutating the request is sanctioned — so a request middleware could reach back and rewrite history, silently breaking replay equivalence and the derived-history guarantee. Separately, the event taxonomy (turn/step nesting, seq monotonicity, tool-call/result pairing, legal status transitions) was asserted only where individual tests happened to look.
|
||||
The session log needs two different protections: immutable ownership of each stored fact, and checks for relationships among facts across time and service seams. Conflating them in an optional development plugin would leave production history vulnerable; trying to express both through TypeScript readonly types would not create a runtime boundary or describe relational rules.
|
||||
|
||||
Two ways to defend the log: make immutability part of the type (`DeepReadonly<SessionEvent>` on the way out), or catch corruption at runtime in dev. The runtime-validation proposal took the runtime route; [the deep-readonly proposal](../../rejected/architecture/2026-06-11-immutable-public-surfaces.md) took the type route.
|
||||
The session log is the durable source of truth for replay, request reconstruction, persistence, and user-visible history. Code outside the session package must be able to inspect that history without retaining a reference that can rewrite it later, and inputs accepted from callers must not remain connected to caller-owned mutable objects.
|
||||
|
||||
Immutability of individual values is only half of the contract. A log can contain perfectly immutable records whose sequence, turn/step nesting, tool-call pairing, scoped delivery, or reconstructed model request is wrong. Those rules relate multiple records or services and cannot be established by freezing one object.
|
||||
|
||||
TypeScript readonly types are not a sufficient runtime boundary. They disappear when the program runs, a cast can bypass them, and a recursive `DeepReadonly<T>` would spread through every log and message consumer even though some downstream request-processing APIs intentionally work with mutable values.
|
||||
|
||||
## Decision
|
||||
|
||||
Reject the pervasive `DeepReadonly<T>` type flip. Instead:
|
||||
Responsibility is split between an always-on storage boundary and optional development assertions.
|
||||
|
||||
1. **Always-on:** `deriveMessages()` deep-clones the content it emits (one `structuredClone` per derived message). In-flight mutation of a request can no longer reach the log — this is the real fix, and it costs nothing meaningful next to a model call.
|
||||
2. **Dev-mode:** a new `dsh-invariants` plugin (pure listeners, off in production, on in tests and demos) asserts the event contract and `Object.freeze`s logged event data so any *other* code that mutates a logged event throws instead of corrupting silently. Seeded sessions are frozen and checked on `session/created` (the constructor copies the seed without emitting `session/event`).
|
||||
### Session owns immutable history
|
||||
|
||||
The invariants encode the *real* contract, not an idealized one: a `tool/call` may have no `tool/result` (a thrown tool-execution pipeline step ends the turn), and both `idle→disposed` and `running→disposed` are legal.
|
||||
`Session` accepts an event only after one recursive pass has materialized a lossless JSON snapshot. That pass rejects unsupported values and produces the exact detached record that enters the log, so validation and storage cannot observe different values from a stateful getter or retain caller-owned nested references.
|
||||
|
||||
The accepted event and all of its descendants are deep-frozen before publication. `append()` returns that owned frozen event, `session/event` observers receive the same record, and `session.events` returns a frozen array snapshot. A previously returned array does not grow after a later append. Seed records pass through the same validation, snapshot, and freeze boundary before construction succeeds.
|
||||
|
||||
This guarantee belongs in `Session`, not in an optional listener, because every composition relies on trustworthy history. A production deployment, a focused test, or a custom embedding receives the same storage semantics whether or not development support plugins are registered.
|
||||
|
||||
### Derived requests remain detached
|
||||
|
||||
`deriveMessages()` projects logged surface events into detached, deep-frozen `Message` objects and returns a fresh array snapshot. Request assembly can therefore combine derived history with other inputs without exposing a path back into the log. The cache reuses safe immutable projections rather than recloning the complete history for each model call.
|
||||
|
||||
### The invariants plugin checks relationships
|
||||
|
||||
`dsh-invariants` is a pure-listener development plugin. It does not freeze records and has no configuration; disposal removes only its assertions. It checks rules that require trace state or observation of another seam, including monotonic sequence numbers, turn and step nesting, tool-call/result pairing, legal agent-status transitions, subject-correct scoped dispatch, and equality between a loop-built request and the request reconstructed from its session-log prefix.
|
||||
|
||||
When the plugin attaches to an existing or seeded session, it replays the immutable log to rebuild trace state. This makes hot reload safe in the middle of a turn without giving the plugin ownership of session storage.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**The pervasive `DeepReadonly<T>` type flip** ([the rejected proposal](../../rejected/architecture/2026-06-11-immutable-public-surfaces.md)) — compile-time only (a plugin casts straight through it), high type-noise across every log/message consumer and adapter, and it would force readonly types through code where mutation is the sanctioned API. The clone draws the mutable/immutable boundary exactly at "logged vs in-flight" without any of that noise.
|
||||
### Pervasive deep-readonly types
|
||||
|
||||
[The rejected immutable-public-surfaces proposal](../../rejected/architecture/2026-06-11-immutable-public-surfaces.md) would apply a recursive readonly type across public log and message surfaces. That provides editor feedback but not a runtime guarantee: TypeScript types are erased and plugin code can cast through them. It also pushes readonly types into consumers where mutation is intentional. Runtime ownership at the `Session` boundary protects every caller without that type propagation.
|
||||
|
||||
### Development-only freezing
|
||||
|
||||
Freezing history only when an invariants plugin is installed would make the core guarantee composition-dependent. Code could pass development tests and still corrupt history in production or in a focused composition that omits the plugin. Storage immutability is therefore always on, while the more expensive relational checks remain opt-in development support.
|
||||
|
||||
### Clone only when deriving messages
|
||||
|
||||
Detaching `deriveMessages()` would protect the most common request path but leave other readers of `session.events`, append return values, and session-event observers able to mutate durable history. The log must protect its own boundary; derived projections are an additional isolation boundary, not a substitute.
|
||||
|
||||
## Consequences
|
||||
|
||||
- History corruption is caught loudly in tests and demos, at zero production cost and zero type noise. The trade-off is that the guarantee is dynamic (a dev-mode tripwire) rather than static.
|
||||
- The invariants plugin doubles as executable documentation of the event taxonomy — the assertions are the contract.
|
||||
- `Session.events` keeps its `readonly SessionEvent[]` type; no consumer churn.
|
||||
- This folds in [the deep-readonly proposal](../../rejected/architecture/2026-06-11-immutable-public-surfaces.md) — there is no separate deep-readonly record; this records the decision to *not* pursue that approach. `InvariantError` is a plain `Error` with a `code` for now; a later taxonomy change can promote it.
|
||||
- Every accepted live or seeded session event is detached from caller-owned inputs and deeply immutable before any observer can receive it.
|
||||
- `session.events` exposes stable immutable snapshots instead of the private growing array.
|
||||
- Request-side mutation cannot reach stored history through derived messages.
|
||||
- Development builds can enable relational assertions without changing storage behavior, and disposing or omitting the plugin does not weaken log immutability.
|
||||
- `dsh-invariants` has no `Config` surface because it has no behavior to tune.
|
||||
- The runtime boundary carries a recursive snapshot-and-freeze cost once per accepted event; later readers and cached projections reuse the owned immutable records.
|
||||
|
||||
@@ -16,9 +16,9 @@ A new `cancel()` verb on the `Agent` interface — the single public stop primit
|
||||
|
||||
### 2. `AgentHandle` async disposer
|
||||
|
||||
`ctx.agents.create`/`resume` (and the `AgentFactory` interface) return `AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **capability** — only the holder can tear down exactly this agent: stop its loop, `await` the loop's exit (true quiescence, not just the `disposed` status flip), unregister it, and remove its session from the store. `ctx.agents.get(id)` still returns a bare `Agent`. Config-created agents stay owned by the `AgentLoop` fiber (the handle is discarded). ACP holds each session's disposer in its `SessionRecord` and runs it on disconnect/teardown, so a bare client disconnect leaves no registered agent and no session-store entry — even when `session/load` races teardown (the just-resumed handle is disposed before the closed-guard throw).
|
||||
`ctx.agents.create`/`resume` (and the `AgentFactory` interface) return `AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **consumer capability** — a registry observer holding only the bare `Agent` cannot tear it down. The caller fiber and registered factory provider are structural co-owners: caller unload enforces structured ownership, while provider unload must stop old instances whose scoped dependency surface resolves through that provider. All three paths reach the same memoized teardown: stop the loop, `await` its exit (true quiescence, not just the `disposed` status flip), unregister it, remove its session from the store, unwind its scope, and only then release both public IDs. Config-created agents are already owned by the `AgentLoop` fiber (the handle is discarded). ACP holds each session's disposer in its `SessionRecord` and runs it on disconnect/teardown, so a bare client disconnect leaves no registered agent and no session-store entry — even when `session/load` races teardown (the just-resumed handle is disposed before the closed-guard throw).
|
||||
|
||||
**Teardown ORDER is load-bearing for durability**, and the implementation folds the session lifecycle into the agent's SINGLE composite cordis effect (`SessionStore.prepare`/`enter`/`announce`, replacing a sibling-effect split). A fiber unload disposes sibling effects concurrently (`Promise.all`), which would race the session's `onAppend` detach against the loop's closing `session/flush` and drop the closing `turn/end`; inside one effect the disposers run as an ordered LIFO chain (loop stopped + `await agent.done` BEFORE the session detaches), so the loop's final flush is captured on BOTH the handle's `dispose()` and a fiber unload. The register disposer's `agent/disposed` emit is contained (a throwing listener must not reject the chain and skip the later session detach).
|
||||
**Teardown ORDER is load-bearing for durability**, and the implementation folds the session lifecycle into the agent's SINGLE composite cordis effect (`SessionStore.prepare`/`enter`/`announce`, replacing a sibling-effect split). A fiber unload disposes sibling effects concurrently (`Promise.all`), which would race removing the session store's append publication hooks against the loop's closing `session/flush` and drop the closing `turn/end`; inside one effect the disposers run as an ordered LIFO chain (loop stopped + `await agent.done` BEFORE the session detaches), so the loop's final flush is captured on BOTH the handle's `dispose()` and a fiber unload. The contained `agent/disposed` and `session/disposed` notifications cannot reject the chain or skip later teardown.
|
||||
|
||||
### 3. Bash owner token in the seam
|
||||
|
||||
@@ -40,7 +40,7 @@ The bash owner-token comparison relies on `session.header.id` being unique among
|
||||
## Alternatives considered
|
||||
|
||||
- **A public `BashTask.owner` field** instead of the `BashExecutor.ownerOf(id)` seam — rejected: one read path, no redundant API.
|
||||
- **Sibling cordis effects for the agent's session lifecycle** — rejected: a fiber unload disposes sibling effects concurrently (`Promise.all`), racing the session's `onAppend` detach against the loop's closing `session/flush`; the single composite effect's ordered LIFO chain is what captures the closing `turn/end` on both disposal paths.
|
||||
- **Sibling cordis effects for the agent's session lifecycle** — rejected: a fiber unload disposes sibling effects concurrently (`Promise.all`), racing removal of the store-owned append publication hooks against the loop's closing `session/flush`; the single composite effect's ordered LIFO chain is what captures the closing `turn/end` on both disposal paths.
|
||||
- **A separate step-only `abort()` beside `cancel()`** — shipped originally, then removed as unused; `cancel()` is the single public stop primitive ([the public-stop-surface RFC](../simplification/2026-06-20-public-agent-stop-surface.md)).
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -28,9 +28,9 @@ This is the foundational change in a stack that adds a Hooks subsystem; it estab
|
||||
|
||||
## Consequences
|
||||
|
||||
- The loop no longer emits any boundary mirror; `closeStep` appends `step/end` only and `closeTurn` appends `turn/end` only. A throwing `step/end`/`turn/end` session-event listener is the surviving boundary-listener failure path (contained inside `closeStep`/`closeTurn` — `Session.append` pushes the event before notifying listeners, so the boundary is durable and the turn closes balanced regardless).
|
||||
- The loop no longer emits any boundary mirror; `closeStep` appends `step/end` only and `closeTurn` appends `turn/end` only. `Session.append` owns post-commit observer containment, so a throwing boundary observer cannot change the turn outcome or starve later consumers; an acceptance or internal validation failure still escapes before the boundary enters the log.
|
||||
- Tests that observed boundaries via the removed emits now observe the durable `turn/start`/`turn/end`/`step/start`/`step/end` session events — the behavior they pin (boundary ordering, step counting) is unchanged; only the feed they read moved to the canonical one. The tests that exercised a *throwing turn-boundary emit listener* were deleted, because that code path no longer exists (there is no emit to throw from). Per [AGENTS.md "tests document behavior, not golden truth"](../../../../AGENTS.md), the behavior and its test moved (or died) together.
|
||||
- The loop marks the step open (`stepOpen = true`) BEFORE appending `step/start`, because `Session.append` pushes the event to the log before notifying `session/event` listeners (validation throws happen earlier, before the push — see [the session append contract](../../../core-data-structures/session.md)). So a throwing `step/start` session-event listener runs with the step already open and the event already in the log: the loop's outer catch then calls `closeStep()`, which appends the balancing `step/end`, and the turn closes balanced with an error (`turn/start → step/start → step/end → turn/end` — verified by the invariants oracle in the regression test). Closing the open step is owed precisely because the marker is set first.
|
||||
- The loop marks the step open (`stepOpen = true`) only after `append('step/start')` returns. Internal dispatch validation runs before the log push and may reject without opening a step; post-commit `session/event` observer failures are contained inside `Session.append`. The marker therefore represents exactly the committed boundary that owes a later `step/end`.
|
||||
- The full realization of this is [the simplification RFC "Stop mirroring durable boundaries as agent events"](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md): all four boundary mirrors are removed and every consumer reads boundaries off `session/event`. `agent/steering` (not a boundary mirror) stayed outside that RFC's scope and was removed by its own follow-up, [Remove the `agent/steering` mirror emit](../simplification/2026-07-04-remove-agent-steering-mirror.md) — it mirrored the durable `steering/message`.
|
||||
- The cordis events catalog (`docs/cordis-catalog/events.md`) is regenerated to drop the mirror events.
|
||||
|
||||
|
||||
@@ -36,9 +36,9 @@ Plugins contribute named values via `ctx.systemPrompt.variable(name, provider)`;
|
||||
|
||||
Per-tool semantics and when-to-use live in tool DESCRIPTIONS, which already ship in every request — the YAML prose was ~fully redundant with them. Sections carry only the cross-call habits a single call's description cannot: `dsh-tool-bash` contributes `tool:bash` (order 105) — check the `[exit code: N]` marker on every result; `dsh-tool-fs`'s read section gains the "not shell commands like cat" contrast. `todo_write` and the subagent tools need NO section — their descriptions already carry the whole contract. The leaf personas shrink to identity + behavior (verify your work; keep answers brief), and the welcome banner stops enumerating tools.
|
||||
|
||||
### The subagent context contract
|
||||
### The subagent conversation-history descriptor
|
||||
|
||||
`SubagentProvider` gains `readonly inheritsParentContext: boolean` — a DESCRIPTIVE fact beside `capabilities`, not in it (capabilities are start-time validation; nothing validates against this flag). Spawn and ACP declare `false`, fork declares `true`. `dsh-tool-subagent` derives both the tool description and the `prompt` parameter description from the flag (`providerWording`): the fork instance now tells the model the child inherits the conversation's completed turns (not the in-flight turn) and that its prompt should state only what is new. Deriving the description from a provider that arrives on its own fiber is what forced the provider-lifecycle events and the tool's reactive registration — that mechanism, its Loader-concurrency rationale, and its rejected alternatives are recorded in [the provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md).
|
||||
`SubagentProvider` gains `readonly inheritsParentContext: boolean` — a DESCRIPTIVE conversation-history fact beside `capabilities`, not in it (capabilities are start-time validation; nothing validates against this flag). Spawn and ACP declare `false`, fork declares `true`. The name refers only to conversation seeding, not Cordis scope, services, tools, or authority. `dsh-tool-subagent` derives both the tool description and the `prompt` parameter description from the flag (`providerWording`): the fork instance now tells the model the child is seeded with the conversation's completed turns (not the in-flight turn) and that its prompt should state only what is new. Deriving the description from a provider that arrives on its own fiber is what forced the provider-lifecycle events and the tool's reactive registration — that mechanism, its Loader-concurrency rationale, and its rejected alternatives are recorded in [the provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ Prefix-cache stability is corollary #1, not the headline: an append-only log pro
|
||||
|
||||
Each step rebuilds the prompt assembly, composes and freezes the session prefix once per loop instance, runs `agent/pre-step`, snapshots derived messages immediately before `step/start`, and folds call config from the logged header. `agent/request` may replace only the frozen config seed; model-visible content must enter through logged channels. The loop then records the owed header event, builds `GenerateOptions` from the prefix, snapshot, and header, and deep-freezes it while leaving `AbortSignal` live. Per-instance state is only the cached prefix and whether its anchoring snapshot has been written.
|
||||
|
||||
**The reconstruction boundary is `step/start`, unconditionally.** A step's messages are the derivation over `events[0..stepStartSeq)`. Because the snapshot precedes the `step/start` append in the same synchronous frame, nothing can enter this request past the boundary: an `agent.inject()` from an `agent/request` listener (or any concurrent task, or a `session/event` listener firing on `step/start` itself) lands in the log after the boundary and joins the NEXT request. For waterfall-window appends this matches the prior loop (it also derived before its waterfall); for a synchronous `step/start` listener it is a deliberate change — such a listener could previously reach the current request — and `agent/pre-step` is the sanctioned seam for content that must affect the CURRENT request. A step's header for reconstruction is the fold after its own `request/header*` event (which sits between its `step/start` and first response event) or the fold carried forward.
|
||||
**The reconstruction boundary is `step/start`, unconditionally.** A step's messages are the derivation over `events[0..stepStartSeq)`. Because the snapshot precedes the `step/start` append in the same synchronous frame, an `agent.inject()` from an `agent/request` listener or any concurrent task lands after the boundary and joins the NEXT request. `session/event` is observe-only during publication: a reentrant append is rejected until the current callback list drains, preventing nested event delivery from overtaking the event being observed. `agent/pre-step` is the sanctioned seam for content that must affect the CURRENT request. A step's header for reconstruction is the fold after its own `request/header*` event (which sits between its `step/start` and first response event) or the fold carried forward.
|
||||
|
||||
**Enforcement.** Dev-mode ([dsh-invariants](../../../../packages/support/invariants/src/index.ts)), on `llm/stream`: a frozen request with a live `sessionId` — the loop-built marker; hand-built one-shots are unfrozen and skipped — must carry messages deep-equal to the folded header's `messagePrefix` followed by the boundary derivation — the derivation rebuilt through a FRESH `Session` over `events[0..stepStartSeq)` so the live cache cannot vouch for itself — and header fields equal to `foldRequestHeader` over the log. There is no divergence allowance and nothing to allow: no seam can put unlogged content into a request — the `agent/session-prefix` seam's product enters only because the header event records it first. `prepend: true` only defends against the replay adapter's short-circuit (an append-registered listener); two prepended listeners have no defined mutual order in cordis, so correctness rests on the seq-bounded fold, never on listener timing. Measurement stays lean: the with-key e2e ([request-cache.e2e.ts](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts)) proves `usage.cacheReadTokens > 0` on every request after the first against the live API, and per-step usage in the log is the production observable — a header event or compaction shows up as a cache-read collapse on the next step.
|
||||
|
||||
|
||||
@@ -4,167 +4,173 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
One application can run many agents that share infrastructure but need different capabilities and policy. A child may have its own persona, tool set, structured-output schema, and listeners while still using the deployment's model adapters, persistence, and UI.
|
||||
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.
|
||||
|
||||
Neither a global registry nor a separate service graph per agent fits that shape. Global registration leaks child-specific behavior; independent graphs duplicate shared services and make cross-agent infrastructure harder to compose.
|
||||
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 model-visible, executable, and observable views must agree. A hidden tool must not remain callable, an advertised tool must execute through the same scoped definition, and policy intended for one agent must not intercept another. The registrations must also disappear only after their agent reaches quiescence.
|
||||
|
||||
Some owner rules cannot depend on middleware order. Prompt assembly, tool policy, result transformation, and continuation are extensible waterfalls, so another listener can wrap, replace, or short-circuit ordinary listeners. Structured output and reserved transport need service-owned final boundaries.
|
||||
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
|
||||
|
||||
Each live agent owns a Cordis registration context, `agent.ctx`. Registering through a plain plugin context contributes to the deployment; registering through `agent.ctx` contributes only to that agent and is disposed with it.
|
||||
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.
|
||||
|
||||
The design has three parts:
|
||||
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.
|
||||
|
||||
| Part | Contract |
|
||||
For most contributors, the complete contract is four rules:
|
||||
|
||||
| Question | Rule |
|
||||
|---|---|
|
||||
| Registration scope | Resolve the global layer plus exactly one agent layer; the registration context determines both visibility and ownership. |
|
||||
| Lifecycle transaction | Compose the scope while the agent and session are unpublished, then publish through an ordered rollback-covered sequence. |
|
||||
| Owner-final policy | Services provide narrow final boundaries for canonical prompt entries, monotonic tool denial, authoritative results, and terminal turn stopping. |
|
||||
| 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 |
|
||||
|
||||
Scopes are flat. A child does not inherit its parent's scoped registrations; parentage is explicit session data, and an ownership link controls lifetime without granting authority.
|
||||
The scope is flat. Resolution never walks parent or sibling scopes, and lifetime ownership does not imply registration inheritance.
|
||||
|
||||
The public contracts live in [`dsh-scope`](../../../../packages/core/scope/README.md), [`dsh-agent`](../../../../packages/core/agent/README.md), [`dsh-system-prompt`](../../../../packages/core/system-prompt/README.md), and [`dsh-tools`](../../../../packages/core/tools/README.md). The generated [event catalog](../../../cordis-catalog/events.md) is the signature reference.
|
||||
```mermaid
|
||||
flowchart LR
|
||||
plain["Plain plugin context<br/>cleanup follows the plugin"] -->|"registers into"| globalLayer["Deployment-global layer"]
|
||||
agentAContext["agentA.ctx<br/>cleanup follows Agent A"] -->|"registers into"| agentALayer["Agent A layer"]
|
||||
agentBContext["agentB.ctx<br/>cleanup follows Agent B"] -->|"registers into"| agentBLayer["Agent B layer"]
|
||||
|
||||
## Registration scope
|
||||
operationA["Operation for Agent A"] -->|"selects"| agentAView["Agent A view<br/>globals plus A local"]
|
||||
globalLayer --> agentAView
|
||||
agentALayer --> agentAView
|
||||
operationB["Operation for Agent B"] -->|"selects"| agentBView["Agent B view<br/>globals plus B local"]
|
||||
globalLayer --> agentBView
|
||||
agentBLayer --> agentBView
|
||||
```
|
||||
|
||||
### Context selects visibility and ownership
|
||||
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.
|
||||
|
||||
A Cordis context is both a service view and the origin of effects such as tool registration, prompt contribution, and event subscription. `createScope(ctx, key)` mounts an ownership fiber and returns a derived context tagged with an opaque `ScopeKey`; derived contexts inherit the nearest tag.
|
||||
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 | Visible to | Disposed with |
|
||||
### Registration origin chooses visibility and cleanup
|
||||
|
||||
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 | Default visibility | Disposed with |
|
||||
|---|---|---|
|
||||
| Plain plugin context | Every agent | Registering plugin |
|
||||
| `agent.ctx` | That agent | Agent scope |
|
||||
| Plain plugin context | Every eligible agent view | Registering plugin |
|
||||
| `agent.ctx` | Exactly that agent's view | Agent scope |
|
||||
|
||||
This coupling prevents a registration from being visible to one agent but owned by an unrelated lifecycle. The live `Agent` object is its scope key, so operations that already carry the agent need no secondary string lookup.
|
||||
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.
|
||||
|
||||
`agent.ctx.agent` is a convenient association, not the generic scope tag. Lower-level services use `scopeOf(context)` because a nested scope may replace the nearest key while retaining inherited context properties.
|
||||
The ordinary contributor pattern is to register the complete local world during agent setup:
|
||||
|
||||
The scope exposes two disposal forms. `rawDispose` is the exact Cordis disposer required when nesting a scope at a precise generator-effect position; `dispose()` is the idempotent promise ordinary callers use to await the backing fiber's quiescence, including a race started through `rawDispose`.
|
||||
```js
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('reviewer'),
|
||||
sessionId: SessionId('reviewer-session'),
|
||||
agentOptions: { model: 'model-name' },
|
||||
setup(agentCtx) {
|
||||
agentCtx.systemPrompt.section({
|
||||
name: 'deployment:persona',
|
||||
order: 0,
|
||||
text: 'Review code, but do not modify files.',
|
||||
})
|
||||
agentCtx.tools.register({
|
||||
name: 'review_summary',
|
||||
description: 'Return the review summary.',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
async execute() {
|
||||
return [{ type: 'text', text: 'review complete' }]
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
### Registries retain domain-specific merge rules
|
||||
ctx.tools.get('review_summary') // undefined: not global
|
||||
ctx.tools.get('review_summary', handle.agent) // the reviewer-local tool
|
||||
|
||||
The scope primitive selects a layer but does not prescribe how a service combines it. Named tools, prompt sections, and variables use scoped-over-global shadowing; tool-schema providers are additive within the selected view. Duplicate names in one layer fail.
|
||||
await handle.dispose()
|
||||
ctx.tools.get('review_summary', handle.agent) // undefined: scope is gone
|
||||
```
|
||||
|
||||
Reads name their subject explicitly. Prompt assembly receives an `AssembleContext.scope`; tool lookup, visibility, execution, timeout policy, Code Mode bindings, inspection, and presentation receive an agent or scope. Merely calling a read method through `agent.ctx` does not silently choose a subject.
|
||||
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.
|
||||
|
||||
Tool restrictions mask global end capabilities for one agent, and multiple restrictions intersect. Tools registered in the agent's own layer are explicit grants. A hidden global tool behaves as unknown at execution.
|
||||
### The operation chooses the view
|
||||
|
||||
Code Mode's `run_code` is reserved transport rather than an end capability. It remains outside the filterable layers so a restriction cannot leave an SDK in the prompt without its only transport. The registry resolves restricted globals plus scoped grants, then adds the transport in non-native modes; every registry-owned view consumes that same result.
|
||||
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.
|
||||
|
||||
### Scoped events use the operation's subject
|
||||
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.
|
||||
|
||||
A scoped event reaches global listeners and listeners registered through the matching agent context. It never reaches another agent's listeners. Cordis's explicit `{ global: true }` option remains the intentional bypass.
|
||||
Only services that adopt the scope contract resolve an agent layer. `agent.ctx` does not automatically change arbitrary Cordis service calls.
|
||||
|
||||
The dispatch receiver carries the scope key and is exposed as `this` to function listeners. Each event family derives the key from its real subject rather than accepting an independent caller-supplied scope:
|
||||
### Scoped events keep routing separate from event data
|
||||
|
||||
| Event family | Scope source |
|
||||
|---|---|
|
||||
| `agent/*` | Event agent |
|
||||
| `approval/request` | `request.agent` |
|
||||
| Tool execution events | `execution.agent` |
|
||||
| `system-prompt/assemble` | `AssembleContext.scope` |
|
||||
| Session events and flushes | Owner captured when the session enters the store |
|
||||
| `subagent/start` and `subagent/end` | Delegating parent |
|
||||
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 one agent's operation.
|
||||
At the Cordis level, `Scoped<T>` is an opaque routing receiver. It carries the filter used to choose listeners but is not the domain object. Event signatures therefore keep the real `Agent`, tool execution, approval request, or other subject as an explicit argument that listeners can inspect.
|
||||
|
||||
The receiver is a proxy over the subject. Property access and writes reach the subject, and methods bind to the subject so classes with private fields work; the proxy is intentionally not identity-equal to it. Event arguments carry the real object where identity matters. `Scoped<T>` marks the required receiver at typed dispatch sites, while runtime marks and development invariants cover JavaScript and casts.
|
||||
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.
|
||||
|
||||
## Agent lifecycle transaction
|
||||
### Creation publishes last and disposal revokes last
|
||||
|
||||
### Setup finishes before publication
|
||||
`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.
|
||||
|
||||
Create and resume reserve both agent and session IDs before any await, snapshot caller-owned options and setup inputs, construct the agent, mint its scope, and install the teardown skeleton. Resume also races persistence loading against owner disposal so a late backend result cannot publish after its owner is gone.
|
||||
An optional creation signal cancels work only while create or resume is pending. After the promise resolves, the returned `AgentHandle` owns explicit disposal.
|
||||
|
||||
The optional `setup(agentCtx)` callback runs while neither the session nor the agent is globally visible. It may register scoped contributions or await child-plugin activation. A rejection, owner unload, or failed liveness check unwinds the complete unpublished world and releases both IDs.
|
||||
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.
|
||||
|
||||
Setup code can reach the unpublished agent through `agentCtx.agent`, but the driver cannot accept work until publication enables its private controls. This keeps the first turn behind the lifecycle boundary without publishing a partially configured agent.
|
||||
`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.
|
||||
|
||||
### Publication is ordered and rollback-covered
|
||||
The calling Cordis context and the concrete AgentLoop factory are structural co-owners. Unloading either disposes the transaction or live agent.
|
||||
|
||||
After setup, publication runs synchronously in this order: enter the session store, enter the agent registry, announce `session/created`, announce `agent/created`, enable driving, emit the contained `agent/session-start` notification, and start the loop.
|
||||
```mermaid
|
||||
flowchart TB
|
||||
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"]
|
||||
|
||||
Both registry entries exist before creation listeners run. The sequence is not atomic: observers run during it, and rollback cannot retract effects they already performed. A throwing creation listener causes the owned transaction to unwind; failures from the non-vetoing session-start notification are reported without preventing loop startup.
|
||||
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"]
|
||||
```
|
||||
|
||||
### Teardown preserves the scoped world until work settles
|
||||
### Subagent controls are an independent feature
|
||||
|
||||
Every owner path stops and awaits the driver and agent-started durability checkpoints, removes the agent, detaches the session, then unwinds the scope. Final session events and flushes therefore still see the session and scoped listeners. `AgentHandle.dispose()` and `Scope.dispose()` give racing callers shared quiescence boundaries.
|
||||
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.
|
||||
|
||||
In-process subagents add a run-owner fiber under `parent.ctx`. This makes the parent own the child lifecycle without merging the parent's scoped capabilities into the child's new flat scope.
|
||||
`inheritsParentContext` describes conversation-history seeding only. It says nothing about Cordis scope, injected services, tools, or authority.
|
||||
|
||||
## Owner-final policy
|
||||
## Security and authority are non-goals
|
||||
|
||||
Ordinary waterfalls remain the extension mechanism. The following service-owned boundaries are reserved for invariants whose result must not depend on listener order:
|
||||
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.
|
||||
|
||||
| Boundary | Guarantee |
|
||||
|---|---|
|
||||
| `systemPrompt.protect()` | After the assembly waterfall, restore the canonical presence, definition, and local anchor of named sections or schemas. Canonical absence is protected too. |
|
||||
| `tools.guard()` | After pre-execute policy, guards may deny or abstain but cannot allow, so denials compose monotonically. |
|
||||
| `tools/result` | After execution, post-processing, error normalization, and JSON validation, notify observers of one immutable authoritative outcome. Observer failures are contained independently. |
|
||||
| `agent/turn-stop` | After ordinary continuation and steering folding, a strict serial stop is terminal through turn close and flush; it discards steering but preserves queued prompts. |
|
||||
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.
|
||||
|
||||
Prompt protection is narrow rather than a whole-assembly reset. It removes protected names from the transformed result and reinserts canonical entries near their surviving canonical neighbors; unrelated contributions remain extensible. A globally protected section name cannot be shadowed by a scoped section. Code Mode protects its SDK section and `run_code`; structured output protects its capture instruction and schema.
|
||||
|
||||
Tool execution identity supports the final-result boundary. The registry snapshots lossless-JSON arguments into a distinct execution, assigns an opaque frozen token, and makes identity fields immutable before policy. Only `signal` remains replaceable by around-dispatch wrappers. Nested transports carry the parent's token, not its live execution object.
|
||||
|
||||
`tools/result` is a live registry notification and also fires for programmatic execution. The singular `tool/result` session event is the durable transcript record appended later by the loop. Consumers choose the live final verdict or persisted history according to their contract.
|
||||
|
||||
`agent/turn-stop` has stronger authority than ordinary continuation and is intended only for terminal protocols. `undefined` is its sole abstention value; malformed returns and listener failures end the current turn as errors. Once stopped, steering added during turn close or flush cannot create another step or fallback turn.
|
||||
|
||||
## Subagent composition
|
||||
|
||||
The in-process spawn and fork providers build a child through the unpublished setup transaction. Spawn uses an empty session; fork seeds only the parent's balanced completed-turn prefix, excluding the currently open tool-call turn.
|
||||
|
||||
Provider definitions and accepted requests are snapshotted before asynchronous creation. Identity capabilities such as the parent and abort signal are retained; mutable options, filters, seed events, schema, and prompt are detached. One run-owner fiber coordinates provider unload, parent teardown, manual disposal, and cancellation during creation.
|
||||
|
||||
`SubagentRun.started` separates acceptance from publication. It resolves only after the child is in the agent registry and rejects if rollback prevents publication. Lifecycle notifications and workflow bridges wait for this boundary, while attaching result handlers immediately so an early settlement is not unhandled.
|
||||
|
||||
Persona, tool restriction, and structured output are ordinary registrations installed through the child's context during setup. The child's scope owns them and prevents concurrent children with different schemas or policy from interacting.
|
||||
|
||||
### Structured output is a terminal protocol
|
||||
|
||||
A structured child receives a scoped `structured_output` tool with its actual schema and a protected prompt instruction. Native mode exposes the tool directly; Code Mode exposes it through the protected SDK and `run_code` transport; both mode offers both paths.
|
||||
|
||||
The capture tool validates and stages a cloned value by immutable `ToolExecution` identity. A scoped `tools/result` observer commits it only if that execution's authoritative result succeeds. For a Code Mode sub-call, the value remains pending until the enclosing `run_code` token also reaches a successful final result, so an inner success cannot survive outer runtime or policy failure.
|
||||
|
||||
Once a value is pending or committed, a scoped guard denies later calls. After commit, a scoped turn-stop ends the child turn after ordinary continuation has settled. A child that finishes without a committed capture returns an error; the provider does not re-prompt it.
|
||||
|
||||
## Correctness enforcement
|
||||
|
||||
Scope selection would otherwise fail open to global-only behavior, so the contract is checked at several boundaries:
|
||||
|
||||
| Boundary | Check |
|
||||
|---|---|
|
||||
| API | Helpers couple the payload subject to the dispatch carrier; stores capture subjects they must use later. |
|
||||
| Type system | Scoped event declarations require `Scoped<T>` receivers. |
|
||||
| Runtime | Development invariants require marked carriers and compare keys with exposed subjects. |
|
||||
| Repository gates | `verify-scoped-dispatch` aligns declarations with the invariant table; generated catalogs require recognized dispatchers. |
|
||||
|
||||
These checks make omissions visible but do not replace the runtime carrier.
|
||||
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
|
||||
|
||||
| Alternative | Why rejected |
|
||||
|---|---|
|
||||
| Add `{ agent }` to every registration | Separates visibility from effect ownership and repeats scope plumbing in every service. |
|
||||
| Build a service graph per agent | Duplicates shared infrastructure and cannot naturally merge global contributions with one agent layer. |
|
||||
| Inherit the parent's scope | Couples ownership to authority and silently grants parent-scoped capabilities. |
|
||||
| Publish before setup | Exposes partially configured agents; rollback cannot retract observer side effects. |
|
||||
| Require synchronous setup | Cannot compose asynchronous plugins and is not reliably enforced by TypeScript callback assignability. |
|
||||
| Prepend invariant listeners | Later prepends, short-circuits, and outer wrappers can still bypass or replace their results. |
|
||||
| Scope only event delivery | Leaves schemas, lookup, prompt state, Code Mode bindings, and lifetime global. |
|
||||
| Modify vendored Cordis | Existing contexts, fibers, and receiver filtering are sufficient; a framework fork adds unnecessary maintenance. |
|
||||
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 })` 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 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 service graph per agent
|
||||
|
||||
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 parent registration scopes
|
||||
|
||||
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
|
||||
|
||||
Plugin authors use the same registration APIs globally and per agent; only the context changes. Prompt schemas, executable lookup, Code Mode bindings, policy listeners, and UI presentation resolve from one agent view. Existing unscoped plugins remain deployment-wide contributors.
|
||||
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 implementation pays for per-scope maps, a proxy-shaped event carrier, explicit subject parameters on reads, and disciplined dispatch helpers. `agent.ctx` is capability-bearing and exposes the service surface injected into the agent loop. Flat scopes require child capabilities to be global or explicitly registered for the child.
|
||||
|
||||
Reserved transport and final-policy APIs are deliberately narrow. `run_code` cannot be removed by an end-capability filter; policy that forbids programs must deny execution. Prompt protection preserves named canonical contributions, not the whole assembly. Terminal turn stopping may discard steering and is too strong for ordinary cooperative policy.
|
||||
|
||||
This decision applies scoping to tools, prompt state, selected live events, sessions, and in-process subagent composition. It does not make every service call agent-scoped; other capabilities adopt the context rule only through their own explicit contracts.
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
# RFC: Agent-scope runtime design and correctness
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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 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.
|
||||
|
||||
The design can be skimmed as seven choices:
|
||||
|
||||
| Problem | Authoritative mechanism |
|
||||
|---|---|
|
||||
| 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 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.
|
||||
|
||||
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.
|
||||
|
||||
## Cordis model: context, fiber, effect, receiver, and waterfall
|
||||
|
||||
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.
|
||||
|
||||
### A context is an ownership path through one service graph
|
||||
|
||||
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.
|
||||
|
||||
`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.
|
||||
|
||||
### Fibers and effects make cleanup structural
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Receivers route listeners; waterfalls compose decisions
|
||||
|
||||
Cordis filters listeners using the dispatch receiver (`this`), while harness listeners need an explicit agent, execution, request, or other subject. `Scoped<T>` marks the receiver expected by a scoped event declaration, but the runtime carrier deliberately exposes no subject API.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Scope routing: one opaque key selects one layer
|
||||
|
||||
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.
|
||||
|
||||
### Scope identity uses object identity
|
||||
|
||||
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.
|
||||
|
||||
`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.
|
||||
|
||||
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`.
|
||||
|
||||
### Registry reads overlay one exact map
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Fused dispatch helpers prevent subject drift
|
||||
|
||||
`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.
|
||||
|
||||
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.
|
||||
|
||||
## Agent creation: one transaction owns the complete operation
|
||||
|
||||
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.
|
||||
|
||||
### Registry entries are the only live identity records
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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 transaction owns preparation before awaiting it
|
||||
|
||||
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.
|
||||
|
||||
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 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.
|
||||
|
||||
### Setup is trusted composition inside a private world
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Publication has one ordered commit path
|
||||
|
||||
Publication admits and announces resources in the order required by observers:
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
### Teardown preserves work before revoking registrations
|
||||
|
||||
Every teardown request joins one memoized path. The order is:
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Session append: materialize, validate, commit, notify
|
||||
|
||||
Session events cross a durable boundary, so append owns their data. The rest of the algorithm uses one attached entry and one commit point.
|
||||
|
||||
### Durable data is materialized once
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Pre-commit listeners can veto; post-commit observers cannot
|
||||
|
||||
Append follows one sequence:
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
`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.
|
||||
|
||||
## Trust boundaries: copy only when ownership actually changes
|
||||
|
||||
The runtime distinguishes typed in-process contracts from serialization and durability boundaries. This is the main simplification rule for values and callbacks.
|
||||
|
||||
| Boundary | Ownership rule |
|
||||
|---|---|
|
||||
| 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 |
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Tools and prompts: one view, one execution identity, explicit finality
|
||||
|
||||
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.
|
||||
|
||||
### One resolver defines the tool view
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
`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.
|
||||
|
||||
### Tool execution owns identity and boundary materialization
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Contribution-owned finality protects only named invariants
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Structured output commits only authoritative outcomes
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Four final boundaries have four narrow powers
|
||||
|
||||
Owner-final behavior is not a general priority system. Four domain owners need four different one-way powers after cooperative extension points:
|
||||
|
||||
| Boundary | Final power | Why ordinary listener order is insufficient |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
|
||||
`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.
|
||||
|
||||
### Skill and approval services trust typed callers
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Subagents: readiness is the start promise
|
||||
|
||||
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.
|
||||
|
||||
### The service contract has one cancellation channel
|
||||
|
||||
`SubagentProvider.start()` and `SubagentService.start()` return `Promise<SubagentRun>`. 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.
|
||||
|
||||
`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.
|
||||
|
||||
Optional `sendMessage()` supports a live backend that can accept steering. Optional `resume()` returns `Promise<SubagentRun>` because the resumed child has the same asynchronous readiness boundary.
|
||||
|
||||
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.
|
||||
|
||||
### In-process providers reuse the core transaction
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### 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 design is enforced at types, runtime escape points, generated contracts, and behavioral tests. No one layer is asked to prove what it cannot observe.
|
||||
|
||||
### Types make the ordinary path hard to misuse
|
||||
|
||||
Readonly contracts describe borrowed same-process values. `Scoped<T>` marks event receivers, `agentEvents()` fuses carrier and subject, tool inputs omit registry-owned tokens, and subagent async return types expose readiness directly.
|
||||
|
||||
TypeScript cannot govern JavaScript casts, direct Cordis dispatch, process messages, or durable files, so runtime enforcement remains at those escape points.
|
||||
|
||||
### Runtime invariants cover cross-service facts
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Generated artifacts keep public contracts aligned
|
||||
|
||||
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.
|
||||
|
||||
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 [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.
|
||||
|
||||
### Use a transparent proxy as the scope carrier
|
||||
|
||||
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.
|
||||
|
||||
### Reserve agent and session IDs before 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.
|
||||
|
||||
### Snapshot every typed same-process argument
|
||||
|
||||
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.
|
||||
|
||||
### Give readiness, cancellation, and disposal separate controllers
|
||||
|
||||
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.
|
||||
|
||||
### Keep synchronous subagent start plus `run.started`
|
||||
|
||||
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.
|
||||
|
||||
### Keep a separate prompt-protection registry
|
||||
|
||||
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.
|
||||
|
||||
### Remove worker/process lifecycle guards with same-process hardening
|
||||
|
||||
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 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.
|
||||
|
||||
### What the design guarantees
|
||||
|
||||
- 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.
|
||||
|
||||
### Costs and limits
|
||||
|
||||
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 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.
|
||||
|
||||
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.
|
||||
@@ -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<string>; bash(args: …): Promise<string>; … }` — quoted keys make every name reachable with no sanitization or alias-collision logic. Typing is advisory (the runtime executes type-stripped JS); the instructions say so.
|
||||
|
||||
|
||||
@@ -35,9 +35,9 @@ A new package group `packages/subagent/`:
|
||||
| `@deepseek-ai/dsh-subagent-mock` | support: a scripted provider for testing the seam through the real load path |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | consumer: the model-facing `subagent` tool over `ctx.subagents` |
|
||||
|
||||
### The primitive: `start → SubagentRun`
|
||||
### The primitive: async `start → SubagentRun`
|
||||
|
||||
A provider exposes `start(request) → SubagentRun`. The run carries `started` (the provider's publication/readiness promise), `result` (the terminal `SubagentResult`), `cancel()`, and `dispose()`. The transport-neutral verb is **`start`**; "spawn" is reserved for the in-process `dsh-subagent-spawn` backend's identity, not the service verb. The service's `start(name, request)` resolves the named provider, validates capabilities, delegates, and waits for `started` before emitting the paired `subagent/start` / `subagent/end`; an attempt that never establishes a child emits neither lifecycle event. For an in-process backend, readiness means the child is published in `ctx.agents`; for ACP it means the remote session exists.
|
||||
A provider exposes `start(request) → Promise<SubagentRun>`. Promise fulfillment is the publication/readiness and provider-to-caller ownership boundary: for an in-process backend the child is already published in `ctx.agents`, and for ACP the remote session already exists. `SubagentStartRequest.signal` is the single cancellation channel before and after readiness; `SubagentRun` carries the terminal `result` and a `dispose()` method that cancels remaining work and awaits quiescence. The transport-neutral verb is **`start`**; "spawn" is reserved for the in-process `dsh-subagent-spawn` backend's identity, not the service verb. A rejected start cleans provider-owned partial resources and emits neither subagent lifecycle event.
|
||||
|
||||
### Two kinds of optional capability, discovered two ways
|
||||
|
||||
@@ -46,7 +46,7 @@ A provider exposes `start(request) → SubagentRun`. The run carries `started` (
|
||||
|
||||
### Fork vs. fresh are separate backends, not a flag
|
||||
|
||||
Rather than a `context: 'fresh' | 'fork'` request field, the distinction is the provider's identity: `dsh-subagent-spawn` (fresh, isolated, own system prompt) and `dsh-subagent-fork` (seeded from the parent's log) are two registered providers. You pick behavior by picking a provider — consistent with the registry being the selection mechanism. The fork backend seeds only a **balanced, completed-turn prefix** of the parent log: at tool-execute time the parent's turn is open (it holds the `assistant/message` and the dangling spawn `tool/call` with no `tool/result`), and seeding that raw prefix would give the child an unbalanced turn the [invariants](../../../../packages/support/invariants/src/index.ts) freeze-check rejects.
|
||||
Rather than a `context: 'fresh' | 'fork'` request field, the distinction is the provider's identity: `dsh-subagent-spawn` (fresh, isolated, own system prompt) and `dsh-subagent-fork` (seeded from the parent's log) are two registered providers. You pick behavior by picking a provider — consistent with the registry being the selection mechanism. The fork backend seeds only a **balanced, completed-turn prefix** of the parent log: at tool-execute time the parent's turn is open (it holds the `assistant/message` and the dangling spawn `tool/call` with no `tool/result`), and seeding that raw prefix would give the child an unbalanced turn that the [invariants](../../../../packages/support/invariants/src/index.ts) trace replay rejects.
|
||||
|
||||
### Child isolation and the parent log
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -20,13 +20,13 @@ The canonical surface separates transformable policy, around-dispatch control, a
|
||||
|
||||
### The tool pipeline gives each phase one kind of authority
|
||||
|
||||
Every call follows one ordered pipeline: `tools/pre-execute` → monotonic guards → `tools/execute` → core dispatch → `tools/post-execute` → `tools/result`. The registry requires caller-owned `arguments` to survive lossless-JSON validation before and after cloning, then snapshots `ToolExecutionInput` into a pipeline execution with its own opaque token: identity fields and deeply frozen detached arguments are immutable for the whole pipeline, and a nested call's `parent` contains only the enclosing execution's token rather than its live object. Optional `signal` is the only operational field an around-dispatch wrapper may add, replace, or remove, and the complete object freezes before final observers run. This identity contract prevents a policy listener from silently changing what the log, UI, and tool body believe ran.
|
||||
Every call follows one ordered pipeline: `tools/pre-execute` → monotonic guards → `tools/execute` → core dispatch → `tools/post-execute` → `tools/result`. The registry reads each caller-owned input field once, materializes `arguments` as detached lossless JSON in one recursive pass, and snapshots `ToolExecutionInput` into a pipeline execution with its own opaque token. Identity fields and deeply frozen arguments are immutable for the whole pipeline, and a nested call's `parent` contains only the enclosing execution's token rather than its live object. Optional `signal` is the only operational field an around-dispatch wrapper may add, replace, or remove, and the complete object freezes before final observers run. This identity contract prevents a policy listener from silently changing what the log, UI, and tool body believe ran.
|
||||
|
||||
- **`tools/pre-execute`** is the extensible waterfall gate. Its `PreToolDecision` allows, denies, or asks. Deny skips `tools/execute` and core dispatch. Ask resolves through the optional approval seam: only `allowed-once` continues through guards and dispatch; rejection, cancellation, an unavailable channel, a missing approval service, or an agent-less call becomes a normalized denial. Every outcome still reaches post-policy and final observers.
|
||||
- **`ctx.tools.guard()`** installs synchronous scope-aware policy after the whole pre-execute waterfall. A guard may deny or abstain, never force-allow, so listener ordering cannot resurrect an operation that a final invariant forbids.
|
||||
- **`tools/execute`** is the around-dispatch waterfall for timeout, retry, and metrics plugins. A wrapper delegates to core dispatch with `next()`, may add, replace, or remove only `exec.signal` before doing so, and receives the already-normalized result of a thrown or unknown tool; returning its own valid result short-circuits dispatch.
|
||||
- **`tools/post-execute`** is the inspect/transform waterfall. Its `PostToolDecision` accepts, blocks with feedback, optionally replaces content, or attaches `additionalContext`; in-place mutation of the result is not a transform channel, because the registry rebuilds the outcome from a protected snapshot plus the returned decision.
|
||||
- **`tools/result`** is the awaited parallel notification after every transform, lossless-JSON validation, and the outer error boundary. It receives the same frozen execution identity and an immutable snapshot of the authoritative result; observer failures are contained per listener and cannot change or reject `ToolRegistry.execute()`'s returned outcome.
|
||||
- **`tools/result`** is the awaited parallel notification after every transform, lossless-JSON materialization, and the outer error boundary. It receives the same frozen execution identity and an immutable snapshot of the authoritative result; observer failures are contained per listener and cannot change or reject `ToolRegistry.execute()`'s returned outcome.
|
||||
|
||||
Core dispatch and the tool body sit inside normalization boundaries, so tool, listener, malformed-result, non-JSON result, and identity-shape failures resolve as JSON-safe `isError` results rather than escaping the turn. A post-execute listener can therefore inspect a thrown tool, and a final observer sees exactly what the caller receives and the session log can persist.
|
||||
|
||||
@@ -42,7 +42,7 @@ Core dispatch and the tool body sit inside normalization boundaries, so tool, li
|
||||
|
||||
### Pre-tool input rewrite is a separate consistency decision
|
||||
|
||||
`PreToolDecision` is allow/deny/ask only — **no `arguments` rewrite**. Output replacement is safe because `tool/result` is logged after execution from the final result. Input rewrite is different: `assistant/message` (model history) and `tool/call` (the audit record) are logged before `ToolRegistry.execute()`, while ACP and tool presentation read those arguments. The registry therefore seals the cloned arguments before `tools/pre-execute`; no listener or test shim can mutate them in place. An honest rewrite must update history, audit, presentation, and execution as one unit before that identity is created, which belongs to the separate [pre-tool input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) and its loop-side `TODO(pre-tool-input-rewrite)`.
|
||||
`PreToolDecision` is allow/deny/ask only — **no `arguments` rewrite**. Output replacement is safe because `tool/result` is logged after execution from the final result. Input rewrite is different: `assistant/message` (model history) and `tool/call` (the audit record) are logged before `ToolRegistry.execute()`, while ACP and tool presentation read those arguments. The registry therefore seals the materialized arguments before `tools/pre-execute`; no listener or test shim can mutate them in place. An honest rewrite must update history, audit, presentation, and execution as one unit before that identity is created, which belongs to the separate [pre-tool input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) and its loop-side `TODO(pre-tool-input-rewrite)`.
|
||||
|
||||
### Boundaries
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -24,11 +24,13 @@ One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferre
|
||||
|
||||
**Trust premise (governs every engine decision below)**: workflow scripts are MODEL-WRITTEN — the same trust level as the model's existing bash access — so the engine defends against BUGGY scripts, never hostile ones. In scope: `result` never rejects, no unhandled rejections from dropped hook promises, loud rejection of values JSON cannot carry, fatal-vs-null hook discipline, cancellation that always frees the caller. Out of scope, deliberately: adversarial values (throwing/spinning accessors, proxies with hostile traps, prototype forgery, `prepareStackTrace` hijack) AND Node-API escape from the script's context — the vm context shares object machinery with its surrounding realm, so a script can reach the `Function` constructor (`globalThis.constructor.constructor`) and from it `process` and every Node builtin; the absent globals are API surface, not containment, and a worker thread is NOT a security boundary (an escapee holds process-wide privileges — Node's permission model is per-process). Worker-side code MAY run script code while reading script values, and that is accepted: a synchronous spin costs the script its OWN thread (terminated at the post-cancel grace), never the host loop, so containing error VALUES would be cost without a threat model. Genuine sandboxing (isolated-vm, a separate process) remains an engine swap behind the seam, not incremental defenses here.
|
||||
|
||||
Each workflow run gets one worker thread. A vm inside the worker limits script-visible globals while message-port RPC keeps child agents on the host loop. Host-side parsing preserves synchronous start errors; a ready/go handshake prevents pre-start cancellation from running code; host cancellation and child tracking handle wedged workers; the grace period ends with `worker.terminate()`. The private wire protocol uses typed payload maps. Tests exercise the worker session through `MessageChannel` and the built worker under plain Node. `isolated-vm` was rejected because its runtime and build requirements would burden every consumer.
|
||||
**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. 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.
|
||||
|
||||
`materializeFromRealm` copies JSON-compatible values out of the script realm and rejects exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, and nested `undefined`; data properties are defined safely so `__proto__` cannot mutate prototypes. Inputs are cloned before script access. Engine-generated `WorkflowError`s remain distinguishable by name and code, while a total renderer converts arbitrary thrown script values into a non-rejecting result. Stage functions stay inside the realm. Concurrency, item, total-agent, and timeout limits are validated configuration.
|
||||
**Value boundary**: values leaving the script (meta, hook options, schemas, the return value) go through `materializeFromRealm` — a plain recursive walk that rejects loud everything JSON cannot carry (exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`), copying via `Object.defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation; getters are read ordinarily and their RESULT crosses (a throwing read fails loud) — which is also what makes every later postMessage hop total. Values entering the realm (`args`, `agent()` results, hook promises and failures, combinator arrays) are handed over directly as worker-realm values — the script is trusted, so outer prototypes are not a leak; `args` rides the `workerData` structured clone (the caller-isolation copy) and is cloned once more so a script scribbling on it cannot mutate the session's init object. Hook failures are `WorkflowError`s built OUTSIDE the script's context: the combinators recognize fatality by `instanceof` against the engine's own class (unforgeable from the script), and the script-visible consequence — in-script `instanceof Error` is `false` for hook errors; branch on `e.name`/`e.code` — is documented in the engine README. Realm functions (stages, thunks) are called, never materialized. Thrown script values are rendered by a total renderer (stack → message → `String()`, fixed label if rendering throws), so `result` cannot reject. Caps (`maxConcurrentAgents` auto = `min(16, max(1, availableParallelism() - 2))`, `maxTotalAgents` 1000, `maxItemsPerCall` 4096) and timeouts are validated Config, not literals.
|
||||
|
||||
### The consumer (dsh-tool-workflow)
|
||||
|
||||
@@ -38,10 +40,9 @@ A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, awai
|
||||
|
||||
`SubagentStartRequest.outputSchema` is implemented by `dsh-subagent-inprocess` for both in-process backends. Each structured child receives its own scoped capture tool, instruction, and enforcement registrations on `child.ctx`; concurrent children can use different schemas without sharing mutable policy, and disposing the child removes the entire attachment.
|
||||
|
||||
- **Assembly is owner-protected.** The child registers `structured_output` with the run's real schema plus an order-190 instruction, then `systemPrompt.protect()` restores their canonical presence and definition after the complete assembly waterfall. Restored entries anchor before the first surviving later unprotected canonical neighbor, or at the end, without undoing listener ordering of unprotected entries. In native and both modes the capture tool remains a native wire tool. In pure Code Mode its canonical native presence is absent, so protection removes injected copies while the scoped tool remains in the generated SDK; the Code Mode owner independently protects `tools:sdk` and the reserved `run_code` transport. The loop logs the final assembly as `request/header`, keeping the demand reconstructable.
|
||||
- **Capture uses a two-level commit.** The capture body validates and stages a cloned value in a `WeakMap` keyed by that immutable execution object; only the observe-only `tools/result` notification commits it when the authoritative JSON-safe result after pre-policy, guards, around dispatch, post-policy, and outer error normalization succeeds. A capture called from a `run_code` program carries only the enclosing execution's opaque token as `parent`: inner success becomes pending, and commits when that token matches the enclosing transport's own successful `tools/result`. An outer runtime failure or post-policy block therefore cannot report structured success, and the observer never receives a live outer execution reference.
|
||||
- **Finality is monotonic within and after the step.** A scoped `ctx.tools.guard()` denies calls after capture has become pending or committed, and it runs after the entire extensible `tools/pre-execute` waterfall so listener order cannot force-allow a later side effect. After the step, scoped serial `agent/turn-stop` runs after ordinary continuation and steering folding; a captured child stops with no extra model step, and neither a continuation wrapper nor late steering can resurrect it.
|
||||
- **Schema and failure behavior stay explicit.** `start()` clones the schema so caller mutation cannot drift enforcement. `ToolArgsError` keeps validation retry inside the same turn. A child that finishes cleanly without a committed capture settles `error` to the parent; there is no re-prompt loop. `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.
|
||||
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-commits-only-authoritative-outcomes) owns the assembly, commit, guard, and terminal-stop correctness algorithms.
|
||||
|
||||
## Deferred (documented non-goals of this cut)
|
||||
|
||||
|
||||
@@ -49,11 +49,11 @@ The `escalation-rejected` twin ends in `{"outcome": "rejected"}` instead: nothin
|
||||
|
||||
#### The seam: mechanism and policy split
|
||||
|
||||
`ApprovalService.request` snapshots and shallow-freezes the request, then resolves to the closed `ApprovalOutcome` vocabulary without rejecting. It races the captured signal, maps abort to `cancelled`, contains throwing or invalid answerers as `unavailable`, and writes the paired `approval/asked` and `approval/decided` events using a branded request id. Observer failures are contained after the event is logged, so the pair still completes. Grants are one-shot and stored nowhere. Requests require an open turn because audit events must remain inside the durable turn boundary.
|
||||
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.
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# RFC: Configure subagent persona, tool visibility, and depth
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
A reusable subagent provider answers how to run a child, but different delegation tools need different child behavior. One deployment may want a reviewer persona, a research-only tool set, or a hard recursion bound without creating a new provider for every combination.
|
||||
|
||||
These controls affect the child's first model request and therefore cannot be installed after the child is visible. They also need honest provider support: an ACP backend cannot silently accept an in-process-only tool filter, and a filter must not be described as a security boundary when every plugin runs in the same trusted process.
|
||||
|
||||
## Decision
|
||||
|
||||
Subagent starts have three independent composition controls: `persona`, `toolFilter`, and `maxDepth`. A provider advertises support for each control, the service rejects unsupported requests before starting a run, and an in-process provider installs the requested composition while the child is still unpublished.
|
||||
|
||||
The controls answer different questions:
|
||||
|
||||
| Control | Question | Result |
|
||||
|---|---|---|
|
||||
| `persona` | What role instructions replace the deployment persona for this child? | A child-local prompt section shadows `deployment:persona` |
|
||||
| `toolFilter` | Which deployment-global tools enter this child's visible tool view? | A scoped restriction filters globals before child-local tools are added |
|
||||
| `maxDepth` | How deep may this delegation tree grow? | A start whose child depth exceeds the absolute cap is rejected |
|
||||
|
||||
`dsh-tool-subagent` exposes the controls as plugin configuration and copies them into each request it creates. Direct `SubagentService` callers may choose them per request. The provider capability descriptor remains the source of truth for whether a backend can honor each field.
|
||||
|
||||
### Persona is a scoped shadow
|
||||
|
||||
The persona control changes one child without changing deployment-wide prompt assembly. During unpublished setup, an in-process provider registers a child-scoped section named `deployment:persona`; ordinary most-specific-wins resolution replaces the global section only in that child's assemblies.
|
||||
|
||||
The value has the same strict template semantics as the deployment persona. Omitting it inherits the deployment section through the global layer; an explicit empty string shadows the global persona with an empty section. Parent and sibling personas never enter the child's flat scope.
|
||||
|
||||
This uses the normal system-prompt registration mechanism rather than a second persona channel. The first prompt therefore sees the same named contribution that later prompts and prompt-inspection tools see.
|
||||
|
||||
### Tool filtering is one live global-view rule
|
||||
|
||||
The tool filter controls visibility and executable lookup together. An in-process provider installs `ToolRegistry.restrict()` in the child's scope before publication, and the registry's single resolver applies the same result to prompt schemas, lookup, execution, and Code Mode SDK generation.
|
||||
|
||||
Resolution follows these rules:
|
||||
|
||||
1. Each restriction applies `allow` before `deny` to the live deployment-global tool registry.
|
||||
2. Multiple restrictions intersect, so every installed restriction must admit a global tool.
|
||||
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 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.
|
||||
|
||||
### Depth is an absolute tree cap
|
||||
|
||||
The depth limit bounds recursive delegation independently of tool visibility. A top-level agent has depth zero; an in-process child has its parent's validated depth plus one. `maxDepth` is an absolute non-negative safe integer, and a start rejects before child ownership begins when the derived child depth is greater than the cap.
|
||||
|
||||
Every public entry validates the domain rather than relying on one model-facing configuration path. Negative values, fractions, negative zero, non-finite values, unsafe integers, malformed stored parent depth, and derived overflow all reject. Omitting the cap leaves depth unbounded by this mechanism.
|
||||
|
||||
A deployment can combine depth and filtering. For example, it may keep the delegation tool visible at depth one but set `maxDepth: 1`, or deny the delegation tool entirely in children. Neither choice changes the provider's conversation-history behavior.
|
||||
|
||||
### Capability gating keeps providers honest
|
||||
|
||||
Capabilities separate a requested feature from a provider implementation. `SubagentCapabilities` advertises `persona`, `toolFilter`, and `depthLimit`; `SubagentService.start()` checks every present request field against those flags before calling the provider.
|
||||
|
||||
This lets spawn and fork providers share the in-process implementation while external providers advertise only what they can enforce. A request never degrades silently: selecting an unsupported control produces `UNSUPPORTED_CAPABILITY`, and no run or lifecycle event exists.
|
||||
|
||||
### Unpublished setup makes the first request correct
|
||||
|
||||
All child-local composition is complete before the child becomes observable. The in-process provider supplies one setup callback to agent creation; that callback installs persona, tool restriction, and structured-output contributions in the child's scope. Only after setup succeeds does creation publish the session and agent and allow the driver to start.
|
||||
|
||||
A setup failure rolls back the private child. No observer can acquire a child whose first prompt used the deployment persona or unfiltered tool set and whose later prompts use the requested configuration.
|
||||
|
||||
## Visibility is not authority
|
||||
|
||||
These controls compose trusted same-process behavior; they do not authorize it. `toolFilter` changes the child view resolved by the tool registry, but it does not create a parent-to-child grant lattice, require a child to be a subset of its parent, sandbox plugins, or prevent code with another Cordis context from calling services directly.
|
||||
|
||||
In particular, a child-local tool is added after the global filter and may be absent from the parent's view. A deny-only child also sees later global tools not named by the deny-list. Those are deliberate live-composition semantics, not non-escalation guarantees.
|
||||
|
||||
A security design would need a separate authority representation, propagation rule, and execution-time enforcement point. Creation-time grant snapshots, parent-subset grants, explicit future-grant APIs, and generic capability/output/termination tags are outside this feature.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Create one provider per persona or tool set.** This multiplies providers that share the same transport and lifecycle implementation, makes dynamic deployment configuration awkward, and still needs a recursion mechanism. Providers remain about execution transport; requests carry per-child composition.
|
||||
|
||||
**Copy the parent's complete tool view.** Registration scope is flat by design, and lifetime ownership does not imply visibility inheritance. Copying a resolved view would also freeze dynamic global registrations and conflate composition with authority without defining either contract fully.
|
||||
|
||||
**Snapshot allowed global tools at child creation.** A frozen allow-set makes future registration uniformly unavailable, but it changes hot-registration semantics and starts an authorization design. The implemented filter stays a live registry predicate and documents allow-versus-deny behavior directly.
|
||||
|
||||
**Hide only tool schemas.** Presentation-only filtering lets the model execute a tool that the prompt says does not exist through Code Mode or a forged call. One resolver governs both presentation and execution instead.
|
||||
|
||||
**Use only tool filtering to stop recursion.** Removing the delegation tool is useful but provider-specific and does not protect direct service callers or alternate delegation tools. Absolute depth is an independent structural bound.
|
||||
|
||||
## Consequences
|
||||
|
||||
Contributors can configure child role, visible global tools, and recursion without defining new providers. Capability checks fail before ownership starts, unpublished setup makes the first request consistent, and one tool resolver prevents presentation/execution drift.
|
||||
|
||||
The cost is that deployments must understand live allow/deny behavior and the distinction between visibility and authority. Provider authors must advertise each supported control accurately, and in-process providers must install every requested contribution before publication. The controls deliberately do not solve security confinement or parent-to-child non-escalation.
|
||||
@@ -8,7 +8,7 @@ The Node 22 branch of the root `engines.node` range is a contract for the instal
|
||||
|
||||
## Decision
|
||||
|
||||
Set `engines.node` to `^22.19.0 || >=24.0.0` and test the keyless CI compatibility matrix on `['22.19', 24, 26]`. The real-API e2e workflow stays on Node 24 because it exercises API integration rather than the runtime floor.
|
||||
Set `engines.node` to `^22.19.0 || >=24.0.0` and test the keyless CI compatibility matrix on `['22.19', 24, 26]`. Every matrix leg runs the TypeScript typecheck plus a keyless source-mode worker smoke, so the floor is exercised through both a complete source typecheck and a real unbuilt runtime path. The real-API e2e workflow stays on Node 24 because it exercises API integration rather than the runtime floor.
|
||||
|
||||
Two Node features gate the source runtime:
|
||||
|
||||
@@ -22,7 +22,7 @@ Those source features clear on the 22.x line at **22.18**, but the installed Pi
|
||||
## Consequences
|
||||
|
||||
- The advertised LTS branch no longer undercuts the Pi adapter dependency floor.
|
||||
- CI proves the Node 22 LTS floor directly with Node 22.19, keeps the Node 24 branch on `node: 24`, and keeps Node 26 for the next even line.
|
||||
- CI proves the Node 22 LTS floor directly with Node 22.19, keeps the Node 24 branch on `node: 24`, and keeps Node 26 for the next even line; each leg typechecks the source graph and launches the unbuilt workflow worker for real.
|
||||
- The built-bin smoke needs no version-conditional flag: at 22.19 type-stripping is already the default, so the test stays the plain `node lib/bin.js` path it documents.
|
||||
- A future dependency or source API that raises the runtime floor must move `engines.node`, the compatibility matrix, and this RFC in the same change.
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ The hard part is the artifact boundary. `publint`, `verify-node-next-types`, and
|
||||
|
||||
## Decision
|
||||
|
||||
[CI](../../../../.github/workflows/ci.yml) keeps the keyless workflow to a few broad jobs instead of one job per gate. The Node 24 matrix has five lanes: static gates (`pnpm run check:ci:static`), lint (`pnpm run check:ci:lint`), coverage (`pnpm run check:ci:coverage`), snapshot replay (`pnpm run check:ci:snapshot`), and artifact gates (`pnpm run check:ci:artifacts`). The Node 26 compatibility job installs once and runs `pnpm run check:node-compat`.
|
||||
[CI](../../../../.github/workflows/ci.yml) keeps the keyless workflow to a few broad jobs instead of one job per gate. The Node 24 matrix has five lanes: static gates (`pnpm run check:ci:static`), lint (`pnpm run check:ci:lint`), coverage (`pnpm run check:ci:coverage`), snapshot replay (`pnpm run check:ci:snapshot`), and artifact gates (`pnpm run check:ci:artifacts`). The compatibility matrix has Node 22.19, 24, and 26 jobs; each installs once and runs `pnpm run check:node-compat`.
|
||||
|
||||
Each lane delegates to [scripts/run-gates.ts](../../../../scripts/run-gates.ts), an in-process scheduler with bounded concurrency (`DSH_GATE_CONCURRENCY`). The static lane fans out constraints, the echo-agent demo smoke, `doc-sync` leaf gates, module-graph freshness, and `knip`; the lint lane runs ESLint with its own Node heap cap and a content-strategy ESLint cache; the coverage lane runs Vitest coverage with bounded file workers (`DSH_COVERAGE_MAX_WORKERS`); the snapshot lane isolates replay; the artifact lane builds once and then fans out the artifact consumers; the Node 26 compatibility job owns the TypeScript typecheck. The scheduler buffers each gate's output and prints a named result block with duration, so independent failures stay attributable inside each broad job log.
|
||||
Each lane delegates to [scripts/run-gates.ts](../../../../scripts/run-gates.ts), an in-process scheduler with bounded concurrency (`DSH_GATE_CONCURRENCY`). The static lane fans out constraints, the echo-agent demo smoke, `doc-sync` leaf gates, module-graph freshness, and `knip`; the lint lane runs ESLint with its own Node heap cap and a content-strategy ESLint cache; the coverage lane runs Vitest coverage with bounded file workers (`DSH_COVERAGE_MAX_WORKERS`); the snapshot lane isolates replay; the artifact lane builds once and then fans out the artifact consumers. Every compatibility job runs the TypeScript typecheck and a keyless workflow-workerthread source-launch smoke, which starts a real unbuilt worker and therefore catches Node-version-specific loader/runtime failures that typechecking cannot. The scheduler buffers each gate's output and prints a named result block with duration, so independent failures stay attributable inside each broad job log.
|
||||
|
||||
Generated `.sessions/` logs and `.doc-typecheck-*` temp directories are ignored by lint. The aggregate local CI mode still runs demo smoke after lint, while the split GitHub static lane can run demo smoke directly because lint is isolated in its own lane.
|
||||
|
||||
@@ -36,4 +36,4 @@ The broad-lane split repeats checkout, setup, and install more often than a sing
|
||||
|
||||
The split introduces a maintenance obligation: when `package.json` adds or removes a gate that belongs in CI, [scripts/run-gates.ts](../../../../scripts/run-gates.ts) needs the matching leaf. That obligation is intentional because the runner is the parallel execution plan for the same gate vocabulary, not a separate quality policy.
|
||||
|
||||
The Node 26 signal is narrower than the primary Node 24 signal. It proves the source graph on the newer runtime without doubling documentation, coverage, publication, snapshot, and smoke checks whose failures are not expected to vary by Node minor version.
|
||||
The compatibility signal is narrower than the primary Node 24 signal. It proves that the source graph typechecks and that the real unbuilt workflow-worker launch path executes on every advertised runtime line without doubling documentation, coverage, publication, snapshot replay, and unrelated smoke checks whose failures are not expected to vary by Node version.
|
||||
|
||||
@@ -13,7 +13,7 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
The loop records the canonical transcript in `SessionEvent` and also emitted a parallel set of live `agent/*` boundary mirror events: `agent/turn-start`, `agent/turn-end`, `agent/step-start`, and `agent/step-end`. The mirrors made consumers choose between two sources of truth for the SAME durable fact. ACP already chose the session log for the editor-facing transcript because a throwing peer listener can prevent later `agent/*` listeners from observing a boundary, while the session event was already appended. The stdio UI was the only production consumer that still rendered turn boundaries from the mirror events; it already rendered tool calls and results from `session/event`.
|
||||
The loop records the canonical transcript in `SessionEvent` and also emitted a parallel set of live `agent/*` boundary mirror events: `agent/turn-start`, `agent/turn-end`, `agent/step-start`, and `agent/step-end`. The mirrors made consumers choose between two sources of truth for the SAME durable fact. ACP already chose the session log for the editor-facing transcript because it is the one durable, replayable record; consuming a live mirror would require reconciling its timing with the boundary already stored in that log. The stdio UI was the only production consumer that still rendered turn boundaries from the mirror events; it already rendered tool calls and results from `session/event`.
|
||||
|
||||
This duplication is not free. Every lifecycle change had to update the session event, the mirror event, docs, invariants, tests, and snapshot expectations. The duplicate boundary events also made failure ordering subtle: a turn can be durably closed before a live `agent/turn-end` listener runs, so a post-boundary listener failure has no valid in-log position left and must be reported out of band.
|
||||
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
# RFC: Deep-readonly public surfaces
|
||||
|
||||
Status: rejected — the pervasive `DeepReadonly<T>` type flip was rejected in favor of an always-on `deriveMessages` clone plus dev-mode `Object.freeze` + invariants. The immutability *goal* shipped via that alternative; see [dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md).
|
||||
Status: rejected — the pervasive `DeepReadonly<T>` type flip is replaced by source-owned runtime immutability in `Session` plus relational development assertions. See [source-owned session immutability and dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md).
|
||||
|
||||
## Problem
|
||||
|
||||
The session log is append-only by contract, but `session.events` returns `readonly SessionEvent[]` whose *elements* are mutable: a plugin can reach in and rewrite history (`events[0].data.content.push(...)`), silently breaking replay equivalence and the derived-history guarantee. The same applies to derived messages and prompt assemblies passed through waterfalls — mutation is sometimes the intended idiom (waterfall middleware mutates the request) and sometimes corruption (mutating a *logged* event), and the types don't distinguish.
|
||||
The rejected proposal targeted an ownership hole that a `readonly SessionEvent[]` type alone cannot close: its elements remain mutable at runtime, so a cast or plain JavaScript can rewrite nested history. The implemented design closes that hole in `Session` by materializing and deep-freezing every accepted event and returning frozen array snapshots. In-flight prompt waterfalls remain intentionally transformable, so immutability is an ownership boundary rather than a blanket type rule.
|
||||
|
||||
## Proposal
|
||||
|
||||
> **Implemented differently — see the Status line and [dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md).** The `DeepReadonly<T>` design below was rejected as written (compile-only, high type-noise, castable). What shipped: an always-on deep clone in `deriveMessages` (closing the request/adapter aliasing path) plus a dev-mode `Object.freeze` + invariants plugin. The proposal text is kept for the record.
|
||||
> **Implemented differently — see the Status line and [source-owned session immutability and dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md).** The `DeepReadonly<T>` design below is rejected as written: it is compile-only, noisy across consumers, and castable. `Session` instead snapshots and deep-freezes accepted events and public log snapshots in every composition; `deriveMessages()` returns detached frozen projections; the development plugin checks cross-record and cross-seam relationships.
|
||||
|
||||
Make immutability part of the type where mutation is corruption:
|
||||
|
||||
- `SessionEvent` data becomes `DeepReadonly` on the way OUT of a session (`events`, `session/event` listeners); `append()` keeps taking plain mutable input. A `DeepReadonly<T>` utility type lands in dsh-llm next to the brand/never helpers.
|
||||
- `deriveMessages()` returns deep-readonly messages; the loop clones before handing a mutable request to the `agent/request` waterfall (mutation there is sanctioned — the clone makes the boundary explicit and cheap, once per step).
|
||||
- `PromptAssembly` stays mutable through its waterfall (sanctioned) but the registry's internal section list is cloned per assembly (already true).
|
||||
- Optionally, dev-mode `Object.freeze` of event data behind [the dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md) flag, so sanctioned-mutation violations throw in tests rather than corrupting silently.
|
||||
|
||||
## Plan
|
||||
|
||||
Introduce `DeepReadonly`, flip the session read paths, fix resulting compile errors in consumers (expected: a handful in tests), add the freeze-in-dev option alongside [the dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md) plugin.
|
||||
Introduce `DeepReadonly`, flip the session read paths, and fix the resulting compile errors in consumers.
|
||||
|
||||
## Risks
|
||||
|
||||
|
||||
Reference in New Issue
Block a user