From 32db205c100e9e5a7c8c6a1046f66fbd9c9edcad Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:54:03 +0800 Subject: [PATCH 01/64] feat(scope): dsh-scope scoped-context registration primitive createScope(ctx, key) mints a tagged context over a synchronously-usable no-op-plugin fiber (one fact drives visibility AND lifetime); scopeOf reads the tag through the prototype chain; scopeTarget(base, key) builds the scope-filtered dispatch carrier over cordis Context.filter, composing the base's own filter, branded Scoped and runtime-marked for the dev invariants. Scope.rawDispose exposes the exact cordis disposer so a composite effect can nest the scope's teardown at its yield position. --- docs/config-catalog.md | 1 + docs/module-graph.md | 2 + packages/core/scope/README.md | 20 ++ packages/core/scope/package.json | 30 +++ packages/core/scope/src/index.ts | 237 ++++++++++++++++++++++++ packages/core/scope/tests/scope.spec.ts | 209 +++++++++++++++++++++ packages/core/scope/tsconfig.json | 18 ++ pnpm-lock.yaml | 6 + tsconfig.build.json | 1 + tsconfig.json | 1 + 10 files changed, 525 insertions(+) create mode 100644 packages/core/scope/README.md create mode 100644 packages/core/scope/package.json create mode 100644 packages/core/scope/src/index.ts create mode 100644 packages/core/scope/tests/scope.spec.ts create mode 100644 packages/core/scope/tsconfig.json diff --git a/docs/config-catalog.md b/docs/config-catalog.md index e55066101f..870f57053e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -815,4 +815,5 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts)) - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) +- `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts)) diff --git a/docs/module-graph.md b/docs/module-graph.md index 5e043d22d4..9461c746d2 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -19,6 +19,7 @@ flowchart TD pkg_agent["agent"] pkg_agent_core["agent-core"] pkg_agent_loop["agent-loop"] + pkg_scope["scope"] pkg_session["session"] pkg_system_prompt["system-prompt"] pkg_tools["tools"] @@ -211,6 +212,7 @@ flowchart TD | Package | Group | Depends on | | --- | --- | --- | | [`brand`](../packages/util/brand) | `util` | — | +| [`scope`](../packages/core/scope) | `core` | — | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | — | | [`app-boot`](../packages/ui/app-boot) | `ui` | — | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | — | diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md new file mode 100644 index 0000000000..18961e42fd --- /dev/null +++ b/packages/core/scope/README.md @@ -0,0 +1,20 @@ +# dsh-scope + +Scoped-context registration primitive. `createScope(ctx, key)` mints a Cordis context that TAGS everything registered through it with an opaque `ScopeKey` and OWNS those registrations' lifetime (one backing fiber drives both facts); `scopeOf(ctx)` reads the tag; `scopeTarget(base, key)` builds the dispatch carrier that makes an event scope-filtered — listeners registered through a scoped context fire only for their key's subject, while plain plugin listeners keep firing for every subject. The agent loop is the one scope minter today (one scope per live agent, key = the `Agent` object — the `Agent.ctx` contract in `dsh-agent`), but the mechanism is key-agnostic so packages below the agent layer (`dsh-session`, `dsh-system-prompt`) depend on it without a dependency cycle. + +## Public API + +- `createScope(ctx: Context, key: ScopeKey): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). Throws on a primitive key, or when `ctx`'s fiber is disposing (`INACTIVE_EFFECT`). +- `Scope.ctx` The tagged context: registrations through it are scope-visible AND scope-lifetime. Derived contexts (an `extend`, a fiber mounted under it) inherit the tag; nested scopes shadow (nearest tag wins). +- `Scope.rawDispose` The EXACT Cordis disposer for the backing fiber — a composite (generator) effect yields THIS function to nest the scope's teardown at that yield position (Cordis dedupes nested effects by function identity; yielding a wrapper leaves the scope disposing as a concurrent sibling). +- `Scope.dispose(): Promise` Idempotent, always-awaitable teardown of every registration made through the scope. +- `scopeOf(ctx: Context): ScopeKey | undefined` The tag a context (or any context derived from it) carries; `undefined` = context-global. +- `scopeTarget(base: T, key?: ScopeKey): Scoped` Build the dispatch `thisArg` for a scope-filtered event: composes `base`'s own `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). Listener `this` stays `base`-shaped. `{ global: true }` listeners bypass filtering (Cordis semantics). +- `Scoped` The compile-time carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. +- `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name. + +## Design contract + +Ownership and visibility derive from ONE fact — which context a registration went through. An explicit `{ scope }` registration parameter could express "visible to X, disposed with Y", which is almost always a bug; the scoped context makes it unrepresentable. Rationale and alternatives: the agent-scoped-registration RFC (`docs/rfc/implemented/architecture/2026-07-08-agent-scoped-registration.md`, landing with this change set). + +Handing out a scoped context hands out the minting plugin's service-resolution capability (resolution walks the minting fiber's dependency chain, not the holder's) — mint scopes from a plugin whose `inject` surface is what scope holders should reach. diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json new file mode 100644 index 0000000000..89c2b4428b --- /dev/null +++ b/packages/core/scope/package.json @@ -0,0 +1,30 @@ +{ + "name": "@deepseek-ai/dsh-scope", + "description": "Scoped-context registration primitive (scope tags, scope-filtered event dispatch) for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts new file mode 100644 index 0000000000..ceb1cccba9 --- /dev/null +++ b/packages/core/scope/src/index.ts @@ -0,0 +1,237 @@ +/** + * Scoped-context primitive: mint a Cordis context that TAGS everything + * registered through it with an opaque {@link ScopeKey}, and dispatch events so + * listeners registered through such a context fire only for their key's + * subject. Scope-aware registries (`ctx.tools`, `ctx.systemPrompt`) read the + * tag via {@link scopeOf} to file a registration in the right layer; the agent + * loop is the one scope MINTER today (one scope per live agent, key = the + * `Agent` object — see `Agent.ctx` in `@deepseek-ai/dsh-agent`), but the + * mechanism is key-agnostic by design so packages below the agent layer + * (`dsh-session`, `dsh-system-prompt`) can depend on it without a dependency + * cycle. + * + * Ownership and visibility derive from ONE fact — which context a registration + * went through: the scope's fiber owns the disposal (a `ctx.effect()`/ + * `ctx.on()`/registry call through the scoped context unwinds on + * {@link Scope.dispose}, because Cordis routes a service method's `this.ctx` + * to the ACCESSING context), and the tag decides who sees it. Splitting those + * two — an explicit `{ scope }` registration parameter — would let a caller + * express "visible to X, disposed with Y", which is almost always a bug; the + * scoped context makes it unrepresentable. + * + * @module @deepseek-ai/dsh-scope + */ + +import type { Context } from 'cordis' +import { Context as CordisContext, withProps } from 'cordis' + +/** + * The identity a scope is keyed by. Opaque and compared by object identity — + * never inspected. The harness convention: a live `Agent` is the key of its + * own scope, so seam vocabularies that already carry the agent + * (`ToolExecution.agent`, `AssembleContext.scope`) name the layer directly. + */ +export type ScopeKey = object + +/** The context tag {@link createScope} writes and {@link scopeOf} reads (module-private). */ +const kScope = Symbol('dsh.scope') + +/** The carrier mark {@link scopeTarget} writes and {@link carrierKeyOf} reads (module-private). */ +const kCarrier = Symbol('dsh.scope.carrier') + +declare const ScopedBrand: unique symbol + +/** + * A dispatch carrier built by {@link scopeTarget}: structurally the `base` it + * overlays, branded so scope-filtered events can DEMAND a carrier as their + * `this` type — passing a bare subject where a `Scoped` is required is a + * compile error, which is what makes "forgot the carrier" unrepresentable at + * dispatch sites. The brand is compile-time only; {@link isScopeCarrier} is + * the runtime counterpart (used by the dev invariants). + */ +export type Scoped = T & { readonly [ScopedBrand]: 'dsh.scope.carrier' } + +/** + * A minted scope: the tagged context to register through, plus the disposers + * that unwind every registration made through it. + */ +export interface Scope { + /** + * The scoped context. Registrations through it are tagged with the scope's + * key (scope-aware registries file them in that key's layer; `ctx.on` + * listeners fire only for dispatches targeted at that key) and owned by the + * scope's fiber (disposed together on {@link dispose}). Contexts DERIVED + * from it — an `extend`, a fiber mounted under it — inherit the tag through + * the prototype chain. + */ + ctx: Context + /** + * The EXACT disposer Cordis registered on the minting fiber for the scope's + * backing fiber. A composite (generator) effect that owns the scope's + * position in an ordered teardown must yield THIS function: Cordis dedupes a + * nested effect out of the parent's concurrent disposal list by function + * identity, so yielding a wrapper would leave the scope disposing as an + * unordered sibling. Callers outside a composite effect use {@link dispose}. + * @returns the backing fiber's teardown promise (undefined on a repeat call + * — Cordis effect disposers are single-shot). + */ + rawDispose: () => Promise | void + /** + * Unwind the scope: dispose the backing fiber, running every collected + * registration disposer. Idempotent and always awaitable — a repeat call + * resolves immediately (the underlying Cordis disposer is single-shot and + * returns undefined the second time; this wrapper Promise-normalizes it). + * After disposal the scoped context is inert — a further registration + * through it throws Cordis's INACTIVE_EFFECT. + * @returns resolves when every registration's disposer has settled. + */ + dispose(): Promise +} + +/** + * The shared no-op plugin every scope fiber mounts: named so diagnostics read + * `scope` and shared so all scopes join ONE plugin runtime (Cordis deletes the + * runtime record when its last fiber disposes, so idle deployments carry no + * residue). + */ +function scope(): void {} + +/** + * Mint a registration scope for `key` under `ctx`. + * + * Mounts a runtime fiber (`ctx.plugin`) and tags a child of its context with + * `key`. The fiber is usable synchronously — Cordis activates it on a + * microtask, but effect collection is uid-gated (not state-gated) and service + * resolution falls through the pending fiber to the MINTING plugin's + * dependency surface, so a caller may register through {@link Scope.ctx} the + * moment this returns. + * + * Service resolution through the scoped context flows through the minting + * plugin's dependency chain (the fiber walk), regardless of what the eventual + * holder's own fiber injected — handing out the scoped context hands out that + * capability; see `Agent.ctx` in `@deepseek-ai/dsh-agent` for the harness's + * contract. + * @param ctx - the context to mount the scope under; its fiber must be active + * (a disposing owner throws Cordis's INACTIVE_EFFECT), and its plugin's + * `inject` surface is what the scoped context resolves services against. + * @param key - the scope's identity ({@link ScopeKey}); must be an object + * (identity-compared), else this throws. + * @returns the tagged context plus its disposers ({@link Scope}). + */ +export function createScope(ctx: Context, key: ScopeKey): Scope { + // Runtime guard behind the ScopeKey type: callers outside the typechecker + // (yml-configured plugins, JS consumers) can still pass a primitive. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (typeof key !== 'object' || key === null) { + throw new TypeError('createScope: key must be an object (scope keys are identity-compared)') + } + const fiber = ctx.plugin(scope) + const scoped: Context = fiber.ctx.extend({ [kScope]: key }) + return { + ctx: scoped, + // fiber.dispose IS the disposer Cordis pushed onto the minting fiber's + // disposable list — the identity a composite effect must yield (see + // Scope.rawDispose). + rawDispose: fiber.dispose, + // Promise.resolve-normalized: a cordis fiber's dispose returns undefined + // on a repeat call (the epoch is already cleared), and Scope.dispose + // promises an awaitable on every call. + dispose: () => Promise.resolve(fiber.dispose()), + } +} + +/** + * Read the scope key a context is tagged with, or `undefined` for an untagged + * (context-global) context. Walks the prototype chain, so any context DERIVED + * from a scoped context — service shadows, `extend`s, fibers mounted under it + * — reads as that scope; with nested scopes the nearest tag wins. + * @param ctx - the context to inspect (typically a registry method's + * `this.ctx`, i.e. the ACCESSING context). + * @returns the key given to {@link createScope}, or `undefined` when the + * context is not derived from any scope. + */ +export function scopeOf(ctx: Context): ScopeKey | undefined { + // A plain (possibly proxied) property read: symbols bypass the Cordis + // context proxy's service resolution, and Reflect walks the prototype chain. + return (ctx as Context & { [kScope]?: ScopeKey })[kScope] +} + +/** + * Build the dispatch carrier for a scope-filtered event: `base` overlaid with + * a `Context.filter` that admits a listener iff + * + * - its registering context is UNTAGGED (a context-global listener — the + * compatibility default: plain plugin listeners see every subject), or + * - its tag IS `key` (a scoped listener seeing exactly its own subject), + * + * AND `base`'s own filter (a Cordis `Service`'s isolation check) also admits + * it. Dispatching with `key === undefined` — a subject-less dispatch, e.g. a + * tool call with no calling agent or a bare (agent-less) session's events — + * admits only untagged listeners: a scoped listener never fires for someone + * else's (or nobody's) subject. Listeners registered `{ global: true }` + * bypass all filtering (Cordis semantics). + * + * Use it as the `thisArg` of the dispatch: + * `ctx.waterfall(scopeTarget(this, exec.agent), 'tools/pre-execute', …)`. The + * carrier is a proxy over `base` — listener `this` stays `base`-shaped, but + * identity-comparing `this` against the subject is not supported; the subject + * always travels in the event's arguments. The returned carrier is branded + * {@link Scoped} and runtime-marked ({@link isScopeCarrier} / + * {@link carrierKeyOf}) so both the type system and the dev invariants can + * tell a carrier from a bare subject. + * @param base - the object the event is dispatched on behalf of (the owning + * service, or the subject agent itself); its own `Context.filter` is + * preserved and composed. + * @param key - the subject's scope key, or `undefined` for a subject-less + * dispatch. + * @returns the carrier to pass as the dispatch `thisArg`. + */ +export function scopeTarget(base: T, key: ScopeKey | undefined): Scoped { + const baseFilter = (base as { [CordisContext.filter]?: (ctx: Context) => boolean })[CordisContext.filter] + const filter = (ctx: Context): boolean => { + if (baseFilter && !baseFilter.call(base, ctx)) return false + const tag = scopeOf(ctx) + return tag === undefined || tag === key + } + // withProps overlays own-property reads; the symbol-keyed props have no + // structural overlap with T. withProps is typed `any` upstream (a generic + // proxy helper); the carrier is structurally the same T it overlays plus + // the compile-time brand, so pin the type via the return annotation. + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return withProps(base, { + [CordisContext.filter]: filter, + [kCarrier]: { key }, + }) +} + +/** + * Whether `value` is a carrier built by {@link scopeTarget} — the runtime + * counterpart of the {@link Scoped} brand, used by the dev invariants to + * assert that a scope-filtered event was dispatched with a carrier and not a + * bare subject. + * @param value - the dispatch `thisArg` to test. + * @returns true iff `value` came from {@link scopeTarget}. + */ +export function isScopeCarrier(value: unknown): value is Scoped { + if (typeof value !== 'object' || value === null) return false + // A property READ, not an `in` check: withProps overlays props via get/set + // traps only (no `has` trap), so `kCarrier in carrier` would fall through to + // the wrapped base and always answer false. + return (value as { [kCarrier]?: { key: ScopeKey | undefined } })[kCarrier] !== undefined +} + +/** + * The scope key a carrier was built for — `undefined` for a subject-less + * carrier, and also `undefined` for a non-carrier (pair with + * {@link isScopeCarrier} when the distinction matters). The dev invariants + * use it to assert the carrier's key IS the subject the event's arguments + * name. + * @param value - the dispatch `thisArg` to read. + * @returns the `key` given to {@link scopeTarget}, or `undefined`. + */ +export function carrierKeyOf(value: unknown): ScopeKey | undefined { + if (!isScopeCarrier(value)) return undefined + // Optional-prop cast: the guard proves the mark is present at runtime, but + // the Scoped<> brand carries no structural kCarrier member to narrow from. + return (value as { [kCarrier]?: { key: ScopeKey | undefined } })[kCarrier]?.key +} diff --git a/packages/core/scope/tests/scope.spec.ts b/packages/core/scope/tests/scope.spec.ts new file mode 100644 index 0000000000..5350a539f0 --- /dev/null +++ b/packages/core/scope/tests/scope.spec.ts @@ -0,0 +1,209 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' +import { Context } from 'cordis' +import { carrierKeyOf, createScope, isScopeCarrier, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' +import type { Scope, ScopeKey, Scoped } from '@deepseek-ai/dsh-scope' + +declare module 'cordis' { + interface Events { + /** + * Test-only event for exercising scope-filtered dispatch. + * @param value - opaque payload recorded by listeners. + * @mode emit + */ + 'scope-test/ping'(value: string): void + /** + * Test-only waterfall for exercising carrier `this` shape. + * @param value - seed value listeners may wrap. + * @mode waterfall + */ + 'scope-test/echo'(value: string, next: () => string): string + } +} + +/** Mount a host plugin and mint a scope inside it, returning both. */ +async function mintScope(ctx: Context, key: object): Promise { + let scope!: Scope + await ctx.plugin((inner: Context) => { + scope = createScope(inner, key) + }) + return scope +} + +describe('createScope', () => { + it('rejects a primitive key at runtime (identity-compared keys must be objects)', () => { + const ctx = new Context() + // Typed through `unknown` so the ScopeKey type cannot argue the assertion + // away: this test exercises exactly the callers the typechecker misses. + const badKeys: unknown[] = ['k', null] + for (const bad of badKeys) { + expect(() => createScope(ctx, bad as ScopeKey)).toThrow(/must be an object/) + } + }) + + it('tags the scoped context, readable through derivations (nearest tag wins)', async () => { + const ctx = new Context() + const key = { name: 'a' } + const inner = { name: 'a.inner' } + const scope = await mintScope(ctx, key) + + expect(scopeOf(scope.ctx)).toBe(key) + // An extend of the scoped context inherits the tag through the prototype chain. + expect(scopeOf(scope.ctx.extend({}))).toBe(key) + // A plain context carries no tag. + expect(scopeOf(ctx)).toBeUndefined() + // A fiber mounted UNDER the scoped context reads as that scope… + let mountedCtx!: Context + await scope.ctx.plugin((c: Context) => { mountedCtx = c }) + expect(scopeOf(mountedCtx)).toBe(key) + // …and a nested scope shadows the outer tag (nearest wins). + const nested = createScope(scope.ctx, inner) + expect(scopeOf(nested.ctx)).toBe(inner) + }) + + it('is usable synchronously: registrations land before the fiber activates', async () => { + const ctx = new Context() + const events: string[] = [] + await ctx.plugin((inner: Context) => { + const scope = createScope(inner, { name: 'sync' }) + // Same tick as createScope — no await between mint and use. + scope.ctx.effect(() => () => void events.push('effect-disposed')) + scope.ctx.on('scope-test/ping', value => void events.push(`heard:${value}`)) + events.push('registered') + }) + ctx.emit(scopeTarget(ctx, undefined), 'scope-test/ping', 'nobody') + expect(events).toEqual(['registered']) + }) + + it('dispose() unwinds registrations, is idempotent, and inerts the context', async () => { + const ctx = new Context() + const scope = await mintScope(ctx, { name: 'd' }) + const order: string[] = [] + scope.ctx.effect(() => () => void order.push('a')) + scope.ctx.effect(() => () => void order.push('b')) + + await scope.dispose() + expect(order).toEqual(['b', 'a']) // LIFO within the scope fiber + + // Repeat dispose: the underlying cordis disposer returns undefined; the + // wrapper still resolves. + await expect(scope.dispose()).resolves.toBeUndefined() + // Registration through a disposed scope throws INACTIVE_EFFECT. + expect(() => scope.ctx.effect(() => () => {})).toThrow(/inactive context/) + }) + + it('rawDispose is the exact cordis disposer: yielding it nests the scope at its position', async () => { + const ctx = new Context() + const order: string[] = [] + let composite!: () => Promise | void + await ctx.plugin((inner: Context) => { + composite = inner.effect(function* () { + yield () => void order.push('outermost') // disposed LAST + const scope = createScope(inner, { name: 'nested' }) + scope.ctx.effect(() => () => void order.push('scope-registration')) + yield scope.rawDispose // disposed SECOND — nested by identity + yield () => void order.push('innermost') // disposed FIRST + }) + }) + await composite() + // The scope disposed exactly at its yield position (between the two + // neighbours), not as a concurrent sibling of the composite. + expect(order).toEqual(['innermost', 'scope-registration', 'outermost']) + }) +}) + +describe('scopeTarget dispatch filtering', () => { + it('scoped listeners hear only their key; untagged listeners hear everything', async () => { + const ctx = new Context() + const keyA = { name: 'A' } + const keyB = { name: 'B' } + const scopeA = await mintScope(ctx, keyA) + const scopeB = await mintScope(ctx, keyB) + + const heard: string[] = [] + ctx.on('scope-test/ping', value => void heard.push(`global:${value}`)) + scopeA.ctx.on('scope-test/ping', value => void heard.push(`A:${value}`)) + scopeB.ctx.on('scope-test/ping', value => void heard.push(`B:${value}`)) + + ctx.emit(scopeTarget(ctx, keyA), 'scope-test/ping', 'to-A') + ctx.emit(scopeTarget(ctx, keyB), 'scope-test/ping', 'to-B') + ctx.emit(scopeTarget(ctx, undefined), 'scope-test/ping', 'to-nobody') + + expect(heard).toEqual([ + 'global:to-A', 'A:to-A', + 'global:to-B', 'B:to-B', + 'global:to-nobody', + ]) + }) + + it('{ global: true } listeners bypass scope filtering entirely', async () => { + const ctx = new Context() + const keyA = { name: 'A' } + const scopeA = await mintScope(ctx, keyA) + const heard: string[] = [] + scopeA.ctx.on('scope-test/ping', value => void heard.push(`escape:${value}`), { global: true }) + + ctx.emit(scopeTarget(ctx, { name: 'other' }), 'scope-test/ping', 'foreign') + ctx.emit(scopeTarget(ctx, undefined), 'scope-test/ping', 'nobody') + expect(heard).toEqual(['escape:foreign', 'escape:nobody']) + }) + + it("composes the base's own Context.filter (a rejecting base filter wins)", async () => { + const ctx = new Context() + const keyA = { name: 'A' } + const scopeA = await mintScope(ctx, keyA) + const heard: string[] = [] + ctx.on('scope-test/ping', value => void heard.push(`global:${value}`)) + scopeA.ctx.on('scope-test/ping', value => void heard.push(`A:${value}`)) + + // A base whose own filter rejects every listener context: nothing fires, + // scoped or not — the scope predicate never overrides the base's veto. + const vetoBase = { [Context.filter]: () => false } + ctx.emit(scopeTarget(vetoBase, keyA), 'scope-test/ping', 'vetoed') + expect(heard).toEqual([]) + + // A base whose filter accepts delegates to the scope predicate. + const openBase = { [Context.filter]: () => true } + ctx.emit(scopeTarget(openBase, keyA), 'scope-test/ping', 'open') + expect(heard).toEqual(['global:open', 'A:open']) + }) + + it('keeps listener `this` base-shaped through the carrier (waterfall)', async () => { + const ctx = new Context() + const base = { label: 'the-base' } + let seenLabel: string | undefined + ctx.on('scope-test/echo', function (this: { label: string }, value, next) { + seenLabel = this.label + return `${next()}+${value}` + }) + const result = ctx.waterfall(scopeTarget(base, undefined), 'scope-test/echo', 'v', () => 'seed') + expect(result).toBe('seed+v') + expect(seenLabel).toBe('the-base') + }) +}) + +describe('carrier marks', () => { + it('isScopeCarrier / carrierKeyOf distinguish carriers, keys, and bare subjects', () => { + const base = { name: 'base' } + const key = { name: 'key' } + const keyed = scopeTarget(base, key) + const subjectless = scopeTarget(base, undefined) + + expect(isScopeCarrier(keyed)).toBe(true) + expect(carrierKeyOf(keyed)).toBe(key) + expect(isScopeCarrier(subjectless)).toBe(true) + expect(carrierKeyOf(subjectless)).toBeUndefined() + + expect(isScopeCarrier(base)).toBe(false) + expect(carrierKeyOf(base)).toBeUndefined() + expect(isScopeCarrier(null)).toBe(false) + expect(isScopeCarrier('x')).toBe(false) + }) + + it('brands the carrier type (compile-time)', () => { + const base = { name: 'base' } + const carrier = scopeTarget(base, undefined) + expectTypeOf(carrier).toExtend>() + // A bare subject is NOT assignable where a carrier is demanded. + expectTypeOf(base).not.toExtend>() + }) +}) diff --git a/packages/core/scope/tsconfig.json b/packages/core/scope/tsconfig.json new file mode 100644 index 0000000000..754725418e --- /dev/null +++ b/packages/core/scope/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32ffa0d389..8251dc62e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -264,6 +264,12 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/core/scope: + devDependencies: + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/core/session: devDependencies: '@deepseek-ai/dsh-brand': diff --git a/tsconfig.build.json b/tsconfig.build.json index 3d99ad4e28..b2cb30885d 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -13,6 +13,7 @@ { "path": "./packages/util/brand" }, { "path": "./packages/llm/llm" }, { "path": "./packages/core/session" }, + { "path": "./packages/core/scope" }, { "path": "./packages/session-persistence/session-persistence" }, { "path": "./packages/session-persistence/session-persistence-jsonl" }, { "path": "./packages/session-persistence/session-persistence-sqlite" }, diff --git a/tsconfig.json b/tsconfig.json index 2091283c93..24e81fac15 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,7 @@ { "path": "./packages/util/brand" }, { "path": "./packages/llm/llm" }, { "path": "./packages/core/session" }, + { "path": "./packages/core/scope" }, { "path": "./packages/session-persistence/session-persistence" }, { "path": "./packages/session-persistence/session-persistence-jsonl" }, { "path": "./packages/session-persistence/session-persistence-sqlite" }, From 3d16026eb04a93022336052a6d43923df0d3b05e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:09:21 +0800 Subject: [PATCH 02/64] feat(core): scope-aware registries and session dispatch carriers dsh-tools and dsh-system-prompt gain a per-scope registration layer over dsh-scope: a registration through a scoped context files into that scope, shadows a same-named global contribution for that scope (per-agent persona and tool variants), and unwinds with the scope. tools.restrict() masks the global surface per scope (snapshot-at-registration, loud unknown-name validation, intersection composition; scoped grants bypass). One visibility function feeds schemas/get/execute, so prompt, presentation, and dispatch can never disagree; out-of-view executes as UNKNOWN_TOOL. Prompt tool providers now receive the AssembleContext and return {schemas, knownNames}: toolOrder validates against the pre-restriction name universe (a typo fails every assembly loudly) while ordering operates on the post-restriction schemas (a restricted-away tool is a normal absence). dsh-session captures each session's dispatch carrier at enter() from the entering context's scope tag, and the new sessions.flush(session) owns the awaited session/flush dispatch. tools/pre|post-execute and system-prompt/assemble dispatch with scope carriers keyed by their subject; session/created|event|flush by the owning session's scope. --- .../core/agent-loop/tests/tool-order.spec.ts | 2 +- packages/core/session/package.json | 2 + packages/core/session/src/index.ts | 78 +++++- packages/core/session/tests/scoped.spec.ts | 112 ++++++++ packages/core/session/tsconfig.json | 3 + packages/core/system-prompt/package.json | 2 + packages/core/system-prompt/src/index.ts | 246 +++++++++++++----- .../core/system-prompt/tests/scoped.spec.ts | 138 ++++++++++ .../system-prompt/tests/system-prompt.spec.ts | 16 +- .../system-prompt/tests/tool-order.spec.ts | 26 +- packages/core/system-prompt/tsconfig.json | 3 + packages/core/tools/package.json | 2 + packages/core/tools/src/index.ts | 237 +++++++++++++++-- packages/core/tools/tests/scoped.spec.ts | 172 ++++++++++++ packages/core/tools/tsconfig.json | 3 + pnpm-lock.yaml | 15 ++ 16 files changed, 940 insertions(+), 117 deletions(-) create mode 100644 packages/core/session/tests/scoped.spec.ts create mode 100644 packages/core/system-prompt/tests/scoped.spec.ts create mode 100644 packages/core/tools/tests/scoped.spec.ts diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index 35329ce679..d9b0a87e1d 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -107,7 +107,7 @@ describe('loop-level canonical tool order', () => { agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) - expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; registered tools: alpha']) + expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; known tools: alpha']) expect(foldRequestHeader(agent.session.events)).toBeUndefined() const end = agent.session.events.find(e => e.type === 'turn/end') expect(end?.type === 'turn/end' && end.data.reason).toMatchObject({ kind: 'error', step: 1 }) diff --git a/packages/core/session/package.json b/packages/core/session/package.json index 8c6645e37e..454a0d7cc3 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -24,11 +24,13 @@ "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index cd9e1828b2..0bbd957a79 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -9,6 +9,8 @@ import { Context, Service } from 'cordis' import { isAbsolute } from 'node:path' import { deepFreeze } from '@deepseek-ai/dsh-llm' +import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' +import type { Scoped } from '@deepseek-ai/dsh-scope' import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionId } from './types.ts' import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts' @@ -33,28 +35,48 @@ declare module 'cordis' { interface Events { /** * A session was created in the store. + * 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. * @param session - the session just entered and announced. * @mode emit */ - 'session/created'(session: Session): void + 'session/created'(this: Scoped, session: Session): void /** * An event was appended to a session log (sync, fire-and-forget). This is * the per-append feed a UI or invariant plugin tails. + * 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. * @param session - the session whose log grew. * @param event - the appended event, exactly as recorded. * @mode emit */ - 'session/event'(session: Session, event: SessionEvent): void + 'session/event'(this: Scoped, session: Session, event: SessionEvent): void /** * Awaited durability checkpoint. The agent loop awaits - * `ctx.parallel('session/flush', session)` at every turn end; persistence + * `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 loop waits for all of them, but none can veto. + * and the caller waits for all of them, but none can veto. Dispatch it + * through {@link 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. * @param session - the session whose buffered events must reach durable storage. * @mode parallel */ - 'session/flush'(session: Session): Promise | void + 'session/flush'(this: Scoped, session: Session): Promise | void } } @@ -404,6 +426,14 @@ export class SessionForkError extends Error { */ export class SessionStore extends Service { private store = new Map() + /** + * Each live session's dispatch carrier, captured at {@link enter} from the + * ENTERING context's scope tag (an agent session is entered through + * `agent.ctx` ⇒ its events dispatch in that agent's scope; a bare session ⇒ + * subject-less carrier). WeakMap so a detached session drops its carrier + * with the entry. + */ + private carriers = new WeakMap>() private counter = 0 constructor(ctx: Context) { @@ -498,7 +528,15 @@ export class SessionStore extends Service { */ enter(session: Session): () => void { if (this.store.has(session.id)) throw new Error(`session "${session.id}" already exists`) - session.onAppend = (event) => { this.ctx.emit('session/event', session, event) } + // The carrier is decided HERE, once, from the ENTERING context's scope tag + // (`this.ctx` is the caller's context — the tracker mechanism): every + // session/created|event|flush dispatch for this session uses it, so the + // session's whole event feed is scope-filtered consistently. The base is + // the session itself (scoped listeners' `this` is the session). + const carrier = scopeTarget(session, scopeOf(this.ctx)) + this.carriers.set(session, carrier) + const emitCtx = this.ctx + session.onAppend = (event) => { emitCtx.emit(carrier, 'session/event', session, event) } this.store.set(session.id, session) return () => { session.onAppend = undefined @@ -506,12 +544,32 @@ export class SessionStore extends Service { } } - /** Emit `session/created` for an {@link enter}ed session. Separate from - * {@link enter} so the caller can yield the detach disposer first (rollback - * safety — see {@link enter}). + /** Emit `session/created` for an {@link enter}ed session (with the carrier + * {@link enter} captured). Separate from {@link enter} so the caller can + * yield the detach disposer first (rollback safety — see {@link enter}). * @param session - the entered session to announce to listeners. */ announce(session: Session): void { - this.ctx.emit('session/created', session) + this.ctx.emit(this.carrierFor(session), 'session/created', session) + } + + /** + * Dispatch the awaited `session/flush` durability checkpoint for `session`, + * with the carrier captured at {@link enter}. THE flush entry point: the + * store owns the carrier, so callers (the loop's turn-end checkpoint, idle + * injection, teardown drains) must come through here rather than dispatch a + * raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the + * scoped-dispatch invariant can pin it. + * @param session - the session whose buffered events must reach durable storage. + * @returns resolves when every flush listener has settled; rejects if one rejects. + */ + async flush(session: Session): Promise { + await this.ctx.parallel(this.carrierFor(session), 'session/flush', session) + } + + /** The carrier {@link enter} captured, or a subject-less one for a session + * never entered (defensive: dispatch stays filtered either way). */ + private carrierFor(session: Session): Scoped { + return this.carriers.get(session) ?? scopeTarget(session, undefined) } /** diff --git a/packages/core/session/tests/scoped.spec.ts b/packages/core/session/tests/scoped.spec.ts new file mode 100644 index 0000000000..743b72def3 --- /dev/null +++ b/packages/core/session/tests/scoped.spec.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { createScope, scopeOf } from '@deepseek-ai/dsh-scope' +import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope' +import SessionStore from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' + +async function mount(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + return ctx +} + +async function mintScope(ctx: Context, name: string): Promise { + let scope!: Scope + // The scoped context resolves services through the MINTING plugin's + // dependency chain — the minter must inject what scope holders will reach. + await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, { name }) }, + { inject: ['sessions'] })) + return scope +} + +/** The key a test scope was minted with. */ +function keyOf(scope: Scope): ScopeKey { + + return scopeOf(scope.ctx)! +} + +describe('session dispatch carriers', () => { + it('a session entered through a scoped context dispatches its events in that scope', async () => { + const ctx = await mount() + const scope = await mintScope(ctx, 'owner') + const otherScope = await mintScope(ctx, 'other') + + const heard: string[] = [] + ctx.on('session/event', (_session, event) => void heard.push(`global:${event.type}`)) + scope.ctx.on('session/event', (_session, event) => void heard.push(`owner:${event.type}`)) + otherScope.ctx.on('session/event', (_session, event) => void heard.push(`other:${event.type}`)) + scope.ctx.on('session/created', session => void heard.push(`owner-created:${session.id}`)) + otherScope.ctx.on('session/created', session => void heard.push(`other-created:${session.id}`)) + + const session = scope.ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + + expect(heard).toEqual([ + `owner-created:${session.id}`, + 'global:turn/start', + 'owner:turn/start', + ]) + }) + + it('a bare session dispatches subject-less: scoped listeners never hear it', async () => { + const ctx = await mount() + const scope = await mintScope(ctx, 'owner') + const heard: string[] = [] + ctx.on('session/event', (_s, event) => void heard.push(`global:${event.type}`)) + scope.ctx.on('session/event', (_s, event) => void heard.push(`owner:${event.type}`)) + + const bare = ctx.sessions.create() + bare.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + expect(heard).toEqual(['global:turn/start']) + }) +}) + +describe('sessions.flush()', () => { + it('dispatches session/flush with the owning carrier and awaits all listeners', async () => { + const ctx = await mount() + const scope = await mintScope(ctx, 'owner') + const flushed: string[] = [] + ctx.on('session/flush', async (session: Session) => { + await Promise.resolve() + flushed.push(`global:${session.id}`) + }) + scope.ctx.on('session/flush', (session: Session) => void flushed.push(`owner:${session.id}`)) + + const owned = scope.ctx.sessions.create() + const bare = ctx.sessions.create() + await ctx.sessions.flush(owned) + await ctx.sessions.flush(bare) + + // Parallel dispatch: listener completion order is unspecified (the global + // listener awaits a microtask) — assert set membership per flush instead. + expect(flushed.slice(0, 2).sort()).toEqual([`global:${owned.id}`, `owner:${owned.id}`]) + expect(flushed.slice(2)).toEqual([`global:${bare.id}`]) + }) + + it('propagates a rejecting flush listener (the caller owns the failure policy)', async () => { + const ctx = await mount() + ctx.on('session/flush', () => Promise.reject(new Error('disk full'))) + const session = ctx.sessions.create() + await expect(ctx.sessions.flush(session)).rejects.toThrow('disk full') + }) + + it('flushes a never-entered session with a subject-less carrier (defensive path)', async () => { + const ctx = await mount() + const scope = await mintScope(ctx, 'owner') + const flushed: string[] = [] + ctx.on('session/flush', (session: Session) => void flushed.push(`global:${session.id}`)) + scope.ctx.on('session/flush', (session: Session) => void flushed.push(`owner:${session.id}`)) + + const detached = ctx.sessions.prepare() + await ctx.sessions.flush(detached) + expect(flushed).toEqual([`global:${detached.id}`]) + }) + + it('keyOf sanity: distinct scopes carry distinct keys', async () => { + const ctx = await mount() + const a = await mintScope(ctx, 'a') + const b = await mintScope(ctx, 'b') + expect(keyOf(a)).not.toBe(keyOf(b)) + }) +}) diff --git a/packages/core/session/tsconfig.json b/packages/core/session/tsconfig.json index 7ca1556695..b19b98c5ad 100644 --- a/packages/core/session/tsconfig.json +++ b/packages/core/session/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../llm/llm" + }, + { + "path": "../../core/scope" } ] } diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index d97a7b8538..120e10ef11 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -23,6 +23,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "dependencies": { @@ -30,6 +31,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 81ca8087bb..8fa2daf3b0 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -14,6 +14,8 @@ import { Context, Service } from 'cordis' import z from 'schemastery' +import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' +import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope' import type { ToolSchema } from '@deepseek-ai/dsh-llm' declare module 'cordis' { @@ -30,15 +32,23 @@ declare module 'cordis' { * @param assembly - the assembly built from the registered sections, tool * providers, and variable providers; listeners may mutate it or return a * replacement. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed + * by `context.scope` — a listener registered through `agent.ctx` fires only + * for that agent's assemblies; a plain plugin listener fires for every + * assembly (scope-less ones included, dispatched subject-less). * @param context - the per-assembly {@link AssembleContext} the caller * passed to {@link SystemPrompt.assemble} (e.g. which agent the prompt * is for), so a listener can filter or extend per agent. * @mode waterfall */ - 'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise + 'system-prompt/assemble'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise /** * A section, tool provider, or variable provider was registered or - * unregistered (the assembly inputs changed). + * 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. * @mode emit */ 'system-prompt/change'(): void @@ -47,13 +57,24 @@ declare module 'cordis' { /** * Per-assembly input: what one {@link SystemPrompt.assemble} call is FOR. - * Declared empty here so this package stays agnostic of who assembles; - * merge-extensible — `@deepseek-ai/dsh-agent` declares the `agent` field, so - * section text and variable providers can be functions of the calling agent. - * Every field is optional by nature: a bare `assemble()` (tests, diagnostics) - * carries an empty context, and providers must tolerate absent fields. + * Merge-extensible and agnostic of who assembles — `@deepseek-ai/dsh-agent` + * declares the `agent` field, so section text and variable providers can be + * functions of the calling agent. Every field is optional by nature: a bare + * `assemble()` (tests, diagnostics) carries an empty, scope-less context, and + * providers must tolerate absent fields. */ -export interface AssembleContext {} +export interface AssembleContext { + /** + * The scope layer this assembly resolves (`@deepseek-ai/dsh-scope`): scoped + * sections/variables/tool-providers registered through this key's context + * join the assembly (shadowing same-named global contributions), and the + * `system-prompt/assemble` waterfall dispatches in this scope. The agent + * loop sets it to the agent (alongside the `agent` DX field — never set + * `agent` without `scope`; the dev invariants flag the mismatch). Absent = + * a scope-less assembly: global layer only, subject-less dispatch. + */ + scope?: ScopeKey +} /** One contributed section of the system prompt (registry input). */ export interface PromptSection { @@ -83,6 +104,23 @@ export interface AssembledSection { text: string } +/** + * What one tool-schema provider contributes to an assembly + * ({@link SystemPrompt.tools}). `schemas` is the provider's POST-restriction + * visible set for the assembly's scope — exactly what the model may be shown. + * `knownNames` is its PRE-restriction name universe: the set configured names + * (`toolOrder`) are validated against, so a config typo fails loud while a + * restricted-away tool stays a normal, non-erroneous absence. Omitted, + * `knownNames` defaults to the names of `schemas` (right for providers with no + * restriction concept). + */ +export interface ToolProviderResult { + /** The schemas this provider contributes to THIS assembly. */ + schemas: ToolSchema[] + /** The pre-restriction name universe for config validation (defaults to `schemas`' names). */ + knownNames?: readonly string[] +} + /** * The assembled prompt. * @@ -146,23 +184,26 @@ function validateToolOrder(toolOrder: string[] | undefined): string[] | undefine * list, plain lexicographic name order; with one, listed names take their * listed position and every unlisted tool lands at the * {@link TOOL_ORDER_REST} rest entry in lexicographic name order. A listed - * name with no collected tool throws — misconfiguration fails loud, and this - * is the earliest moment the registered tool set exists to check against - * (tool plugins register after the service constructs, so load time is too - * early): the assembly rejects, failing the caller's turn before any model - * request. Never drops a tool, and both sorts are stable, so tools sharing a - * name keep their collection order. + * name outside `knownNames` — the providers' PRE-restriction name universe — + * throws: misconfiguration fails loud, and each assembly is the earliest + * moment the registered tool set exists to check against (tool plugins + * register after the service constructs, so load time is too early); the + * assembly rejects, failing the caller's turn before any model request. A + * listed name that is KNOWN but not collected (a tool restricted away for + * this assembly's scope) is a normal absence: its position simply + * contributes nothing — `toolOrder` stays compatible with per-agent + * `restrict()` masks. Never drops a collected tool, and both sorts are + * stable, so tools sharing a name keep their collection order. */ -function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined): ToolSchema[] { +function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined, knownNames: ReadonlySet): ToolSchema[] { const reserved = tools.find(tool => tool.name === TOOL_ORDER_REST) if (reserved !== undefined) { throw new Error(`tool provider returned reserved tool name "${TOOL_ORDER_REST}" (reserved for toolOrder's rest entry)`) } if (toolOrder === undefined) return tools.sort(compareToolNames) - const registered = new Set(tools.map(tool => tool.name)) - const unknown = toolOrder.filter(name => name !== TOOL_ORDER_REST && !registered.has(name)) + const unknown = toolOrder.filter(name => name !== TOOL_ORDER_REST && !knownNames.has(name)) if (unknown.length > 0) { - throw new Error(`toolOrder lists unregistered tool${unknown.length > 1 ? 's' : ''} ${unknown.map(name => `"${name}"`).join(', ')}; registered tools: ${[...registered].sort().join(', ') || '(none)'}`) + throw new Error(`toolOrder lists unregistered tool${unknown.length > 1 ? 's' : ''} ${unknown.map(name => `"${name}"`).join(', ')}; known tools: ${[...knownNames].sort().join(', ') || '(none)'}`) } const listed = new Set(toolOrder) const rest = tools.filter(tool => !listed.has(tool.name)).sort(compareToolNames) @@ -181,7 +222,10 @@ 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). Every agent in - * the context shares it, subagents included. Template, not free-form text: + * 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 @@ -301,8 +345,12 @@ export class SystemPrompt extends Service { }) private sections: PromptSection[] = [] - private toolProviders: (() => ToolSchema[])[] = [] + private toolProviders: ((context: AssembleContext) => ToolProviderResult)[] = [] private variableProviders = new Map string | undefined>() + /** Per-scope layers (`@deepseek-ai/dsh-scope`); entries drop when a layer empties, so a disposed scope leaves no residue. */ + private scopedSections = new Map() + private scopedToolProviders = new Map ToolProviderResult)[]>() + private scopedVariableProviders = new Map string | undefined>>() private readonly toolOrder: string[] | undefined constructor(ctx: Context, public config: Config) { @@ -330,27 +378,44 @@ export class SystemPrompt extends Service { /** * Contribute a text section to the system prompt. Order is determined by - * `section.order` (ascending). Throws if a section with the same name is - * already registered (a duplicate would silently double prompt text — e.g. - * a double-loaded tool plugin). The section is removed when the calling - * fiber is disposed. Emits `system-prompt/change` on register/unregister. + * `section.order` (ascending). The layer is decided by the CALLING context + * (`@deepseek-ai/dsh-scope`): a plain plugin context contributes globally; a + * scoped context (`agent.ctx`) contributes to that scope alone — and a + * scoped section SHADOWS a same-named global section for that scope's + * assemblies (most-specific-wins; this is how a per-agent persona overrides + * `deployment:persona`). Throws if the SAME layer already has the name (a + * duplicate would silently double prompt text — e.g. a double-loaded tool + * plugin; the global-duplicate message names `agent.ctx` as the per-agent + * alternative). Removed when the calling fiber is disposed. Emits + * `system-prompt/change` on register/unregister. * @param section - the section to contribute (name, order, text or provider). * @returns the disposer that removes the section. */ section(section: PromptSection): () => void { + const scope = scopeOf(this.ctx) const dispose = this.ctx.effect(function* (this: SystemPrompt) { - if (this.sections.some(existing => existing.name === section.name)) { - throw new Error(`prompt section "${section.name}" is already registered`) + const layer = scope === undefined + ? this.sections + : this.scopedSections.get(scope) ?? (() => { + const created: PromptSection[] = [] + this.scopedSections.set(scope, created) + return created + })() + if (layer.some(existing => existing.name === section.name)) { + throw new Error(scope === undefined + ? `prompt section "${section.name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)` + : `prompt section "${section.name}" is already registered in this scope`) } - this.sections.push(section) + layer.push(section) // Yield the rollback BEFORE emitting `system-prompt/change`: a generator // effect collects each yielded disposer before the next step runs, so a // throwing change listener removes the section instead of leaking it into // every future assembly. yield () => { - const index = this.sections.indexOf(section) + const index = layer.indexOf(section) /* v8 ignore next 3 -- defensive: section was registered, so indexOf is guaranteed >= 0 */ - if (index >= 0) this.sections.splice(index, 1) + if (index >= 0) layer.splice(index, 1) + if (scope !== undefined && layer.length === 0) this.scopedSections.delete(scope) this.ctx.emit('system-prompt/change') } this.ctx.emit('system-prompt/change') @@ -361,23 +426,36 @@ export class SystemPrompt extends Service { } /** - * Contribute a tool-schema provider that is evaluated at each assembly - * call (so it can reflect the live registry state). The provider is - * removed when the calling fiber is disposed. A provider must not return a - * schema named {@link TOOL_ORDER_REST}; that name is reserved for + * Contribute a tool-schema provider, evaluated at each assembly call with + * that assembly's {@link AssembleContext} (so it reflects the live registry + * state AND the assembly's scope — see {@link ToolProviderResult} for the + * `schemas`/`knownNames` split). The layer is decided by the calling + * context: a scoped provider (registered through `agent.ctx`) is consulted + * only for that scope's assemblies. Removed when the calling fiber is + * disposed. A provider must not return a schema named + * {@link TOOL_ORDER_REST}; that name is reserved for * {@link Config.toolOrder}'s rest entry and rejects the assembly. Emits * `system-prompt/change`. * @param provider - evaluated at every {@link assemble} for fresh schemas. * @returns the disposer that removes the provider. */ - tools(provider: () => ToolSchema[]): () => void { + tools(provider: (context: AssembleContext) => ToolProviderResult): () => void { + const scope = scopeOf(this.ctx) const dispose = this.ctx.effect(function* (this: SystemPrompt) { - this.toolProviders.push(provider) + const layer = scope === undefined + ? this.toolProviders + : this.scopedToolProviders.get(scope) ?? (() => { + const created: ((context: AssembleContext) => ToolProviderResult)[] = [] + this.scopedToolProviders.set(scope, created) + return created + })() + layer.push(provider) // Yield the rollback BEFORE emitting `system-prompt/change` (see section()). yield () => { - const index = this.toolProviders.indexOf(provider) + const index = layer.indexOf(provider) /* v8 ignore next 3 -- defensive: provider was registered, so indexOf is guaranteed >= 0 */ - if (index >= 0) this.toolProviders.splice(index, 1) + if (index >= 0) layer.splice(index, 1) + if (scope !== undefined && layer.length === 0) this.scopedToolProviders.delete(scope) this.ctx.emit('system-prompt/change') } this.ctx.emit('system-prompt/change') @@ -392,26 +470,40 @@ export class SystemPrompt extends Service { * `{{name}}`. The provider is evaluated at each assembly with that * assembly's {@link AssembleContext}; returning `undefined` means "no value * for this assembly" (a section referencing it then fails to render — a - * deployment must not claim facts it does not have). Throws on a name that - * does not match `[a-z][a-z0-9_]*` (it could never be referenced) or is - * already registered. Removed when the calling fiber is disposed; emits - * `system-prompt/change` on register/unregister. + * deployment must not claim facts it does not have). The layer is decided + * by the calling context: a scoped variable (registered through + * `agent.ctx`) resolves only for that scope's assemblies and SHADOWS a + * same-named global variable there. Throws on a name that does not match + * `[a-z][a-z0-9_]*` (it could never be referenced) or one already + * registered in the SAME layer. Removed when the calling fiber is disposed; + * emits `system-prompt/change` on register/unregister. * @param name - the reference name (matches `[a-z][a-z0-9_]*`). * @param provider - evaluated at every {@link assemble} for the value. * @returns the disposer that removes the variable. */ variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void { + const scope = scopeOf(this.ctx) const dispose = this.ctx.effect(function* (this: SystemPrompt) { if (!VARIABLE_NAME.test(name)) { throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`) } - if (this.variableProviders.has(name)) { - throw new Error(`prompt variable "${name}" is already registered`) + const layer = scope === undefined + ? this.variableProviders + : this.scopedVariableProviders.get(scope) ?? (() => { + const created = new Map string | undefined>() + this.scopedVariableProviders.set(scope, created) + return created + })() + if (layer.has(name)) { + throw new Error(scope === undefined + ? `prompt variable "${name}" is already registered (for a per-agent value, register through that agent's \`agent.ctx\` instead)` + : `prompt variable "${name}" is already registered in this scope`) } - this.variableProviders.set(name, provider) + layer.set(name, provider) // Yield the rollback BEFORE emitting `system-prompt/change` (see section()). yield () => { - this.variableProviders.delete(name) + layer.delete(name) + if (scope !== undefined && layer.size === 0) this.scopedVariableProviders.delete(scope) this.ctx.emit('system-prompt/change') } this.ctx.emit('system-prompt/change') @@ -422,13 +514,17 @@ export class SystemPrompt extends Service { } /** - * Assemble the current prompt for one caller: section texts are resolved - * against `context` and sorted by order, tools collected from all providers - * and put in the canonical model-facing order ({@link Config.toolOrder}, or - * lexicographic name order when unconfigured — provider registration order - * is a plugin-load artifact and never reaches the assembly; a configured - * order naming a tool no provider contributed rejects the assembly), and every - * registered variable resolved against `context` into `assembly.variables`. + * Assemble the current prompt for one caller: the global layer merged with + * {@link AssembleContext.scope}'s layer (scoped sections/variables SHADOW + * same-named global ones — most-specific-wins) — section texts resolved + * against `context` and sorted by order across the union, tools collected + * from the global providers plus the scope's and put in the canonical + * model-facing order ({@link Config.toolOrder}, or lexicographic name order + * when unconfigured — provider registration order is a plugin-load artifact + * and never reaches the assembly; a configured order naming a tool outside + * the providers' `knownNames` universe rejects the assembly, while a known + * name restricted away for this scope is a normal absence), and every + * visible variable resolved against `context` into `assembly.variables`. * Tool schemas are deep-cloned because adapters and request waterfalls may * mutate schema objects. Runs through the `system-prompt/assemble` * waterfall, giving listeners the opportunity to mutate or replace the @@ -445,25 +541,59 @@ export class SystemPrompt extends Service { // rejection: a Promise-returning method must not throw synchronously // (`assemble().catch(...)` would miss it). async assemble(context: AssembleContext = {}): Promise { + const scope = context.scope + // Variables: global layer first, then the scope's layer OVERWRITES + // same-named entries (shadowing — a per-agent value wins for that agent). const variables: Record = {} for (const [name, provider] of this.variableProviders) { variables[name] = provider(context) } + const scopedVariables = scope === undefined ? undefined : this.scopedVariableProviders.get(scope) + for (const [name, provider] of scopedVariables ?? []) { + variables[name] = provider(context) + } + // Sections: merge by name, scoped REPLACING same-named global entries + // (most-specific-wins — the per-agent persona mechanism), then sort by + // order across the union. Registration order within a layer is preserved + // for equal orders (stable sort). + const sectionByName = new Map() + for (const section of this.sections) sectionByName.set(section.name, section) + for (const section of (scope === undefined ? [] : this.scopedSections.get(scope)) ?? []) { + sectionByName.set(section.name, section) + } + // Tools: consult the global providers plus the scope's, each with this + // assembly's context. `schemas` are what the model may see (already + // post-restriction, per provider); `knownNames` (defaulting to the + // schemas' names) form the pre-restriction universe `toolOrder` is + // validated against, so a restricted-away tool is a normal absence while + // a config typo still fails every assembly loudly. + const providers = [ + ...this.toolProviders, + ...(scope === undefined ? [] : this.scopedToolProviders.get(scope)) ?? [], + ] + const collected: ToolSchema[] = [] + const knownNames = new Set() + for (const provider of providers) { + const result = provider(context) + for (const tool of result.schemas) { + collected.push({ ...tool, parameters: structuredClone(tool.parameters) }) + } + for (const name of result.knownNames ?? result.schemas.map(tool => tool.name)) { + knownNames.add(name) + } + } const assembly: PromptAssembly = { - sections: this.sections + sections: [...sectionByName.values()] .map(section => ({ name: section.name, order: section.order, text: typeof section.text === 'function' ? section.text(context) : section.text, })) .sort((a, b) => a.order - b.order), - tools: orderTools( - this.toolProviders.flatMap(provider => - provider().map(tool => ({ ...tool, parameters: structuredClone(tool.parameters) }))), - this.toolOrder), + tools: orderTools(collected, this.toolOrder, knownNames), variables, } - return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly)) + return this.ctx.waterfall(scopeTarget(this, scope), 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly)) } } diff --git a/packages/core/system-prompt/tests/scoped.spec.ts b/packages/core/system-prompt/tests/scoped.spec.ts new file mode 100644 index 0000000000..9c448bc2ac --- /dev/null +++ b/packages/core/system-prompt/tests/scoped.spec.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { createScope, scopeOf } from '@deepseek-ai/dsh-scope' +import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope' +import SystemPrompt, { TOOL_ORDER_REST, renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import type { Config, PromptAssembly } from '@deepseek-ai/dsh-system-prompt' + +async function mount(config: Config = {}): Promise { + const ctx = new Context() + await ctx.plugin(SystemPrompt, config) + return ctx +} + +async function mintScope(ctx: Context, name: string): Promise { + let scope!: Scope + // The scoped context resolves services through the MINTING plugin's + // dependency chain — the minter must inject what scope holders will reach. + await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, { name }) }, + { inject: ['systemPrompt'] })) + return scope +} + +const schema = (name: string) => ({ name, description: `tool ${name}`, parameters: {} }) + +/** The key a test scope was minted with (scopeOf over the scope's own ctx). */ +function scopeKeyOf(scope: Scope): ScopeKey { + // scopeOf never answers undefined for a context the scope itself minted. + + return scopeOf(scope.ctx)! +} + +describe('scoped sections', () => { + it('a scoped persona shadows deployment:persona for that scope only (either order)', async () => { + const ctx = await mount({ persona: 'You are the deployment.' }) + const scope = await mintScope(ctx, 'child') + scope.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' }) + + const scoped = renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })) + const global = renderPrompt(await ctx.systemPrompt.assemble()) + expect(scoped).toContain('You run tests.') + expect(scoped).not.toContain('You are the deployment.') + expect(global).toContain('You are the deployment.') + expect(global).not.toContain('You run tests.') + }) + + it('scoped-only sections join that scope alone; disposal removes them', async () => { + const ctx = await mount() + const scope = await mintScope(ctx, 'child') + scope.ctx.systemPrompt.section({ name: 'child:extra', order: 50, text: 'Extra guidance.' }) + + expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }))).toContain('Extra guidance.') + expect(renderPrompt(await ctx.systemPrompt.assemble())).not.toContain('Extra guidance.') + await scope.dispose() + expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }))).not.toContain('Extra guidance.') + }) + + it('duplicate names throw per layer, naming agent.ctx for the global case', async () => { + const ctx = await mount() + const scope = await mintScope(ctx, 'child') + ctx.systemPrompt.section({ name: 'x', order: 1, text: 'a' }) + expect(() => ctx.systemPrompt.section({ name: 'x', order: 1, text: 'b' })).toThrow(/agent\.ctx/) + scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'a' }) + expect(() => scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'b' })).toThrow(/already registered in this scope/) + }) +}) + +describe('scoped variables', () => { + it('a scoped variable shadows its global name-twin for that scope', async () => { + const ctx = await mount({ persona: 'Mode: {{mode}}.' }) + const scope = await mintScope(ctx, 'child') + ctx.systemPrompt.variable('mode', () => 'normal') + scope.ctx.systemPrompt.variable('mode', () => 'strict') + + expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }))).toContain('Mode: strict.') + expect(renderPrompt(await ctx.systemPrompt.assemble())).toContain('Mode: normal.') + }) + + it('same-layer duplicates throw; scoped layer cleans up on dispose', async () => { + const ctx = await mount() + const scope = await mintScope(ctx, 'child') + scope.ctx.systemPrompt.variable('v', () => '1') + expect(() => scope.ctx.systemPrompt.variable('v', () => '2')).toThrow(/already registered in this scope/) + await scope.dispose() + // Re-minting a scope with the SAME key starts clean. + const again = await mintScope(ctx, 'child2') + again.ctx.systemPrompt.variable('v', () => '3') + }) +}) + +describe('scoped tool providers and toolOrder × restriction', () => { + it('scoped providers are consulted only for their scope', async () => { + const ctx = await mount() + const scope = await mintScope(ctx, 'child') + ctx.systemPrompt.tools(() => ({ schemas: [schema('global_tool')] })) + scope.ctx.systemPrompt.tools(() => ({ schemas: [schema('scoped_tool')] })) + + const scoped = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }) + const global = await ctx.systemPrompt.assemble() + expect(scoped.tools.map(t => t.name)).toEqual(['global_tool', 'scoped_tool']) + expect(global.tools.map(t => t.name)).toEqual(['global_tool']) + }) + + it('a toolOrder entry restricted away for a scope is a normal absence, while a typo still throws', async () => { + const ctx = await mount({ toolOrder: ['bash', TOOL_ORDER_REST] }) + // A provider mimicking the registry's restriction split: bash exists + // (knownNames) but is masked for this assembly (schemas). + ctx.systemPrompt.tools(() => ({ + schemas: [schema('read')], + knownNames: ['read', 'bash'], + })) + const assembly = await ctx.systemPrompt.assemble() + expect(assembly.tools.map(t => t.name)).toEqual(['read']) + + const bad = await mount({ toolOrder: ['basj', TOOL_ORDER_REST] }) + bad.systemPrompt.tools(() => ({ schemas: [schema('read')], knownNames: ['read', 'bash'] })) + await expect(bad.systemPrompt.assemble()).rejects.toThrow('toolOrder lists unregistered tool "basj"; known tools: bash, read') + }) +}) + +describe('scoped assemble dispatch', () => { + it('an agent.ctx assemble listener shapes only its own scope\'s assemblies', async () => { + const ctx = await mount() + const scope = await mintScope(ctx, 'child') + const shaped: (ScopeKey | undefined)[] = [] + scope.ctx.on('system-prompt/assemble', async (_assembly: PromptAssembly, context, next: () => Promise) => { + shaped.push(context.scope) + const result = await next() + result.sections.push({ name: 'listener:extra', order: 999, text: 'listener text' }) + return result + }) + + const scoped = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }) + const global = await ctx.systemPrompt.assemble() + expect(scoped.sections.some(s => s.name === 'listener:extra')).toBe(true) + expect(global.sections.some(s => s.name === 'listener:extra')).toBe(false) + expect(shaped).toHaveLength(1) + }) +}) diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index c0bd8be6d2..560a640643 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -52,7 +52,7 @@ describe('SystemPrompt', () => { ctx.systemPrompt.section({ name: 'cwd', order: 20, text: () => 'cwd: /tmp' }) ctx.systemPrompt.section({ name: 'rules', order: 10, text: 'Be precise.' }) - ctx.systemPrompt.tools(() => [{ name: 'echo', description: 'echo back', parameters: {} }]) + ctx.systemPrompt.tools(() => ({ schemas: [{ name: 'echo', description: 'echo back', parameters: {} }] })) const assembly = await ctx.systemPrompt.assemble() expect(assembly.sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'rules', 'cwd']) @@ -84,7 +84,7 @@ describe('SystemPrompt', () => { const fiber = await ctx.plugin(Object.assign((inner: Context) => { inner.systemPrompt.section({ name: 'scoped', order: 0, text: 'scoped section' }) - inner.systemPrompt.tools(() => [{ name: 'scoped-tool', description: '', parameters: {} }]) + inner.systemPrompt.tools(() => ({ schemas: [{ name: 'scoped-tool', description: '', parameters: {} }] })) inner.systemPrompt.variable('scoped_var', () => 'v') }, { inject: ['systemPrompt'] })) @@ -141,11 +141,11 @@ describe('SystemPrompt', () => { if (!threw) { threw = true; throw new Error('boom change listener') } }) - expect(() => ctx.systemPrompt.tools(() => [{ name: 't', description: '', parameters: {} }])).toThrow('boom change listener') + expect(() => ctx.systemPrompt.tools(() => ({ schemas: [{ name: 't', description: '', parameters: {} }] }))).toThrow('boom change listener') expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(0) // nothing leaked off() - ctx.systemPrompt.tools(() => [{ name: 't', description: '', parameters: {} }]) + ctx.systemPrompt.tools(() => ({ schemas: [{ name: 't', description: '', parameters: {} }] })) expect((await ctx.systemPrompt.assemble()).tools.map(t => t.name)).toEqual(['t']) }) @@ -209,7 +209,7 @@ describe('SystemPrompt', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) ctx.systemPrompt.section({ name: 'base', order: 0, text: 'base' }) - ctx.systemPrompt.tools(() => [{ name: 't', description: 'tool', parameters: { type: 'object', properties: {} } }]) + ctx.systemPrompt.tools(() => ({ schemas: [{ name: 't', description: 'tool', parameters: { type: 'object', properties: {} } }] })) const first = await ctx.systemPrompt.assemble() first.sections[0]!.name = 'mutated' @@ -243,7 +243,7 @@ describe('SystemPrompt', () => { let changeCount = 0 ctx.on('system-prompt/change', () => void changeCount++) - const dispose = ctx.systemPrompt.tools(() => []) + const dispose = ctx.systemPrompt.tools(() => ({ schemas: [] })) // registration emits change expect(changeCount).toBe(1) @@ -257,7 +257,7 @@ describe('SystemPrompt', () => { await ctx.plugin(SystemPrompt) const fiber = await ctx.plugin(Object.assign((inner: Context) => { - inner.systemPrompt.tools(() => [{ name: 'fiber-tool', description: '', parameters: {} }]) + inner.systemPrompt.tools(() => ({ schemas: [{ name: 'fiber-tool', description: '', parameters: {} }] })) }, { inject: ['systemPrompt'] })) expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(1) @@ -280,7 +280,7 @@ describe('SystemPrompt', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) - const dispose = ctx.systemPrompt.tools(() => [{ name: 'direct-tool', description: '', parameters: {} }]) + const dispose = ctx.systemPrompt.tools(() => ({ schemas: [{ name: 'direct-tool', description: '', parameters: {} }] })) expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(1) dispose() diff --git a/packages/core/system-prompt/tests/tool-order.spec.ts b/packages/core/system-prompt/tests/tool-order.spec.ts index 02cc99b2d7..16eff6e354 100644 --- a/packages/core/system-prompt/tests/tool-order.spec.ts +++ b/packages/core/system-prompt/tests/tool-order.spec.ts @@ -26,39 +26,39 @@ describe('SystemPrompt tool order', () => { it('assembles tools in lexicographic name order when no toolOrder is configured', async () => { const ctx = await mount() - ctx.systemPrompt.tools(() => [tool('charlie'), tool('alpha')]) - ctx.systemPrompt.tools(() => [tool('bravo')]) + ctx.systemPrompt.tools(() => ({ schemas: [tool('charlie'), tool('alpha')] })) + ctx.systemPrompt.tools(() => ({ schemas: [tool('bravo')] })) expect(names(await ctx.systemPrompt.assemble())).toEqual(['alpha', 'bravo', 'charlie']) }) it('assembles the same order regardless of provider registration order', async () => { const forward = await mount() - forward.systemPrompt.tools(() => [tool('alpha')]) - forward.systemPrompt.tools(() => [tool('zulu')]) + forward.systemPrompt.tools(() => ({ schemas: [tool('alpha')] })) + forward.systemPrompt.tools(() => ({ schemas: [tool('zulu')] })) const backward = await mount() - backward.systemPrompt.tools(() => [tool('zulu')]) - backward.systemPrompt.tools(() => [tool('alpha')]) + backward.systemPrompt.tools(() => ({ schemas: [tool('zulu')] })) + backward.systemPrompt.tools(() => ({ schemas: [tool('alpha')] })) expect(names(await forward.systemPrompt.assemble())).toEqual(['alpha', 'zulu']) expect(names(await backward.systemPrompt.assemble())).toEqual(['alpha', 'zulu']) }) it('applies a configured toolOrder: listed positions, rest at the rest entry lexicographically', async () => { const ctx = await mount({ toolOrder: ['todo_write', TOOL_ORDER_REST, 'bash'] }) - ctx.systemPrompt.tools(() => [tool('bash'), tool('echo_b'), tool('todo_write'), tool('echo_a')]) + ctx.systemPrompt.tools(() => ({ schemas: [tool('bash'), tool('echo_b'), tool('todo_write'), tool('echo_a')] })) expect(names(await ctx.systemPrompt.assemble())).toEqual(['todo_write', 'echo_a', 'echo_b', 'bash']) }) it('rejects the assembly when toolOrder names a tool that is not registered (misconfiguration blocks work)', async () => { const ctx = await mount({ toolOrder: ['todo_write', 'ghost', TOOL_ORDER_REST, 'wraith'] }) - ctx.systemPrompt.tools(() => [tool('bash'), tool('todo_write')]) + ctx.systemPrompt.tools(() => ({ schemas: [tool('bash'), tool('todo_write')] })) await expect(ctx.systemPrompt.assemble()).rejects.toThrow( - 'toolOrder lists unregistered tools "ghost", "wraith"; registered tools: bash, todo_write') + 'toolOrder lists unregistered tools "ghost", "wraith"; known tools: bash, todo_write') }) it('names the single unregistered tool when no tools are registered at all', async () => { const ctx = await mount({ toolOrder: ['ghost', TOOL_ORDER_REST] }) await expect(ctx.systemPrompt.assemble()).rejects.toThrow( - 'toolOrder lists unregistered tool "ghost"; registered tools: (none)') + 'toolOrder lists unregistered tool "ghost"; known tools: (none)') }) it.each([ @@ -66,21 +66,21 @@ describe('SystemPrompt tool order', () => { ['with only the rest entry configured', [TOOL_ORDER_REST]], ])('rejects a provider tool named like the reserved rest entry %s', async (_case, toolOrder) => { const ctx = await mount(toolOrder === undefined ? {} : { toolOrder }) - ctx.systemPrompt.tools(() => [tool(TOOL_ORDER_REST)]) + ctx.systemPrompt.tools(() => ({ schemas: [tool(TOOL_ORDER_REST)] })) await expect(ctx.systemPrompt.assemble()).rejects.toThrow( `tool provider returned reserved tool name "${TOOL_ORDER_REST}"`) }) it('keeps collection order between tools that share a name (stable sort)', async () => { const ctx = await mount() - ctx.systemPrompt.tools(() => [tool('dup', 'first'), tool('anchor'), tool('dup', 'second')]) + ctx.systemPrompt.tools(() => ({ schemas: [tool('dup', 'first'), tool('anchor'), tool('dup', 'second')] })) const assembly = await ctx.systemPrompt.assemble() expect(assembly.tools.map(t => t.description)).toEqual(['anchor', 'first', 'second']) }) it('canonicalizes BEFORE the assemble waterfall: listeners see the ordered list and own their own edits', async () => { const ctx = await mount() - ctx.systemPrompt.tools(() => [tool('zulu'), tool('alpha')]) + ctx.systemPrompt.tools(() => ({ schemas: [tool('zulu'), tool('alpha')] })) let seen: string[] | undefined ctx.on('system-prompt/assemble', function (assembly, _context, next) { seen = assembly.tools.map(t => t.name) diff --git a/packages/core/system-prompt/tsconfig.json b/packages/core/system-prompt/tsconfig.json index e9de391ba1..91e7bf1ba4 100644 --- a/packages/core/system-prompt/tsconfig.json +++ b/packages/core/system-prompt/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../llm/llm" + }, + { + "path": "../../core/scope" } ] } diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index a6d3bbe0ca..377b522490 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -24,12 +24,14 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "cordis": "^4.0.0-rc.6" } diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 39dafd6f1a..cfbeffbd57 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -9,6 +9,8 @@ */ import { Context, Service } from 'cordis' +import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' +import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope' import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { Agent, HookContext } from '@deepseek-ai/dsh-agent' @@ -70,10 +72,14 @@ declare module 'cordis' { * tool body never runs. Input rewrite is deliberately NOT offered here (see * {@link PreToolDecision}); `ask` degrades to deny until the permission * system lands (`FIXME(permissions)`). + * 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). * @param exec - the pending call (name, parsed arguments, caller agent). * @mode waterfall */ - 'tools/pre-execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise): Promise + 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise /** * Waterfall AFTER a tool runs — where hook plugins inspect the result and * accept it (optionally REPLACING the model-facing content, and/or attaching @@ -85,13 +91,22 @@ declare module 'cordis' { * `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). * @param exec - the call that just ran (name, parsed arguments, caller agent). * @param result - the dispatch outcome a listener may accept, replace, or block. * @mode waterfall */ - 'tools/post-execute'(this: ToolRegistry, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise): Promise + 'tools/post-execute'(this: Scoped, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise): Promise /** - * A tool was registered or unregistered (the available tool set changed). + * A tool was registered or unregistered, or a scoped restriction changed + * (the available tool set 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. * @mode emit */ 'tools/change'(): void @@ -269,44 +284,88 @@ function errorInfo(error: unknown): ToolErrorInfo | undefined { return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined } +/** + * A per-scope restriction over the GLOBAL tool surface, registered via + * {@link ToolRegistry.restrict}. `allow` keeps only the listed global tools; + * `deny` removes the listed ones; both present = allow first, then deny. + * Restrictions never touch scoped registrations — a tool registered through + * the same scope is an explicit grant that bypasses them (which is what keeps + * e.g. a structured-output capture tool alive under an allow-list). Multiple + * restrictions on one scope compose by intersection: every one must admit. + */ +export interface ToolRestriction { + /** Global tool names that stay visible; everything else is removed. */ + allow?: string[] + /** Global tool names removed from visibility. */ + deny?: string[] +} + /** * Tool registry (`ctx.tools`): tool plugins register definitions; the agent * loop executes calls through the `tools/pre-execute` → dispatch → * `tools/post-execute` pipeline. The registry contributes its schemas into the * system-prompt assembly. + * + * 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). {@link restrict} masks the global layer per + * scope. One visibility function ({@link visible}) feeds prompt assembly, + * {@link get}, and {@link execute}, so what the model is shown, what a + * presenter renders, and what dispatches can never disagree. */ export class ToolRegistry extends Service { static inject = ['systemPrompt'] - private store = new Map() + private global = new Map() + private scoped = new Map>() + /** Snapshot-at-registration restriction filters, per scope (see {@link restrict}). */ + private restrictions = new Map() constructor(ctx: Context) { super(ctx, 'tools') - ctx.systemPrompt.tools(() => this.schemas()) + ctx.systemPrompt.tools(context => ({ + schemas: this.schemas(context.scope), + knownNames: this.knownNames(context.scope), + })) } /** - * Register a tool. Throws if a tool with the same name is already - * registered. The tool's schema (minus the `execute` function) is - * automatically contributed to the system-prompt assembly. Disposed - * with the calling fiber. Emits `tools/change` on register/unregister. + * Register a tool. The layer is decided by the CALLING context: a plain + * plugin context registers globally; a scoped context (`agent.ctx`) + * registers into that scope's layer — visible to that agent alone, disposed + * with the scope, and shadowing a same-named global tool for that agent. + * Throws if the SAME layer already has the name (cross-layer name twins are + * the shadowing feature, not an error; the global-duplicate message names + * `agent.ctx` as the per-agent alternative). The visible schema set flows + * into prompt assembly automatically. Disposed with the calling fiber. + * Emits `tools/change` on register/unregister. * @param definition - the tool's schema plus its execute (and optional * presentation) functions. * @returns the disposer that unregisters the tool. */ register(definition: ToolDefinition): () => void { + const scope = scopeOf(this.ctx) const dispose = this.ctx.effect(function* (this: ToolRegistry) { - if (this.store.has(definition.name)) { - throw new Error(`tool "${definition.name}" is already registered`) + const layer = scope === undefined ? this.global : this.layerFor(scope) + if (layer.has(definition.name)) { + throw new Error(scope === undefined + ? `tool "${definition.name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)` + : `tool "${definition.name}" is already registered in this scope`) } - this.store.set(definition.name, definition) + layer.set(definition.name, definition) // Yield the rollback BEFORE emitting `tools/change`: a generator effect // collects each yielded disposer before the next step runs, so a throwing // `tools/change` listener removes the tool instead of leaking it (a leak // would wedge the duplicate-name check until restart). The duplicate // throw above fires before any mutation — it leaks nothing. yield () => { - this.store.delete(definition.name) + layer.delete(definition.name) + // An emptied scope layer is dropped so a disposed scope leaves no + // residue keyed by its (dead) key. + if (scope !== undefined && layer.size === 0) this.scoped.delete(scope) this.ctx.emit('tools/change') } this.ctx.emit('tools/change') @@ -317,33 +376,150 @@ export class ToolRegistry extends Service { } /** - * Look up a registered tool. - * @param name - the tool name as registered. - * @returns the definition, or undefined when no tool has that name. + * Restrict the GLOBAL tool surface for the calling scope. Must be called + * through a scoped context (`agent.ctx`) — restricting "everyone" is not a + * thing (throw), and an empty filter (neither `allow` nor `deny`) is a no-op + * that can only be a bug (throw — the materialized-empty-config trap). + * Validates every listed name against the scope's CURRENT pre-restriction + * name universe ({@link knownNames}) and throws on an unknown one (fail loud + * beats a typo silently filtering nothing) — register restrictions after the + * global tools they mask exist (the agent-creation `setup` window satisfies + * this). The filter is SNAPSHOT at registration: later caller mutation of + * the arrays changes nothing. Multiple restrictions compose by intersection. + * Scoped registrations bypass restrictions (explicit grants win). Disposed + * with the calling fiber (revocable independently); emits `tools/change`. + * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove). + * @returns the disposer that lifts this restriction. */ - get(name: string): ToolDefinition | undefined { - return this.store.get(name) + restrict(filter: ToolRestriction): () => void { + const scope = scopeOf(this.ctx) + if (scope === undefined) { + throw new Error('tools.restrict() requires a scoped context (agent.ctx): a context-global restriction would mask every agent — deny the tool for the intended agent instead') + } + if (filter.allow === undefined && filter.deny === undefined) { + throw new Error('tools.restrict({}) is a no-op: pass `allow` and/or `deny` (an empty filter is almost always a materialized-empty-config bug)') + } + // Snapshot BEFORE validation so what was checked is what is enforced. + const snapshot: ToolRestriction = { + ...filter.allow !== undefined ? { allow: [...filter.allow] } : {}, + ...filter.deny !== undefined ? { deny: [...filter.deny] } : {}, + } + const known = new Set(this.knownNames(scope)) + const unknown = [...snapshot.allow ?? [], ...snapshot.deny ?? []].filter(name => !known.has(name)) + if (unknown.length > 0) { + throw new Error(`tools.restrict() names unknown tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known tools for this scope: ${[...known].sort().join(', ') || '(none)'}`) + } + const dispose = this.ctx.effect(function* (this: ToolRegistry) { + const list = this.restrictions.get(scope) ?? [] + this.restrictions.set(scope, list) + list.push(snapshot) + yield () => { + const index = list.indexOf(snapshot) + /* v8 ignore next 3 -- defensive: the snapshot was pushed, so indexOf is guaranteed >= 0 */ + if (index >= 0) list.splice(index, 1) + if (list.length === 0) this.restrictions.delete(scope) + this.ctx.emit('tools/change') + } + this.ctx.emit('tools/change') + }.bind(this), 'tools.restrict()') + // ctx.effect's disposer returns Promise; our disposer API is + // synchronous fire-and-forget — discard the (always-resolved) promise. + return () => void dispose() + } + + /** The (created-on-demand) scoped layer for `scope`. */ + private layerFor(scope: ScopeKey): Map { + let layer = this.scoped.get(scope) + if (!layer) { + layer = new Map() + this.scoped.set(scope, layer) + } + return layer + } + + /** Whether every restriction registered for `scope` admits the global tool `name` (intersection semantics). */ + private admits(scope: ScopeKey | undefined, name: string): boolean { + if (scope === undefined) return true + const filters = this.restrictions.get(scope) + if (!filters) return true + return filters.every(filter => + (filter.allow === undefined || filter.allow.includes(name)) + && (filter.deny === undefined || !filter.deny.includes(name))) } /** - * Return all registered tool schemas — exactly the model-facing fields - * (`name`, `description`, `parameters`), as sent to the model via the + * THE visibility function — one resolution feeding prompt assembly, + * {@link get}, and {@link execute}: the global layer masked by the scope's + * restrictions, unioned with the scope's own layer, scoped shadowing global + * on a name conflict. No scope = the unrestricted global view. + * @param scope - the viewing scope (the agent), or undefined for the global view. + * @returns the visible definitions (scoped shadows applied), in per-layer + * registration order, global layer first. + */ + visible(scope?: ScopeKey): ToolDefinition[] { + const layer = scope === undefined ? undefined : this.scoped.get(scope) + const result = new Map() + for (const [name, definition] of this.global) { + if (this.admits(scope, name)) result.set(name, definition) + } + // Scoped layer second: same-name entries REPLACE (shadow) the global ones, + // and grants bypass restrictions by construction (never filtered above). + for (const [name, definition] of layer ?? []) result.set(name, definition) + return [...result.values()] + } + + /** + * Look up a tool as one scope sees it ({@link visible} semantics: scoped + * shadows global; a restricted-away global reads as absent). Presenters pass + * the calling agent so the rendered card matches the definition that + * actually executed. + * @param name - the tool name as registered. + * @param scope - the viewing scope (the agent); omitted = the global view. + * @returns the definition the scope resolves, or undefined when none is visible. + */ + get(name: string, scope?: ScopeKey): ToolDefinition | undefined { + const shadowed = scope === undefined ? undefined : this.scoped.get(scope)?.get(name) + if (shadowed) return shadowed + if (!this.admits(scope, name)) return undefined + return this.global.get(name) + } + + /** + * The model-facing schemas of everything `scope` can see — exactly the + * fields (`name`, `description`, `parameters`) sent to the model via the * system-prompt assembly. Constructed EXPLICITLY rather than by stripping * known non-schema members: a `ToolDefinition` also carries `execute` and the * optional `presentCall`/`presentResult` UI callbacks, and those (especially * the functions) must never leak into a model request. An allowlist can't * drift when a new non-schema member is added to the definition; a denylist * (rest-destructure) would silently leak it. - * @returns one deep-cloned schema per registered tool, in registration order. + * @param scope - the viewing scope (the agent); omitted = the global view. + * @returns one deep-cloned schema per visible tool. */ - schemas(): ToolSchema[] { - return [...this.store.values()].map(({ name, description, parameters }): ToolSchema => ({ + schemas(scope?: ScopeKey): ToolSchema[] { + return this.visible(scope).map(({ name, description, parameters }): ToolSchema => ({ name, description, parameters: structuredClone(parameters), })) } + /** + * The PRE-restriction name universe for `scope`: every global name plus the + * scope's own layer, ignoring restrictions. This is the set configuration + * (`toolOrder`, `restrict()` filters) validates against, so a typo fails + * loud while a restricted-away tool remains a normal, non-erroneous absence. + * @param scope - the viewing scope (the agent); omitted = global names only. + * @returns the known names, deduplicated. + */ + knownNames(scope?: ScopeKey): string[] { + const names = new Set(this.global.keys()) + if (scope !== undefined) { + for (const name of this.scoped.get(scope)?.keys() ?? []) names.add(name) + } + return [...names] + } + /** * Execute one tool call through the `tools/pre-execute` → dispatch → * `tools/post-execute` pipeline. The two waterfalls are the gate (allow/deny) @@ -362,9 +538,12 @@ export class ToolRegistry extends Service { async execute(exec: ToolExecution): Promise { try { // --- Gate: tools/pre-execute. A deny (or an ask, which degrades to deny - // until the permission system lands) skips dispatch entirely. --- + // until the permission system lands) skips dispatch entirely. The + // carrier keys the dispatch by exec.agent, so an `agent.ctx` listener + // gates only its own agent's calls (agent-less calls are subject-less). + const carrier = scopeTarget(this, exec.agent) const decision = await this.ctx.waterfall( - this, 'tools/pre-execute', exec, + carrier, 'tools/pre-execute', exec, () => Promise.resolve({ kind: 'allow' }), ) if (decision.kind !== 'allow') { @@ -387,7 +566,11 @@ export class ToolRegistry extends Service { // inspect it; an unknown tool routes through the same catch. --- let result: ToolExecutionResult try { - const tool = this.store.get(exec.name) + // Resolve through the CALLER's visible view ({@link get}): a scoped + // tool shadows its global name-twin for that agent, and a + // restricted-away global tool is exactly as absent as a nonexistent + // one — same UNKNOWN_TOOL result, no capability leak in the error. + const tool = this.get(exec.name, exec.agent) if (!tool) throw new ToolNotFoundError(exec.name) // Normalize the two `execute` return shapes: a bare ContentBlock[] (no // meta) or a { content, meta } object (a tool attaching a private @@ -435,7 +618,7 @@ export class ToolRegistry extends Service { ...result.meta !== undefined ? { meta: result.meta } : {}, } const decision = await this.ctx.waterfall( - this, 'tools/post-execute', exec, result, + scopeTarget(this, exec.agent), 'tools/post-execute', exec, result, () => Promise.resolve({ kind: 'accept' }), ) const additionalContext = decision.additionalContext diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts new file mode 100644 index 0000000000..027b5798de --- /dev/null +++ b/packages/core/tools/tests/scoped.spec.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { createScope } from '@deepseek-ai/dsh-scope' +import type { Scope } from '@deepseek-ai/dsh-scope' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import type { PreToolDecision, ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools' +import type { Agent, AgentId } from '@deepseek-ai/dsh-agent' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' + +/** Mount the registry (with its systemPrompt dependency) on a fresh context. */ +async function mount(): Promise { + const ctx = new Context() + await ctx.plugin(SystemPrompt, {}) + await ctx.plugin(ToolRegistry) + return ctx +} + +/** Mint a scope whose key doubles as a minimal Agent-like object. */ +async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; key: Agent }> { + const key = { id: name as AgentId } as Agent + let scope!: Scope + // The scoped context resolves services through the MINTING plugin's + // dependency chain — the minter must inject what scope holders will reach + // (in production the agent loop's inject list plays this role). + await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, key) }, + { inject: ['tools', 'systemPrompt'] })) + return { scope, key } +} + +function tool(name: string, reply = `ran:${name}`): ToolDefinition { + return { + name, + description: `tool ${name}`, + parameters: { type: 'object', properties: {} }, + execute: (): Promise => Promise.resolve([{ type: 'text', text: reply }]), + } +} + +async function run(ctx: Context, name: string, agent?: Agent): Promise { + const result = await ctx.tools.execute({ + callId: CallId('c1'), + name, + arguments: {}, + ...agent ? { agent } : {}, + }) + const first = result.content[0] + return first?.type === 'text' ? first.text : JSON.stringify(result.content) +} + +describe('scoped tool registration', () => { + it('files a scoped tool in its layer: visible/executable for that scope only', async () => { + const ctx = await mount() + const { scope, key } = await mintAgentScope(ctx, 'a') + const other = { id: 'other' as AgentId } as Agent + ctx.tools.register(tool('shared')) + scope.ctx.tools.register(tool('mine')) + + expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['mine', 'shared']) + expect(ctx.tools.schemas().map(t => t.name)).toEqual(['shared']) + expect(ctx.tools.schemas(other).map(t => t.name)).toEqual(['shared']) + + expect(await run(ctx, 'mine', key)).toBe('ran:mine') + // Out-of-view execution is indistinguishable from a nonexistent tool. + expect(await run(ctx, 'mine', other)).toBe('Error: unknown tool "mine"') + expect(await run(ctx, 'mine')).toBe('Error: unknown tool "mine"') + }) + + it('scoped shadows global on a name conflict, in either registration order', async () => { + const ctx = await mount() + const { scope, key } = await mintAgentScope(ctx, 'a') + // scoped-then-global + scope.ctx.tools.register(tool('bash', 'restricted-bash')) + ctx.tools.register(tool('bash', 'global-bash')) + expect(await run(ctx, 'bash', key)).toBe('restricted-bash') + expect(await run(ctx, 'bash')).toBe('global-bash') + expect(ctx.tools.get('bash', key)?.description).toBe(ctx.tools.get('bash', key)?.description) + // Exactly one 'bash' in the scope's schema view (the shadow, not a double). + expect(ctx.tools.schemas(key).filter(t => t.name === 'bash')).toHaveLength(1) + }) + + it('rejects a duplicate name within one layer, naming agent.ctx for the global case', async () => { + const ctx = await mount() + const { scope } = await mintAgentScope(ctx, 'a') + ctx.tools.register(tool('x')) + expect(() => ctx.tools.register(tool('x'))).toThrow(/agent\.ctx/) + scope.ctx.tools.register(tool('y')) + expect(() => scope.ctx.tools.register(tool('y'))).toThrow(/already registered in this scope/) + }) + + it('disposing the scope unwinds its registrations and leaves no residue', async () => { + const ctx = await mount() + const { scope, key } = await mintAgentScope(ctx, 'a') + scope.ctx.tools.register(tool('mine')) + expect(ctx.tools.get('mine', key)).toBeDefined() + await scope.dispose() + expect(ctx.tools.get('mine', key)).toBeUndefined() + expect(ctx.tools.knownNames(key)).toEqual([]) + }) +}) + +describe('restrict()', () => { + it('masks global tools for the scope; grants bypass; assembly and execute agree', async () => { + const ctx = await mount() + const { scope, key } = await mintAgentScope(ctx, 'a') + ctx.tools.register(tool('read')) + ctx.tools.register(tool('bash')) + scope.ctx.tools.register(tool('capture')) + scope.ctx.tools.restrict({ allow: ['read'] }) + + // The scoped grant survives the allow-list; the unlisted global is gone. + expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['capture', 'read']) + expect(await run(ctx, 'bash', key)).toBe('Error: unknown tool "bash"') + expect(await run(ctx, 'read', key)).toBe('ran:read') + expect(await run(ctx, 'capture', key)).toBe('ran:capture') + // Other scopes and the global view are untouched. + expect(ctx.tools.schemas().map(t => t.name).sort()).toEqual(['bash', 'read']) + }) + + it('composes multiple restrictions by intersection and lifts each independently', async () => { + const ctx = await mount() + const { scope, key } = await mintAgentScope(ctx, 'a') + for (const name of ['a', 'b', 'c']) ctx.tools.register(tool(name)) + const liftAllow = scope.ctx.tools.restrict({ allow: ['a', 'b'] }) + scope.ctx.tools.restrict({ deny: ['b'] }) + expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['a']) + liftAllow() + // The deny remains after the allow-list is lifted. + expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['a', 'c']) + }) + + it('snapshots the filter at registration (caller mutation changes nothing)', async () => { + const ctx = await mount() + const { scope, key } = await mintAgentScope(ctx, 'a') + ctx.tools.register(tool('a')) + ctx.tools.register(tool('b')) + const filter = { deny: ['a'] } + scope.ctx.tools.restrict(filter) + filter.deny.push('b') + expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['b']) + }) + + it('fails loud on an unscoped call, an empty filter, and unknown names', async () => { + const ctx = await mount() + const { scope } = await mintAgentScope(ctx, 'a') + ctx.tools.register(tool('real')) + expect(() => ctx.tools.restrict({ deny: ['real'] })).toThrow(/requires a scoped context/) + expect(() => scope.ctx.tools.restrict({})).toThrow(/no-op/) + expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown tool "reall"; known tools for this scope: real/) + }) +}) + +describe('scoped execution dispatch', () => { + it('an agent.ctx pre-execute listener gates only its own agent (and never subject-less calls)', async () => { + const ctx = await mount() + const { scope, key } = await mintAgentScope(ctx, 'a') + const other = { id: 'other' as AgentId } as Agent + ctx.tools.register(tool('t')) + + const seen: (string | undefined)[] = [] + scope.ctx.on('tools/pre-execute', (exec: ToolExecution, _next: () => Promise) => { + seen.push(exec.agent?.id) + return Promise.resolve({ kind: 'deny', reason: 'scoped veto' }) + }) + + expect(await run(ctx, 't', key)).toBe('Error: scoped veto') + expect(await run(ctx, 't', other)).toBe('ran:t') + expect(await run(ctx, 't')).toBe('ran:t') + expect(seen).toEqual(['a']) + }) +}) diff --git a/packages/core/tools/tsconfig.json b/packages/core/tools/tsconfig.json index dedc111d87..be21db9ada 100644 --- a/packages/core/tools/tsconfig.json +++ b/packages/core/tools/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../../core/agent" + }, + { + "path": "../../core/scope" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8251dc62e9..9238bbea1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -183,6 +183,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../session @@ -245,6 +248,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../session @@ -278,6 +284,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../scope cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) @@ -291,6 +300,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../scope cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) @@ -303,6 +315,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../scope '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../system-prompt From f387b774a9a84d992bb73db6b1db984f971343c3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:17:47 +0800 Subject: [PATCH 03/64] =?UTF-8?q?feat(agent):=20the=20agent=20is=20a=20reg?= =?UTF-8?q?istration=20scope=20=E2=80=94=20Agent.ctx,=20setup=20slot,=20fu?= =?UTF-8?q?sed=20scoped=20dispatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every live agent owns a dsh-scope context (Agent.ctx, key = the agent), minted inside the loop's composite lifecycle effect: registrations through it are agent-visible and agent-lifetime, and agent.ctx listeners hear only that agent's dispatches. The composite yields the scope's raw disposer first (identity-nested, no un-nested window), then session entry (scoped enter captures the session carrier), then registration; teardown runs stop/drain -> unregister -> detach session -> unwind scope, keeping store/registry rollback synchronous on every failure path. CreateAgentOptions.setup(agentCtx) runs after the scope is minted and the agent registered, before agent/session-start and the loop start — the slot where a creator composes the agent's scoped world (persona sections, restrict(), scoped tools); a throwing setup unwinds inside the rollback boundary. Setup registers, it never drives. agentEvents(ctx, agent) fuses the scope carrier with the injected subject argument for every agent/* dispatch (the correct dispatch is the shortest spelling); assembleContextFor(agent) pairs the agent DX field with the scope layer selector. All loop/agent/registry dispatch sites converted; agent/* event declarations carry this: Scoped; ctx.agent is a safe root accessor defaulting undefined, shadowed by each agent context. --- packages/core/agent-loop/package.json | 2 + packages/core/agent-loop/src/agent.ts | 44 ++++- packages/core/agent-loop/src/index.ts | 44 ++++- packages/core/agent-loop/src/loop.ts | 49 +++-- .../agent-loop/tests/scope-lifecycle.spec.ts | 172 ++++++++++++++++++ packages/core/agent-loop/tsconfig.json | 3 + packages/core/agent/package.json | 2 + packages/core/agent/src/dispatch.ts | 117 ++++++++++++ packages/core/agent/src/index.ts | 45 ++++- packages/core/agent/src/types.ts | 100 ++++++++-- packages/core/agent/tests/agent.spec.ts | 3 + packages/core/agent/tsconfig.json | 3 + 12 files changed, 532 insertions(+), 52 deletions(-) create mode 100644 packages/core/agent-loop/tests/scope-lifecycle.spec.ts create mode 100644 packages/core/agent/src/dispatch.ts diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index 6e92adb6ab..03acc7e17f 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -24,6 +24,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", @@ -37,6 +38,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 49de0f77c4..5f56cdc8a7 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -7,6 +7,8 @@ */ import type { Context } from 'cordis' +import { scopeTarget } from '@deepseek-ai/dsh-scope' +import type { Scoped } from '@deepseek-ai/dsh-scope' import type { AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' @@ -28,6 +30,27 @@ export class ReactLoopAgent implements Agent { */ readonly inbox = new Inbox() + /** + * The agent's scope context ({@link Agent.ctx}), wired by the factory right + * after the scope is minted — before the agent is registered, announced, or + * driven, so no consumer can observe it unset. Definite-assignment (`!`) + * expresses that two-phase construction: the agent object and its scope + * context are mutually referential (the scope is keyed BY this agent), so + * neither can exist strictly before the other. + */ + ctx!: Context + + /** + * The dispatch carrier for this agent's own emits (`agent/status`, + * `agent/queued`, `agent/error`): keyed by the agent, base = the agent + * (listener `this` is the agent). Built lazily because it is self-referential. + */ + private get carrier(): Scoped { + return (this.#carrier ??= scopeTarget(this, this)) + } + + #carrier: Scoped | undefined + private _status: AgentStatus = 'idle' private currentAbort: AbortController | undefined /** @@ -63,7 +86,7 @@ export class ReactLoopAgent implements Agent { private idleWaiters: (() => void)[] = [] constructor( - private ctx: Context, + private loopCtx: Context, public readonly id: AgentId, public readonly options: AgentOptions, public readonly session: Session, @@ -87,9 +110,9 @@ export class ReactLoopAgent implements Agent { // not hang on one bad listener). if (status !== 'running') this.settleIdleWaiters() try { - this.ctx.emit('agent/status', this, status) + this.loopCtx.emit(this.carrier, 'agent/status', this, status) } catch (error: unknown) { - this.ctx.logger.warn(`agent "${this.id}": agent/status listener threw on ${status}: ${String(error)}`) + this.loopCtx.logger.warn(`agent "${this.id}": agent/status listener threw on ${status}: ${String(error)}`) } } @@ -112,7 +135,7 @@ export class ReactLoopAgent implements Agent { if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`) const source = this.resolveSource(options) this.inbox.enqueue({ content, source }) - this.ctx.emit('agent/queued', this, content, { source, steering: false }) + this.loopCtx.emit(this.carrier, 'agent/queued', this, content, { source, steering: false }) } steer(content: ContentBlock[], options?: SendOptions): void { @@ -120,7 +143,7 @@ export class ReactLoopAgent implements Agent { if (this._status !== 'running') { this.send(content, options); return } const source = this.resolveSource(options) this.inbox.steer({ content, source }) - this.ctx.emit('agent/queued', this, content, { source, steering: true }) + this.loopCtx.emit(this.carrier, 'agent/queued', this, content, { source, steering: true }) } inject(content: ContentBlock[], options?: SendOptions): void { @@ -181,11 +204,12 @@ export class ReactLoopAgent implements Agent { // plugins monitoring agent/error see idle-injection persistence failures // too. A throwing agent/error listener is contained. if (turnRecorded) { - void Promise.resolve(this.ctx.parallel('session/flush', this.session)).catch((error: unknown) => { + // Through the store's flush (the carrier owner), never a raw parallel. + void this.loopCtx.sessions.flush(this.session).catch((error: unknown) => { const err = error instanceof Error ? error : new Error(String(error)) - this.ctx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${err.message}`) + this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${err.message}`) try { - this.ctx.emit('agent/error', this, turn, 0, err) + this.loopCtx.emit(this.carrier, 'agent/error', this, turn, 0, err) } catch { // contained: the failure is already logged; a throwing agent/error // listener must not escape this fire-and-forget catch. @@ -264,7 +288,7 @@ export class ReactLoopAgent implements Agent { * fiber's LIFO disposal chain, where a throw would skip later disposers). */ start(): () => void { - this.done = runLoop(this.ctx, this, { + this.done = runLoop(this.loopCtx, this, { setStatus: (status) => { this.setStatus(status) }, setAbort: controller => void (this.currentAbort = controller), disposed: this.disposed, @@ -296,7 +320,7 @@ export class ReactLoopAgent implements Agent { // 'disposed' is part of the agent/status contract. Guarded: a throwing // listener must not break the disposal chain. try { - this.ctx.emit('agent/status', this, 'disposed') + this.loopCtx.emit(this.carrier, 'agent/status', this, 'disposed') } catch { // listener error during disposal — nothing safe left to do with it } diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index e7e0e562d9..306f8707e2 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -10,6 +10,8 @@ import { Context, Service } from 'cordis' import { randomUUID } from 'node:crypto' import z from 'schemastery' +import { createScope } from '@deepseek-ai/dsh-scope' +import { agentEvents } from '@deepseek-ai/dsh-agent' import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions, SessionStartSource } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' @@ -174,7 +176,7 @@ export class AgentLoop extends Service implements AgentFactory { }) // A seeded (forked) create is still a fresh start, NOT a resume — `resume` // is reserved for reloading a PERSISTED session via resume()/resumeWith(). - return this.startOwned(options.agentId, options.agentOptions ?? {}, session, 'startup') + return this.startOwned(options.agentId, options.agentOptions ?? {}, session, 'startup', options.setup) } /** @@ -298,17 +300,46 @@ export class AgentLoop extends Service implements AgentFactory { */ private start( id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource, + setup?: (agentCtx: Context) => void, ): { agent: ReactLoopAgent; disposeAgent: () => Promise } { const agent = new ReactLoopAgent(this.ctx, id, options, session) const dispose = this.ctx.effect(function* (this: AgentLoop) { - yield this.ctx.sessions.enter(session) + // Mint the agent's scope (key = the agent) and wire the two-phase + // reference: the scope context tags registrations + filters dispatch; + // the extend adds the `ctx.agent` DX own-property on top. The raw + // disposer is yielded IMMEDIATELY (exact function identity nests the + // scope fiber out of the loop fiber's concurrent sibling list), so + // there is no window in which a throw leaves the scope un-nested. + // + // Yield order is the REVERSE of teardown (LIFO). Teardown runs: + // stop/drain → unregister → detach session → unwind scope + // Detach BEFORE the scope unwind is deliberate: the scope fiber's + // unload is asynchronous (fiber inertia), and every disposer chained + // after an async one waits for it — detaching first keeps the + // store/registry rollback SYNCHRONOUS on every failure path (a caller + // that catches a throwing create() observes no half-created agent or + // session, and the ids are immediately reusable), at the cost that a + // scoped listener's own disposer runs after the session left the store + // (it heard the final stop/drain flush while still attached, so + // nothing durable is lost). + const scope = createScope(this.ctx, agent) + agent.ctx = scope.ctx.extend({ agent }) + yield scope.rawDispose + // Enter the session THROUGH agent.ctx so the store captures the agent's + // scope as the session's dispatch carrier. + yield agent.ctx.sessions.enter(session) this.ctx.sessions.announce(session) yield this.ctx.agents.register(agent) + // The creator's scoped composition, inside the rollback boundary: a + // throwing setup unwinds LIFO through register → scope → detach, so a + // half-created agent never leaks. Setup REGISTERS (through agent.ctx), + // it never drives — see CreateAgentOptions.setup. + setup?.(agent.ctx) // Fire AFTER register (a listener can ctx.agents.get(id) + inject()) and // BEFORE the loop's first turn. Contained: a throwing listener is logged, // never aborts construction (no open turn to balance here). try { - this.ctx.emit('agent/session-start', agent, source) + agentEvents(this.ctx, agent).emit('agent/session-start', source) } catch (error: unknown) { this.ctx.logger.warn(`agent "${id}": agent/session-start listener threw: ${String(error)}`) } @@ -338,8 +369,11 @@ export class AgentLoop extends Service implements AgentFactory { * `AgentHandle.dispose(): Promise` contract (mirrors the ACP `quiesce()` * helper). */ - private startOwned(id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource): AgentHandle { - const { agent, disposeAgent } = this.start(id, options, session, source) + private startOwned( + id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource, + setup?: (agentCtx: Context) => void, + ): AgentHandle { + const { agent, disposeAgent } = this.start(id, options, session, source, setup) let disposing: Promise | undefined return { agent, dispose: () => (disposing ??= disposeAgent()) } } diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index eb5b07ba23..dbb66ad7b0 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -10,7 +10,8 @@ import type { Context } from 'cordis' import type { FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm' import { BlockAssembler, HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm' -import type { ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' +import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' +import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' import { canonicalHeader } from '@deepseek-ai/dsh-session' import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' import { createTransmissionLog, recordRequestHeader } from './request-log.ts' @@ -155,9 +156,10 @@ export interface LoopHandle { * every prompt blocked → 'turn/end'(rejected), 0 steps * STEP loop: * drain steering → session('steering/message') ⟵ catches late steering - * assembly = ctx.systemPrompt.assemble({agent}) ⟵ waterfall system-prompt/assemble; renderPrompt + * assembly = ctx.systemPrompt.assemble(assembleContextFor(agent)) ⟵ waterfall system-prompt/assemble + * (scope-filtered; scoped sections/tools join); renderPrompt * (persona section + {{variables}}) IS the full prompt - * await ctx.serial('agent/pre-step') ⟵ surface mutation (compaction) OUTSIDE the step + * await events.serial('agent/pre-step') ⟵ surface mutation (compaction) OUTSIDE the step * boundary = session.deriveMessages() ⟵ the reconstruction boundary: snapshot in the * session('step/start') same sync frame, strictly before step/start * config = waterfall agent/request(config) ⟵ frozen seed; a returned replacement switches @@ -181,7 +183,7 @@ export interface LoopHandle { * if action==stop && steering arrived (step/end/continuation listeners): continue anyway * if action==stop: break * session('turn/end') ⟵ durable turn boundary (no agent/* mirror) - * await ctx.parallel('session/flush', session) ⟵ durability checkpoint + * await ctx.sessions.flush(session) ⟵ durability checkpoint (store-owned carrier) * re-enqueue leftover steering as queued ⟵ steering is never stranded * idle (emit agent/status) unless more queued * ``` @@ -198,6 +200,10 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH const transmission = createTransmissionLog() const { session } = agent + // The fused agent-subject dispatcher: every agent/* dispatch below carries + // the agent's scope (an `agent.ctx` listener hears only this agent) with + // the subject injected — one spelling, checked by the dev invariants. + const events = agentEvents(ctx, agent) while (!handle.isDisposed()) { await agent.inbox.waitForQueued(handle.disposed) @@ -252,7 +258,7 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH // turn number is actually last in the log — a stale counter would collide. const turn = lastTurnNumber(session) + 1 try { - await runTurn(ctx, agent, handle, turn, transmission) + await runTurn(ctx, events, agent, handle, turn, transmission) } catch (error: unknown) { // Backstop: runTurn rethrows only a PRE-turn throw (the invariant guard // before turn/start) — no turn/start was appended, so no turn is open and @@ -263,7 +269,7 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH const err = toError(error) ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${err.message}`) try { - ctx.emit('agent/error', agent, turn, 0, err) + events.emit('agent/error', turn, 0, err) } catch { /* contained: a throwing agent/error listener must not kill the driver */ } } @@ -288,7 +294,7 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH } async function runTurn( - ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, turn: number, transmission: TransmissionLog, + ctx: Context, events: AgentEventDispatch, agent: ReactLoopAgent, handle: LoopHandle, turn: number, transmission: TransmissionLog, ): Promise { const { session } = agent @@ -354,7 +360,7 @@ async function runTurn( // boundary is durable). So set the error reason for closeTurn to append. reason = { kind: 'error', step, ...errorData(err) } try { - ctx.emit('agent/error', agent, turn, step, err) + events.emit('agent/error', turn, step, err) } catch { // contained: the error is already captured on `reason`; a throwing // agent/error listener must not prevent the turn from closing. @@ -398,8 +404,8 @@ async function runTurn( // batch always reports the last vetoing reason. let lastBlockReason = 'prompt blocked by hook' for (const message of queued) { - const decision = await ctx.waterfall( - 'agent/prompt-submit', agent, message.content, message.source, + const decision = await events.waterfall( + 'agent/prompt-submit', message.content, message.source, () => Promise.resolve({ kind: 'allow' }), ) if (decision.kind === 'block') { @@ -456,7 +462,7 @@ async function runTurn( // step. renderPrompt IS the full prompt — the persona is the order-0 // section (registered by the AgentLoop plugin) and `{{variable}}` // interpolation happens in the render, so there is no separate join. - const assembly = await ctx.systemPrompt.assemble({ agent }) + const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent)) const fullSystemPrompt = renderPrompt(assembly) // Interruption landing after assembly: dispose() or cancel() in a @@ -483,7 +489,7 @@ async function runTurn( // throwing listener escapes to the outer catch, which closes the (not-yet- // open) step as a no-op and ends the turn via failTurn — a broken // pre-step plugin ends the turn, not the loop. - await ctx.serial('agent/pre-step', agent, turn, step, fullSystemPrompt, abort.signal) + await events.serial('agent/pre-step', turn, step, fullSystemPrompt, abort.signal) // Interruption landing during the pre-step seam: do not open an empty step. if (handle.isCancelled() || handle.isDisposed()) { @@ -524,7 +530,8 @@ async function runTurn( let stepOutcome: { hadToolCalls: boolean; finish: FinishReason } | { error: Error } try { - stepOutcome = await runStep(ctx, agent, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal) + stepOutcome = await runStep( + ctx, events, agent, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal) } catch (error: unknown) { stepOutcome = { error: toError(error) } } finally { @@ -566,8 +573,8 @@ async function runTurn( const defaultDecision: ContinuationDecision = { action: stepOutcome.hadToolCalls || steered ? 'continue' : 'stop' } let decision: ContinuationDecision try { - decision = await ctx.waterfall( - 'agent/turn-continuation', agent, turn, defaultDecision, + decision = await events.waterfall( + 'agent/turn-continuation', turn, defaultDecision, () => Promise.resolve(defaultDecision), ) } catch (error: unknown) { @@ -644,8 +651,9 @@ async function runTurn( // Durability checkpoint: persistence plugins drain write-behind buffers. // A failing persistence plugin is reported but doesn't kill the agent. + // Through the store's flush (the carrier owner), never a raw parallel. try { - await ctx.parallel('session/flush', session) + await ctx.sessions.flush(session) } catch (error: unknown) { // The turn is already closed (turn/end appended above) and flush must run // AFTER turn/end to be a checkpoint — so there is no in-turn position left @@ -657,7 +665,7 @@ async function runTurn( const err = toError(error) ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${err.message}`) try { - ctx.emit('agent/error', agent, turn, step, err) + events.emit('agent/error', turn, step, err) } catch { // contained: a throwing agent/error listener must not escape the loop. } @@ -681,6 +689,7 @@ function drainSteering(agent: ReactLoopAgent, turn: number): boolean { * step/start and already reflects any compaction. */ async function runStep( ctx: Context, + events: AgentEventDispatch, agent: ReactLoopAgent, turn: number, step: number, @@ -713,7 +722,7 @@ async function runStep( // model-visible content flows through the log channels). The header event // below records whatever the request ACTUALLY uses, so a listener's switch // is a logged, reconstructable fact, never silent drift. - const config = await ctx.waterfall('agent/request', agent, turn, step, seedConfig, () => Promise.resolve(seedConfig)) + const config = await events.waterfall('agent/request', turn, step, seedConfig, () => Promise.resolve(seedConfig)) if (!config.model) { throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`) } @@ -764,7 +773,7 @@ async function runStep( if (assembler.finish.kind === 'max-tokens') { let message: Message = withoutToolCalls(assembler.message()) - message = withoutToolCalls(await ctx.waterfall('agent/step-result', agent, turn, step, message, () => Promise.resolve(message))) + message = withoutToolCalls(await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message))) // Fire the assistant/message when there is content OR usage: a max-tokens // step can be cut off with empty content but still carry token accounting, // and assistant/message is the only host for usage (there is no standalone @@ -787,7 +796,7 @@ async function runStep( // source of truth for derived history and replay) records the message that // tool dispatch actually uses. let message: Message = assembler.message() - message = await ctx.waterfall('agent/step-result', agent, turn, step, message, () => Promise.resolve(message)) + message = await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message)) // Same content-or-usage guard as the max-tokens branch: a step that finishes // with neither assembled content nor usage (e.g. a bare `stop` finish that diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts new file mode 100644 index 0000000000..28d1da192d --- /dev/null +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { scopeOf } from '@deepseek-ai/dsh-scope' +import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { MockAdapter, textResponse } from './mock-adapter.ts' + +async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok')])) { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: 'You are the deployment.' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], adapter) + return ctx +} + +function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { + dispose() + resolve() + } + }) + }) +} + +const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }] + +describe('agent scope lifecycle', () => { + it('wires agent.ctx: tagged with the agent, DX field set, ctx.agent safe elsewhere', async () => { + const ctx = await harness() + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + expect(scopeOf(agent.ctx)).toBe(agent) + expect(agent.ctx.agent).toBe(agent) + // The root accessor default: a plain context answers undefined, not a throw. + expect(ctx.agent).toBeUndefined() + await ctx.agents.get(AgentId('a1'))?.whenIdle() + }) + + it('scoped registrations live in the agent world and die with the agent', async () => { + const ctx = await harness() + const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } }) + const { agent } = handle + agent.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' }) + agent.ctx.tools.register({ + name: 'mine', description: 'scoped', parameters: {}, + execute: () => Promise.resolve(text('ran')), + }) + + const scopedAssembly = await ctx.systemPrompt.assemble(assembleContextFor(agent)) + expect(scopedAssembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You run tests.') + expect(scopedAssembly.tools.map(t => t.name)).toContain('mine') + // Other assemblies are untouched. + const globalAssembly = await ctx.systemPrompt.assemble() + expect(globalAssembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are the deployment.') + expect(globalAssembly.tools.map(t => t.name)).not.toContain('mine') + + await handle.dispose() + // The scoped world unwound with the agent: nothing leaked into the registries. + expect(ctx.tools.get('mine', agent)).toBeUndefined() + const after = await ctx.systemPrompt.assemble(assembleContextFor(agent)) + expect(after.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are the deployment.') + }) + + it('agent.ctx listeners hear only their own agent (scoped dispatch end to end)', async () => { + const ctx = await harness(new MockAdapter([textResponse('one'), textResponse('two')])) + const a = ctx.agentLoop.create(AgentId('a'), { model: 'mock' }) + const b = ctx.agentLoop.create(AgentId('b'), { model: 'mock' }) + + const heard: string[] = [] + a.ctx.on('agent/status', (subject, status) => void heard.push(`a-sees:${subject.id}:${status}`)) + a.ctx.on('session/event', (_s, event) => { + if (event.type === 'user/message') heard.push('a-sees:user-message') + }) + + b.send(text('for b')) + await waitForIdle(ctx, b) + expect(heard).toEqual([]) // nothing of b's leaked into a's scope + + a.send(text('for a')) + await waitForIdle(ctx, a) + expect(heard).toContain('a-sees:a:running') + expect(heard).toContain('a-sees:user-message') + }) + + it('runs setup in the guaranteed slot: scoped world complete before session-start and the first assembly', async () => { + const ctx = await harness() + const order: string[] = [] + ctx.on('agent/session-start', (agent) => { + order.push('session-start') + // The scoped section is already registered by the time session-start fires. + void ctx.systemPrompt.assemble(assembleContextFor(agent)).then((assembly) => { + order.push(`persona:${assembly.sections.find(s => s.name === 'deployment:persona')?.text}`) + }) + }) + + const handle = ctx.agents.create({ + agentId: AgentId('child'), + sessionId: SessionId('child-s'), + agentOptions: { model: 'mock' }, + setup: (agentCtx) => { + order.push('setup') + agentCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You are the child.' }) + }, + }) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(order).toEqual(['setup', 'session-start', 'persona:You are the child.']) + await handle.dispose() + }) + + it('a throwing setup unwinds the half-created agent completely', async () => { + const ctx = await harness() + expect(() => ctx.agents.create({ + agentId: AgentId('bad'), + sessionId: SessionId('bad-s'), + agentOptions: { model: 'mock' }, + setup: () => { throw new Error('boom setup') }, + })).toThrow('boom setup') + + // Nothing leaked: no agent, no session, and the ids are reusable. + expect(ctx.agents.get(AgentId('bad'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined() + const retry = ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } }) + await retry.dispose() + }) + + it('a throwing session/created listener disposes the scope (pre-nesting rollback window)', async () => { + const ctx = await harness() + let boom = true + ctx.on('session/created', () => { + if (boom) { boom = false; throw new Error('boom created') } + }) + expect(() => ctx.agents.create({ + agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' }, + })).toThrow('boom created') + expect(ctx.agents.get(AgentId('bad'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined() + // The rollback also disposed the scope fiber: re-creating works cleanly. + const retry = ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } }) + expect(scopeOf(retry.agent.ctx)).toBe(retry.agent) + await retry.dispose() + }) + + it('registrations through a disposed agent ctx throw INACTIVE_EFFECT', async () => { + const ctx = await harness() + const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } }) + await handle.dispose() + expect(() => handle.agent.ctx.on('agent/status', () => {})).toThrow(/inactive context/) + }) + + it('agentEvents fuses carrier and subject for custom drivers', async () => { + const ctx = await harness() + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const other = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' }) + const heard: string[] = [] + agent.ctx.on('agent/error', (subject: Agent, turn: number) => void heard.push(`${subject.id}:${turn}`)) + + agentEvents(ctx, other).emit('agent/error', 1, 0, new Error('not for a1')) + agentEvents(ctx, agent).emit('agent/error', 2, 0, new Error('for a1')) + expect(heard).toEqual(['a1:2']) + }) +}) diff --git a/packages/core/agent-loop/tsconfig.json b/packages/core/agent-loop/tsconfig.json index 03df67cfc4..5d7cf98bb7 100644 --- a/packages/core/agent-loop/tsconfig.json +++ b/packages/core/agent-loop/tsconfig.json @@ -34,6 +34,9 @@ }, { "path": "../../core/agent" + }, + { + "path": "../../core/scope" } ] } diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index e38a6c8d61..b8e6108904 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -24,6 +24,7 @@ "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "cordis": "^4.0.0-rc.6" @@ -31,6 +32,7 @@ "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "cordis": "^4.0.0-rc.6" diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts new file mode 100644 index 0000000000..bed4981135 --- /dev/null +++ b/packages/core/agent/src/dispatch.ts @@ -0,0 +1,117 @@ +/** + * Fused scope-carrier dispatch for agent-subject events, plus the assembly + * context builder. The ONE sanctioned spelling for dispatching `agent/*` + * events: `agentEvents(ctx, agent).waterfall('agent/request', …)` builds the + * scope carrier ({@link scopeTarget} keyed by the agent) AND injects the + * subject as the first event argument in one move, so the correct dispatch is + * also the shortest — a dispatch site cannot pass a carrier keyed to one + * agent while naming another as the subject, which is the invariant the + * dev-mode scoped-dispatch check asserts at runtime. + * + * @module @deepseek-ai/dsh-agent/dispatch + */ + +import type { Context, Events } from 'cordis' +import { scopeTarget } from '@deepseek-ai/dsh-scope' +import type { Scoped } from '@deepseek-ai/dsh-scope' +import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt' +import type { Agent } from './types.ts' + +/** Extract the parameter tuple from an event handler type (its `this` is not part of the tuple). */ +type Params = F extends (...args: infer P) => unknown ? P : never +/** Extract the return type from an event handler type. */ +type Return = F extends (...args: never[]) => infer R ? R : never + +/** + * The event names whose subject is an agent: handler parameters start with an + * `Agent` AND the handler declares a `Scoped` `this` (the scope-carrier + * contract). The `this` check keeps accidental first-parameter-happens-to-be- + * an-Agent events (or zero-arg events, whose parameter tuple would satisfy a + * bare rest-tuple check via callability) out of the fused-dispatch surface. + */ +export type AgentSubjectEvent = { + [K in keyof Events]: Events[K] extends (this: Scoped, ...args: infer P) => unknown + ? P extends [Agent, ...unknown[]] ? K : never + : never +}[keyof Events] + +/** The event arguments AFTER the injected agent subject. */ +type Tail = Params extends [Agent, ...infer R] ? R : never + +/** + * The fused dispatcher {@link agentEvents} returns: each method dispatches the + * named agent-subject event with the agent's scope carrier as `thisArg` and + * the agent itself injected as the first event argument. + */ +export interface AgentEventDispatch { + /** + * Fire-and-forget notification (Cordis `emit`) in the agent's scope. + * @param name - the agent-subject event to emit. + * @param rest - the event's arguments after the injected agent. + */ + emit(name: K, ...rest: Tail): void + /** + * Awaited in-order dispatch (Cordis `serial`) in the agent's scope. + * @param name - the agent-subject event to dispatch. + * @param rest - the event's arguments after the injected agent. + * @returns the serial chain's result (the first bail value, if any). + */ + serial(name: K, ...rest: Tail): Promise>> + /** + * Around-middleware dispatch (Cordis `waterfall`) in the agent's scope. The + * declared event parameters already end with the `next` callback, so `rest` + * is exactly the event's arguments after the injected agent — the final + * element being the innermost `next` (the default the listener chain wraps). + * @param name - the agent-subject event to dispatch. + * @param rest - the event's arguments after the injected agent. + * @returns the waterfall's composed result. + */ + waterfall(name: K, ...rest: Tail): Return +} + +/** + * Build the fused dispatcher for `agent`'s events (see the module doc). Cheap + * (one carrier + one small object) — dispatch sites create it per run/turn + * rather than caching it on the agent. + * @param ctx - the context to dispatch through (any context of the app). + * @param agent - the subject agent; also the scope-carrier key. + * @returns the fused dispatcher. + */ +export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { + const carrier: Scoped = scopeTarget(agent, agent) + // The three dispatch methods forward through cordis' variadic mixins. The + // fused (carrier, name, agent, ...rest) tuple is provably a valid argument + // list for the matching thisArg overload, but TypeScript cannot relate the + // generic Tail spread back to that overload's conditional parameter + // tuple — hence one contained, shape-preserving cast per method. + return { + emit(name, ...rest) { + // eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function + const emit = ctx.emit as (thisArg: Scoped, name: string, ...args: unknown[]) => void + emit(carrier, name, agent, ...rest) + }, + async serial(name, ...rest) { + // eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function + const serial = ctx.serial as (thisArg: Scoped, name: string, ...args: unknown[]) => Promise + return await serial(carrier, name, agent, ...rest) + }, + waterfall(name, ...rest) { + // eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function + const waterfall = ctx.waterfall as (thisArg: Scoped, name: string, ...args: unknown[]) => never + return waterfall(carrier, name, agent, ...rest) + }, + } +} + +/** + * The assembly context for one agent's prompt: the typed `agent` DX field and + * the `scope` layer selector, set together (setting `agent` without `scope` + * silently drops the agent's scoped sections/tools from the assembly — the + * dev invariants flag it). THE way the loop (and any custom driver) builds + * its per-step `ctx.systemPrompt.assemble(…)` input. + * @param agent - the agent the assembly is for. + * @returns the context to pass to `assemble()`. + */ +export function assembleContextFor(agent: Agent): AssembleContext { + return { agent, scope: agent } +} diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 096555f925..b9c9e6f0df 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -6,14 +6,28 @@ */ import { Context, Service } from 'cordis' +import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type { Agent, AgentId, AgentOptions } from './types.ts' export * from './types.ts' +export { agentEvents, assembleContextFor } from './dispatch.ts' +export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts' declare module 'cordis' { interface Context { agents: AgentRegistry + /** + * The agent whose scope this context belongs to, or `undefined` on any + * context not derived from an agent scope. Pure DX sugar over the + * `dsh-scope` tag: the agent loop sets it as an own property on each + * `Agent.ctx`, and {@link AgentRegistry} registers a root accessor + * defaulting to `undefined` so the read is safe on every context (a plain + * plugin context answers `undefined` instead of throwing the Cordis + * unknown-property error). Core packages below the agent layer read the + * `dsh-scope` tag (`scopeOf`) instead, never this field. + */ + agent?: Agent } } @@ -51,6 +65,20 @@ export interface CreateAgentOptions { seed?: SessionEvent[] /** Per-agent options (model, …). */ agentOptions?: AgentOptions + /** + * Creation-time composition of the agent's scoped world. The factory runs it + * inside the agent's composite lifecycle effect — after the scope is minted + * and the agent registered, before `agent/session-start` fires and the loop + * starts — so everything it registers through `agentCtx` (scoped tools, + * prompt sections/variables, `restrict()`, listeners, `agentCtx.plugin(…)` + * profiles) exists before the first prompt assembly, and a THROWING setup + * unwinds inside the rollback boundary instead of leaking a half-created + * agent. **Setup registers, it never drives**: calling + * `send`/`steer`/`inject` here would open a turn before `agent/session-start` + * (the dev invariants flag a `turn/start` logged before session-start as a + * teaching error) — drive the agent after creation returns. + */ + setup?: (agentCtx: Context) => void } /** @@ -120,6 +148,13 @@ export class AgentRegistry extends Service { constructor(ctx: Context) { super(ctx, 'agents') + // The `ctx.agent` DX accessor: default `undefined` on every context, so a + // plain plugin context reads cleanly instead of hitting the Cordis + // unknown-property throw. Each Agent.ctx shadows it with an own property + // (own properties resolve before the context proxy is consulted), so the + // accessor body never needs to resolve a scope itself. Effect-scoped: + // unwinds with this service's fiber. + ctx.accessor('agent', { get: () => undefined }) } /** @@ -167,7 +202,11 @@ export class AgentRegistry extends Service { /** * Register a live agent. Throws if an agent with the same id is already * registered. Emits `agent/created` on registration and `agent/disposed` - * when the calling fiber is disposed. Returns the disposer. + * when the calling fiber is disposed — both with the agent's scope carrier + * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the + * emits are scope-filtered regardless of which context invoked `register` + * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always + * requires passing the carrier). Returns the disposer. * @param agent - the already-constructed agent to record in the store. * @returns the disposer that removes the agent and emits `agent/disposed`. */ @@ -196,12 +235,12 @@ export class AgentRegistry extends Service { // logging the listener bug and continuing is correct (mirrors the // guarded `agent/status` emit in dsh-agent-loop's ReactLoopAgent). try { - this.ctx.emit('agent/disposed', agent) + this.ctx.emit(scopeTarget(agent, agent), 'agent/disposed', agent) } catch (error: unknown) { this.ctx.logger.warn(`agent "${agent.id}": agent/disposed listener threw: ${String(error)}`) } } - this.ctx.emit('agent/created', agent) + this.ctx.emit(scopeTarget(agent, agent), 'agent/created', agent) }.bind(this), 'agents.register()') // ctx.effect's disposer returns Promise; our disposer API is // synchronous fire-and-forget — discard the (always-resolved) promise. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 46bc52128a..ff6f79d039 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -44,6 +44,8 @@ */ import type { Branded } from '@deepseek-ai/dsh-brand' +import type { Context } from 'cordis' +import type { Scoped } from '@deepseek-ai/dsh-scope' import type { ContentBlock, LlmCallConfig, Message, MessageSource } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -64,10 +66,14 @@ declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext { /** * The agent this assembly is for. The agent loop passes it on every - * per-step `assemble({ agent })`; variable providers project per-agent - * facts from it (`options.model` → `{{model}}`, `session.header.cwd` → + * per-step assembly (via its `assembleContextFor(agent)` helper, which + * also sets the `scope` field to the same agent — the layer selector + * `dsh-system-prompt` reads); variable providers project per-agent facts + * from it (`options.model` → `{{model}}`, `session.header.cwd` → * `{{cwd}}`). Optional because a bare `assemble()` (tests, diagnostics) - * has no agent — providers must tolerate its absence. + * has no agent — providers must tolerate its absence. Never set `agent` + * without `scope`: the assembly would silently miss the agent's scoped + * sections/tools (the dev invariants flag it). */ agent?: Agent } @@ -175,6 +181,17 @@ export interface Agent { readonly options: AgentOptions readonly session: Session readonly status: AgentStatus + /** + * The agent's scope context (`@deepseek-ai/dsh-scope`, key = this agent). + * Registrations through it — tools, prompt sections/variables, event + * listeners, restrictions — are visible to THIS agent only and unwind when + * the agent is disposed; `agent.ctx.on('agent/…')` listeners fire only for + * this agent's dispatches (zero self-filtering). Service resolution through + * it flows through the loop plugin's dependency surface — handing out + * `agent.ctx` hands out that capability. Live for exactly the agent's + * lifetime: registrations after disposal throw Cordis's INACTIVE_EFFECT. + */ + readonly ctx: Context /** Queue a user message. Starts a turn when idle; otherwise waits for the next turn. */ send(content: ContentBlock[], options?: SendOptions): void @@ -259,34 +276,54 @@ declare module 'cordis' { * An agent was registered in the {@link AgentRegistry} and is ready to * receive messages. * @param agent - the newly registered agent, already resolvable in the registry. + * 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`), built by the emitting side via + * `scopeTarget`/`agentEvents`. * @mode emit */ - 'agent/created'(agent: Agent): void + 'agent/created'(this: Scoped, agent: Agent): void /** * An agent was disposed and removed from the registry; its fiber and any * in-flight turn have been torn down. * @param agent - the agent that was torn down; its handle is now inert. + * 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`), built by the emitting side via + * `scopeTarget`/`agentEvents`. * @mode emit */ - 'agent/disposed'(agent: Agent): void + 'agent/disposed'(this: Scoped, agent: Agent): void /** * 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. * @param agent - the agent whose status flipped. * @param status - the status just entered (the transition's destination). + * 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`), built by the emitting side via + * `scopeTarget`/`agentEvents`. * @mode emit */ - 'agent/status'(agent: Agent, status: AgentStatus): void + 'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void /** * A message entered the agent's inbox (queued or steering). `source` is * the resolved source (defaults applied), not the caller's raw options. * @param agent - the agent whose inbox received the message. * @param content - the enqueued content blocks, verbatim. * @param info - the resolved source plus whether it entered as steering. + * 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`), built by the emitting side via + * `scopeTarget`/`agentEvents`. * @mode emit */ - 'agent/queued'(agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void + 'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void // ---- session lifecycle (emit) ---- /** @@ -299,9 +336,14 @@ declare module 'cordis' { * is deliberate (a bridge logs/injects, it does not gate startup). * @param agent - the agent whose session lifecycle began. * @param source - why the session started (fresh startup, resume, …). + * 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`), built by the emitting side via + * `scopeTarget`/`agentEvents`. * @mode emit */ - 'agent/session-start'(agent: Agent, source: SessionStartSource): void + 'agent/session-start'(this: Scoped, agent: Agent, source: SessionStartSource): void // Turn and step boundaries are NOT mirrored as agent/* emits: a consumer // that needs them reads the durable `turn/start`/`turn/end`/`step/start`/ @@ -334,6 +376,11 @@ declare module 'cordis' { * listener needs to measure pressure (the system prompt counts toward the * budget). `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`), built by the emitting side via + * `scopeTarget`/`agentEvents`. * @param agent - the agent about to open the step. * @param turn - the already-open turn this step belongs to. * @param step - the number of the step about to start. @@ -346,7 +393,7 @@ declare module 'cordis' { // reads. Revisit if no second consumer appears: e.g. hand listeners a lazy // prompt provider, or move token-pressure measurement behind a // compaction-specific seam instead of the shared pre-step checkpoint. - 'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise | void + 'agent/pre-step'(this: Scoped, agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise | void /** * Waterfall: decide what happens to ONE drained queued message before it * becomes a `user/message` — allow (optionally rewriting the prompt bytes or @@ -357,9 +404,14 @@ declare module 'cordis' { * @param agent - the agent draining its inbox. * @param content - the drained message's blocks, as queued. * @param source - the message's resolved source. + * 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`), built by the emitting side via + * `scopeTarget`/`agentEvents`. * @mode waterfall */ - 'agent/prompt-submit'(agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise + 'agent/prompt-submit'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise /** * Waterfall: shape the step's call configuration — model switching, * sampling overrides — by returning a replacement {@link LlmCallConfig} @@ -380,9 +432,14 @@ declare module 'cordis' { * @param turn - the open turn number. * @param step - the step whose request this is. * @param config - the config the loop would use (frozen); return a replacement to switch. + * 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`), built by the emitting side via + * `scopeTarget`/`agentEvents`. * @mode waterfall */ - 'agent/request'(agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise + 'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise /** * Waterfall: post-process the assembled assistant {@link Message} before * tool dispatch (validation, content rewriting, …). @@ -390,9 +447,14 @@ declare module 'cordis' { * @param turn - the open turn number. * @param step - the step that produced the message. * @param message - the assistant message as assembled from the stream. + * 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`), built by the emitting side via + * `scopeTarget`/`agentEvents`. * @mode waterfall */ - 'agent/step-result'(agent: Agent, turn: number, step: number, message: Message, next: () => Promise): Promise + 'agent/step-result'(this: Scoped, agent: Agent, turn: number, step: number, message: Message, next: () => Promise): Promise /** * Waterfall: override the turn-continuation decision via a typed * {@link ContinuationDecision}. The loop's `defaultDecision` is `continue` @@ -403,9 +465,14 @@ declare module 'cordis' { * @param agent - the agent deciding whether to run another step. * @param turn - the turn being continued or stopped. * @param defaultDecision - what the loop would do absent an override. + * 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`), built by the emitting side via + * `scopeTarget`/`agentEvents`. * @mode waterfall */ - 'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise + 'agent/turn-continuation'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise // ---- error notifications (emit) ---- /** @@ -415,8 +482,13 @@ declare module 'cordis' { * @param turn - the turn in which the failure surfaced. * @param step - the step at which the failure surfaced. * @param error - the failure, verbatim. + * 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`), built by the emitting side via + * `scopeTarget`/`agentEvents`. * @mode emit */ - 'agent/error'(agent: Agent, turn: number, step: number, error: Error): void + 'agent/error'(this: Scoped, agent: Agent, turn: number, step: number, error: Error): void } } diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index c344cd2a6f..43b4752f1b 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -10,6 +10,9 @@ function stubAgent(rawId: string): Agent { options: {}, session: new Session(SessionId(`${id}-session`)), status: 'idle', + // A bare context stands in for the agent scope: registry tests never + // register through it, they only need the field present. + ctx: new Context(), send() {}, steer() {}, inject() {}, diff --git a/packages/core/agent/tsconfig.json b/packages/core/agent/tsconfig.json index 7f4f457598..2692e1b7f7 100644 --- a/packages/core/agent/tsconfig.json +++ b/packages/core/agent/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../util/brand" }, + { + "path": "../../core/scope" + }, { "path": "../../llm/llm" }, From 806e7a84cf2e8316cdfa6fdc0b1b276be6f5f0cb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:21:27 +0800 Subject: [PATCH 04/64] docs: regenerate module graph, config catalog, and cordis catalogs for agent scoping --- docs/config-catalog.md | 9 ++-- docs/cordis-catalog/events.md | 88 ++++++++++++++++----------------- docs/cordis-catalog/services.md | 22 ++++++--- docs/module-graph.md | 15 ++++-- 4 files changed, 74 insertions(+), 60 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 870f57053e..afa88564c4 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -119,7 +119,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:36`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:38`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -565,7 +565,10 @@ 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). Every agent in - * the context shares it, subagents included. Template, not free-form text: + * 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 @@ -600,7 +603,7 @@ export interface Config { } ``` -Source: [`packages/core/system-prompt/src/index.ts:179`](../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:220`](../packages/core/system-prompt/src/index.ts) ## `@deepseek-ai/dsh-tool-fs` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 8d776a75ef..ec418b0576 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -18,134 +18,134 @@ Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `n An agent was registered in the AgentRegistry and is ready to receive messages. ```ts cordis-catalog -'agent/created'(agent: Agent): void +'agent/created'(this: Scoped, agent: Agent): void ``` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:286`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit An agent was disposed and removed from the registry; its fiber and any in-flight turn have been torn down. ```ts cordis-catalog -'agent/disposed'(agent: Agent): void +'agent/disposed'(this: Scoped, agent: Agent): void ``` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:271`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:298`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit 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'(agent: Agent, turn: number, step: number, error: Error): void +'agent/error'(this: Scoped, agent: Agent, turn: number, step: number, error: Error): void ``` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:420`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:492`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial 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). `signal` cancels any in-flight work a listener starts (e.g. a summarization model call). +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). `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`), built by the emitting side via `scopeTarget`/`agentEvents`. ```ts cordis-catalog -'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise | void +'agent/pre-step'(this: Scoped, agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise | void ``` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:349`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:396`](../../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. 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'(agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise +'agent/prompt-submit'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise ``` Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:362`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:414`](../../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. ```ts cordis-catalog -'agent/queued'(agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void +'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void ``` 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:289`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:326`](../../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). 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` — 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'(agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise +'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise ``` Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:385`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:442`](../../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, …). A pure NOTIFICATION (emit, not waterfall): it carries no veto — a session-start listener that wants to seed context does so via `agent.inject()` (a `context/message` the first request sees), not by returning a decision. Cannot block the session from starting; that gap is deliberate (a bridge logs/injects, it does not gate startup). ```ts cordis-catalog -'agent/session-start'(agent: Agent, source: SessionStartSource): void +'agent/session-start'(this: Scoped, agent: Agent, source: SessionStartSource): void ``` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:304`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:346`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit 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'(agent: Agent, status: AgentStatus): void +'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void ``` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:280`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:312`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall Waterfall: post-process the assembled assistant Message before tool dispatch (validation, content rewriting, …). ```ts cordis-catalog -'agent/step-result'(agent: Agent, turn: number, step: number, message: Message, next: () => Promise): Promise +'agent/step-result'(this: Scoped, agent: Agent, turn: number, step: number, message: Message, next: () => Promise): Promise ``` Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:395`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:457`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall 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'(agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise +'agent/turn-continuation'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise ``` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:408`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:475`](../../packages/core/agent/src/types.ts) ## `fs/*` @@ -203,35 +203,35 @@ Source: [`packages/llm/llm/src/index.ts:39`](../../packages/llm/llm/src/index.ts ### `session/created` — emit -A session was created in the store. +A session was created in the store. 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'(session: Session): void +'session/created'(this: Scoped, session: Session): void ``` -Source: [`packages/core/session/src/index.ts:39`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:47`](../../packages/core/session/src/index.ts) ### `session/event` — emit -An event was appended to a session log (sync, fire-and-forget). This is the per-append feed a UI or invariant plugin tails. +An event was appended to a session log (sync, fire-and-forget). This is the per-append feed a UI or invariant plugin tails. 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'(session: Session, event: SessionEvent): void +'session/event'(this: Scoped, session: Session, event: SessionEvent): void ``` Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:47`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:61`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel -Awaited durability checkpoint. The agent loop awaits `ctx.parallel('session/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 loop waits for all of them, but none can veto. +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'(session: Session): Promise | void +'session/flush'(this: Scoped, session: Session): Promise | void ``` -Source: [`packages/core/session/src/index.ts:57`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/src/index.ts) ## `subagent/*` @@ -282,56 +282,56 @@ Source: [`packages/subagent/subagent/src/index.ts:91`](../../packages/subagent/s 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: SystemPrompt, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise +'system-prompt/assemble'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:38`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:44`](../../packages/core/system-prompt/src/index.ts) ### `system-prompt/change` — emit -A section, tool provider, or variable provider was registered or unregistered (the assembly inputs changed). +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:44`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:54`](../../packages/core/system-prompt/src/index.ts) ## `tools/*` ### `tools/change` — emit -A tool was registered or unregistered (the available tool set changed). +A tool was registered or unregistered, or a scoped restriction changed (the available tool set 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 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:97`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:112`](../../packages/core/tools/src/index.ts) ### `tools/post-execute` — waterfall -Waterfall AFTER a tool runs — where hook plugins inspect the result and accept it (optionally REPLACING the model-facing content, and/or attaching `additionalContext` for the next request) or block it with corrective `feedback` (Claude Code's `PostToolUse`). Listeners receive `(exec, result, next)`: call `next()` to delegate to the default (accept unchanged), or return a PostToolDecision to override. The core tool dispatch sits between the two waterfalls as plain code, 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). +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. The core tool dispatch sits between the two waterfalls as plain code, 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: ToolRegistry, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise): Promise +'tools/post-execute'(this: Scoped, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise): Promise ``` Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:92`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:102`](../../packages/core/tools/src/index.ts) ### `tools/pre-execute` — waterfall -Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code's `PreToolUse`). Listeners receive `(exec, next)`: call `next()` to delegate to the default (allow), or return a PreToolDecision without calling `next()` to short-circuit. A `deny` skips dispatch and yields an `isError` result; the tool body never runs. Input rewrite is deliberately NOT offered here (see PreToolDecision); `ask` degrades to deny until the permission system lands (`FIXME(permissions)`). +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` degrades to deny until the permission system lands (`FIXME(permissions)`). 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/pre-execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise): Promise +'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise ``` Types: [ToolExecution](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:76`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:82`](../../packages/core/tools/src/index.ts) ## Inherited events (cordis core + loader/hmr/timer) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 26126c4481..47c080d9a7 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -21,7 +21,7 @@ createAgent(options: CreateAgentOptions): AgentHandle async resume(options: ResumeAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:68`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:70`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` @@ -38,7 +38,7 @@ list(): Agent[] Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:117`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:145`](../../packages/core/agent/src/index.ts) ## `ctx.bash` — `BashExecutor` (abstract seam) @@ -178,12 +178,13 @@ create(id?: SessionId, options?: CreateSessionOptions): Session prepare(id?: SessionId, options?: CreateSessionOptions): Session enter(session: Session): () => void announce(session: Session): void +async flush(session: Session): Promise get(id: SessionId): Session | undefined list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:405`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:427`](../../packages/core/session/src/index.ts) ## `ctx.subagents` — `SubagentService` @@ -204,27 +205,32 @@ Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, ```ts cordis-catalog section(section: PromptSection): () => void -tools(provider: () => ToolSchema[]): () => void +tools(provider: (context: AssembleContext) => ToolProviderResult): () => void variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void async assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:291`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:335`](../../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` → dispatch → `tools/post-execute` pipeline. The registry contributes its schemas into the system-prompt assembly. +Two registration layers (`@deepseek-ai/dsh-scope`): a registration through a plain plugin context is GLOBAL (visible to every agent); one through a scoped context (`agent.ctx`) is filed in that scope's layer — visible to that agent alone, disposed with the scope, and SHADOWING a global tool of the same name for that agent (most-specific-wins; within one layer a duplicate name still throws). restrict masks the global layer per scope. One visibility function (visible) feeds prompt assembly, get, and execute, so what the model is shown, what a presenter renders, and what dispatches can never disagree. + ```ts cordis-catalog register(definition: ToolDefinition): () => void -get(name: string): ToolDefinition | undefined -schemas(): ToolSchema[] +restrict(filter: ToolRestriction): () => void +visible(scope?: ScopeKey): ToolDefinition[] +get(name: string, scope?: ScopeKey): ToolDefinition | undefined +schemas(scope?: ScopeKey): ToolSchema[] +knownNames(scope?: ScopeKey): string[] async execute(exec: ToolExecution): Promise ``` Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:278`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:319`](../../packages/core/tools/src/index.ts) ## `ctx.web` — `WebService` diff --git a/docs/module-graph.md b/docs/module-graph.md index 9461c746d2..d17352fa27 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -89,13 +89,16 @@ flowchart TD pkg_llm_pi_ai --> pkg_llm pkg_session --> pkg_brand pkg_session --> pkg_llm + pkg_session --> pkg_scope pkg_system_prompt --> pkg_llm + pkg_system_prompt --> pkg_scope pkg_bash_local --> pkg_bash pkg_fs --> pkg_brand pkg_fs --> pkg_llm pkg_web --> pkg_llm pkg_agent --> pkg_brand pkg_agent --> pkg_llm + pkg_agent --> pkg_scope pkg_agent --> pkg_session pkg_agent --> pkg_system_prompt pkg_fs_local --> pkg_fs @@ -113,6 +116,7 @@ flowchart TD pkg_llm_replay --> pkg_session pkg_tools --> pkg_agent pkg_tools --> pkg_llm + pkg_tools --> pkg_scope pkg_tools --> pkg_system_prompt pkg_compact_basic --> pkg_agent pkg_compact_basic --> pkg_compact @@ -127,6 +131,7 @@ flowchart TD pkg_invariants --> pkg_session pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_llm + pkg_agent_loop --> pkg_scope pkg_agent_loop --> pkg_session pkg_agent_loop --> pkg_session_persistence pkg_agent_loop --> pkg_system_prompt @@ -220,12 +225,12 @@ flowchart TD | [`bash`](../packages/bash/bash) | `bash` | [`brand`](../packages/util/brand) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`llm`](../packages/llm/llm) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`llm`](../packages/llm/llm) | -| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | -| [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm) | +| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | -| [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | +| [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) | | [`compact`](../packages/compact/compact) | `compact` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -236,12 +241,12 @@ flowchart TD | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt) | +| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt) | | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`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), [`session`](../packages/core/session) | -| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | From 67cb9a591d8790919d804c481912d6da856c7f9b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:36:14 +0800 Subject: [PATCH 05/64] docs: teach graph generator the scoped-dispatch spellings; sync Agent type-equiv block The producer/consumer matrix reads dispatch sites statically; the fused agentEvents dispatcher, the agent's loopCtx handle, the session store's captured emitCtx, and carrier-first argument lists were invisible to it, silently dropping agent-loop/session as producers of every scoped event. The generator now recognizes those spellings; the Agent type-equiv doc block gains readonly ctx. --- docs/core-data-structures/core.md | 8 +++++++ docs/event-producer-consumer.md | 38 +++++++++++++++---------------- scripts/gen-doc-graphs.ts | 17 ++++++++++++-- 3 files changed, 42 insertions(+), 21 deletions(-) diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 615c222d94..158a52795f 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -244,6 +244,14 @@ interface Agent { readonly session: Session readonly status: AgentStatus + /** + * The agent's scope context (`@deepseek-ai/dsh-scope`, key = this agent): + * registrations through it — tools, prompt sections/variables, listeners, + * restrictions — are visible to this agent only and unwind when it is + * disposed; `agent.ctx.on('agent/…')` listeners fire only for this agent. + */ + readonly ctx: Context + /** Queue a user message. Starts a turn when idle; otherwise waits for the next turn. */ send(content: ContentBlock[], options?: SendOptions): void diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index c10d7caa52..3306a22cef 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,32 +7,32 @@ 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:264`](../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:271`](../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:420`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:349`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:362`](../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/queued` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:385`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:304`](../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) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:280`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:395`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:408`](../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/created` | `emit` | [`packages/core/agent/src/types.ts:286`](../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:298`](../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:492`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:396`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:414`](../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/queued` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:442`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:346`](../packages/core/agent/src/types.ts) | - | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:312`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:457`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:475`](../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) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `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:39`](../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:47`](../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:57`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../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:61`](../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:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:98`](../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:72`](../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:83`](../packages/subagent/subagent/src/index.ts) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:91`](../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:38`](../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:44`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:97`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:92`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:76`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:44`](../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:54`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:112`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:102`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:82`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | Maintenance mode: hybrid generated: Cordis event declarations and most producer/listener edges are AST-scanned; dynamic dispatch sites are classified in `scripts/gen-doc-graphs.ts`. diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 6440a672be..54f52ead18 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -520,7 +520,15 @@ function collectEventRelations(): Map { function isCordisContextReceiver(expr: ts.PropertyAccessExpression, sf: ts.SourceFile): boolean { const target = expr.expression.getText(sf) - return target === 'ctx' || target === 'this.ctx' + if (target === 'ctx' || target === 'this.ctx') return true + // Scoped-dispatch spellings (the agent-scoping seam): the loop's fused + // dispatcher (`events` from `agentEvents(ctx, agent)`), the agent's own + // context handle (`this.loopCtx`), and the session store's captured + // dispatch context (`emitCtx`). Conventional receiver names, pinned by the + // fused-dispatch convention; a rename here must update this list (the + // producer/consumer matrix silently losing a dispatcher is the failure + // mode this list exists to prevent). + return target === 'events' || target === 'this.loopCtx' || target === 'emitCtx' } function eventArg(args: ts.NodeArray, method: string): string | undefined { @@ -529,7 +537,12 @@ function eventArg(args: ts.NodeArray, method: string): string | u return arg?.text } const first = args[0] - return first && ts.isStringLiteralLike(first) ? first.text : undefined + if (first && ts.isStringLiteralLike(first)) return first.text + // Scope-carrier dispatch: `emit(carrier, 'event/name', …)` puts the event + // name second. Accept a string literal in position 1 when position 0 is a + // non-literal expression (the carrier). + const second = args[1] + return second && ts.isStringLiteralLike(second) ? second.text : undefined } function relationPackages(map: Map>, pkgsByShort: Map): string { From 15f4d1cd03a1686dee729a74b97a1ce6db703f64 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:10:06 +0800 Subject: [PATCH 06/64] feat(subagent): persona + toolFilter become real; structured runtime collapses to scoped registrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SubagentStartRequest gains persona (capability-gated like toolFilter); the in-process driver composes the child's scoped world in the factory's setup window — persona as a scoped shadowing deployment:persona section, toolFilter as a scoped tools.restrict() (loud unknown-name validation), outputSchema as the scoped structured runtime. spawn/fork now advertise every start-time capability; ACP stays all-false. A parent-scope teardown effect links each child to its parent through the memoized handle, so a disposed parent reaches its whole subtree even if the delegating tool's finally never runs; subagent/start|end dispatch in the delegating parent's scope. structured.ts loses the placeholder schema, the final-assembly swap/strip, the refcounted root runtime, and the WeakMap state: each child registers its OWN capture tool (real schema), instruction section, and enforcement listeners on child.ctx, riding the child's fiber. The commit listener is call-keyed (a stale stage from a short-circuited post-execute chain is dropped, never promoted on a later call), and one scoped prepend re-assert listener preserves the final-assembly guarantee against a stripping global listener. tool-subagent gains persona/toolFilter/maxDepth passthrough config — deny-listing the delegation tool (or maxDepth) is how a deployment bounds recursion; the omitted-toolFilter schema key is forced absent (a materialized {} would mean an empty allow-list, i.e. deny-everything). --- packages/subagent/subagent-acp/src/index.ts | 2 +- .../subagent-acp/tests/subagent-acp.spec.ts | 2 +- packages/subagent/subagent-fork/src/index.ts | 6 +- .../subagent-fork/tests/subagent-fork.spec.ts | 4 +- .../subagent/subagent-inprocess/src/index.ts | 73 +++- .../subagent-inprocess/src/structured.ts | 400 ++++++------------ .../tests/structured.spec.ts | 206 ++------- packages/subagent/subagent-spawn/src/index.ts | 11 +- .../tests/subagent-spawn.spec.ts | 65 ++- packages/subagent/subagent/src/index.ts | 24 +- packages/subagent/subagent/src/types.ts | 15 +- .../subagent/subagent/tests/service.spec.ts | 4 +- packages/subagent/tool-subagent/src/index.ts | 44 +- .../tool-subagent/tests/tool-subagent.spec.ts | 14 +- packages/support/subagent-mock/src/index.ts | 3 +- 15 files changed, 398 insertions(+), 475 deletions(-) diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index b2ddd9ab2d..dd0aeb5aea 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -89,7 +89,7 @@ type ResolvedConfig = Required> & Pick * a request needing any of them before `start` runs). */ class AcpProvider implements SubagentProvider { - readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false } + readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false, persona: false } // Context contract: an out-of-process ACP child starts fresh — no parent conversation crosses the process boundary. readonly inheritsParentContext = false diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 3eb12fac38..6bf3413485 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -530,7 +530,7 @@ describe('dsh-subagent-acp', () => { it('advertises no start-time capabilities (out-of-process child)', async () => { const ctx = await setup() const provider = ctx.subagents.getProvider('acp')! - expect(provider.capabilities).toEqual({ outputSchema: false, depthLimit: false, toolFilter: false }) + expect(provider.capabilities).toEqual({ outputSchema: false, depthLimit: false, toolFilter: false, persona: false }) }) it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index d8c77a03ac..de650fa4cd 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -64,11 +64,11 @@ export function completedTurnPrefix(parent: Agent): SessionEvent[] { /** * The fork provider. Supports `depthLimit` and `outputSchema` (via the shared - * in-process structured runtime); NOT `toolFilter` this cut (the service - * rejects a request needing it before `start` runs). + * in-process structured runtime), plus `toolFilter`/`persona` (scoped + * restrict() and a scoped shadowing persona section). */ class ForkProvider implements SubagentProvider { - readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: false } + readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true } // Context contract: a forked child IS seeded with the parent's completed-turn prefix. readonly inheritsParentContext = true diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index 90e2d583d8..5356eba6e0 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -182,9 +182,9 @@ describe('dsh-subagent-fork', () => { await run.dispose() }) - it('advertises depthLimit and outputSchema but not toolFilter', async () => { + it('advertises every start-time capability (depthLimit, outputSchema, toolFilter, persona)', async () => { const { ctx } = await setup([]) - expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: false }) + expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }) }) it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 9026954b97..889a8afb5d 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -21,13 +21,13 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' import { - acquireStructuredRuntime, - type StructuredAcquisition, + attachStructuredRuntime, + type StructuredAttachment, } from './structured.ts' -// The runtime itself (acquire/attach/release) is package-internal: runs -// acquire it inside startInProcessRun, and no other package drives it. Only -// the model-facing vocabulary is public. +// The runtime itself (attach) is package-internal: runs attach it inside +// startInProcessRun's setup window, and no other package drives it. Only the +// model-facing vocabulary is public. export { STRUCTURED_OUTPUT_TOOL, STRUCTURED_OUTPUT_INSTRUCTION, @@ -143,21 +143,36 @@ export function startInProcessRun( const seedLength = options.seed?.length ?? 0 const parentHeader = request.parent.session.header // Inherit the parent's model by default (a child with no model cannot run); - // an explicit `request.agentOptions.model` overrides it. The persona needs - // no inheritance: the deployment persona is a context-wide prompt section, - // so parent and child render the same one. A structured run's - // structured_output instruction is NOT prompt state either — the structured - // runtime's final-request listener appends it per request (see structured.ts). + // an explicit `request.agentOptions.model` overrides it. The deployment + // persona needs no inheritance (a context-wide section both render); a + // per-child `request.persona` becomes a SCOPED section of the same name in + // the setup below, shadowing the deployment's for this child alone. const agentOptions: AgentOptions = { ...request.parent.options.model !== undefined ? { model: request.parent.options.model } : {}, ...request.agentOptions, subagentDepth: childDepth, } - // The structured runtime is held for the WHOLE run (acquired before the child - // exists, released when the result settles), so a backend hot-reload mid-run - // cannot unregister the capture tool out from under this live child. - const structured: StructuredAcquisition | undefined = schema !== undefined ? acquireStructuredRuntime(ctx) : undefined + // The child's scoped world, composed in the factory's setup window (after + // the child's scope exists and it is registered, before agent/session-start + // and the first prompt assembly; a throw here unwinds the half-created + // child inside the factory's rollback boundary): + // - persona: a scoped `deployment:persona` section shadowing the global one; + // - toolFilter: a scoped restrict() masking the global tool surface + // (loud unknown-name validation lives in the registry); + // - outputSchema: the structured runtime, attached as scoped registrations. + let structured: StructuredAttachment | undefined + const setup = (childCtx: Context): void => { + if (request.persona !== undefined) { + childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: request.persona }) + } + if (request.toolFilter !== undefined) { + childCtx.tools.restrict(request.toolFilter) + } + if (schema !== undefined) { + structured = attachStructuredRuntime(childCtx, schema) + } + } const handle: AgentHandle = ctx.agents.create({ agentId: childId, @@ -171,9 +186,26 @@ export function startInProcessRun( }, ...options.seed !== undefined ? { seed: options.seed } : {}, agentOptions, + setup, }) const child = handle.agent - if (structured && schema !== undefined) structured.attach(child, schema) + + // Structured-concurrency link: the child's teardown rides the PARENT's + // scope, so a disposed parent reaches its whole subtree even if the + // delegating tool's `finally` never runs — through the MEMOIZED handle, so + // every path (tool finally, parent teardown, owner unload) observes the + // same quiescence boundary. Registered AFTER the child exists; if the + // parent began disposing in between, the registration throws + // INACTIVE_EFFECT — dispose the fresh child before rethrowing (no orphan). + // Definite assignment: the catch rethrows, so past this block the unlink + // disposer always exists. + let unlink!: () => Promise | void + try { + unlink = request.parent.ctx.effect(() => () => handle.dispose()) + } catch (error: unknown) { + void handle.dispose() + throw error + } // Bridge the request's abort signal to the child (the consumer also bridges // its own exec.signal, but a backend-level bridge keeps the contract local). @@ -205,13 +237,9 @@ export function startInProcessRun( // Deliberately NO re-prompt when a structured child finishes cleanly // without calling structured_output: readResult maps that to `error` — // the shortfall goes to the parent instead of buying extra model turns. - return readResult(child, seedLength, isCancelled(), structured ? { captured: structured.captured(child) } : undefined) + return readResult(child, seedLength, isCancelled(), structured ? { captured: structured.captured() } : undefined) } finally { request.signal?.removeEventListener('abort', onAbort) - if (structured) { - structured.detach(child) - structured.release() - } } })() @@ -223,6 +251,11 @@ export function startInProcessRun( }, async dispose(): Promise { request.signal?.removeEventListener('abort', onAbort) + // Through the parent-scope unlink when the parent is still live (one + // disposal path, and the dead effect leaves the parent's list); the + // memoized handle keeps a direct dispose equivalent if the parent's + // teardown already ran the unlink. + await unlink() await handle.dispose() }, } diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index a557e44785..9d8b4cb70b 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -1,63 +1,49 @@ /** - * Structured-output support for the in-process subagent backends: the mechanism - * behind `SubagentStartRequest.outputSchema` for children that run as agents on - * the same context. + * Structured-output support for the in-process subagent backends: the + * mechanism behind `SubagentStartRequest.outputSchema` for children that run + * as agents on the same context. * - * The model-facing surface is one globally registered `structured_output` tool - * whose REGISTERED parameters are a placeholder — the real schema is per run. - * Because the tool registry and prompt assembly are context-global while - * schemas differ per child (two concurrent structured runs may carry different - * schemas), per-agent shaping happens on the `system-prompt/assemble` - * waterfall with a `prepend: true` listener that post-processes `await next()` - * — FINAL-ASSEMBLY enforcement: whatever downstream listeners mutated or - * replaced, the assembly the loop renders never carries `structured_output` - * for an agent without a structured run, and for one that has it always - * carries the run's OWN schema plus a trailing - * {@link STRUCTURED_OUTPUT_INSTRUCTION} section (the demand travels with the - * tool). The loop logs what the assembly produced as the request header, so - * the injection is a reconstructable fact of the session log, never a - * wire-only mutation (the reconstructability RFC). - * (Cooperative mutate-then-`next()` would not survive a downstream listener - * returning a replacement assembly — see the waterfall composition caveat in - * docs/architecture.md.) + * Everything is a SCOPED registration on the child agent's context + * (`child.ctx`, the dsh-scope seam): the `structured_output` capture tool + * carries the run's REAL schema as its registered parameters (each child sees + * exactly its own schema — two concurrent structured runs never interact), the + * demand instruction is an ordinary order-190 scoped section, and the + * enforcement listeners fire only for this child (scope-filtered dispatch). + * Registration lifetime rides the child's fiber, so a backend hot-reload + * mid-run cannot unregister the capture tool out from under a live child, and + * a disposed child leaves no residue — no placeholder schema, no + * strip-for-everyone-else, no refcounted global runtime, no `WeakMap` state. * - * FIXME: the whole enforcement dance above exists because the tool registry - * and prompt assembly are context-global. If they become per-agent or - * per-session scoped, a structured run just registers its own schema'd tool on - * the child's scope and this module reduces to the capture tool plus the - * turn-stop — no placeholder, no final-assembly swap, no strip-for-everyone- - * else, no global-registration lifetime dance. + * Four listeners enforce the contract: * - * A companion `agent/turn-continuation` listener stops a child's turn once its - * output is captured — without it, the loop's default "had tool calls ⇒ - * continue" buys a wasted extra model step per structured child. It is also - * `prepend: true`: the veto must run before any earlier-registered listener - * that could short-circuit the chain into a forced continue. A third listener - * closes the within-step window the continuation veto cannot: a - * `tools/pre-execute` deny for any call arriving after the agent's capture, so - * a response that lists `structured_output` before further tool calls cannot - * run side effects after the final answer was accepted. A fourth, - * `tools/post-execute`, is the capture COMMIT: the tool body only stages the - * validated value, and it becomes the run's captured result only when the - * final post-execute decision accepts the call — a blocking hook downstream - * yields `isError` in the log, and the run must not report success for it. - * - * Lifetime is refcounted by structured RUNS: each acquires from start to - * settle, so the registrations exist exactly while at least one structured - * child is live — a plain deployment that never passes `outputSchema` carries - * no always-on global state, and a backend hot-reload mid-run cannot - * unregister the capture tool out from under a live child (the run holds its - * own acquisition). Registrations land on the ROOT context and the refcount - * disposes them when the last run settles; the next structured run - * re-registers them. + * - `system-prompt/assemble` (prepend, scoped): FINAL-ASSEMBLY re-assert — + * whatever downstream listeners mutated or replaced, the child's assembly + * always carries its capture tool and the trailing instruction section. The + * registry already contributes both; this outermost wrapper preserves the + * guarantee against a (global) listener that strips or replaces the + * assembly. The loop logs the rendered assembly as the request header, so + * the demand is reconstructable log state, never a wire-only mutation. + * - `agent/turn-continuation` (prepend, scoped): stop the child's turn once + * its output is captured — the loop's default "had tool calls ⇒ continue" + * would buy a wasted extra model step per structured child. + * - `tools/pre-execute` (prepend, scoped): terminal means terminal WITHIN the + * step — deny every call arriving after the capture, so a response that + * lists `structured_output` before further tool calls cannot run side + * effects after the final answer was accepted. + * - `tools/post-execute` (prepend, scoped): the capture COMMIT. The tool body + * only STAGES the validated value, KEYED BY CALL ID; it becomes the run's + * captured result only when the final post-execute decision accepts THAT + * call. Call-keyed staging closes a stale-stage hole: an outer + * short-circuiting post-execute listener can orphan a staged value, and an + * un-keyed commit would then promote it on a LATER call's acceptance — + * reporting success for a value the model saw fail. * * @module @deepseek-ai/dsh-subagent-inprocess/structured */ import type { Context } from 'cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' -import type { ContinuationDecision } from '@deepseek-ai/dsh-agent' +import type { Agent, ContinuationDecision } from '@deepseek-ai/dsh-agent' +import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import type { AssembleContext, PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } from '@deepseek-ai/dsh-tools' @@ -66,247 +52,141 @@ import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } f export const STRUCTURED_OUTPUT_TOOL = 'structured_output' /** - * The instruction the assembly listener appends to a structured child's - * system prompt as a trailing section on every assembly. Per-assembly state, - * NOT agent prompt state: `AgentOptions` has no prompt field (the persona is - * deployment config on the system-prompt plugin), so the same final-assembly - * enforcement that injects the schema'd tool carries the instruction that - * demands calling it. + * The instruction registered as the child's trailing (order-190, the end of + * the tool-guidance band) scoped prompt section: the demand travels with the + * tool, as ordinary prompt state of exactly one agent. */ export const STRUCTURED_OUTPUT_INSTRUCTION = 'When you have your final answer, you MUST report it by calling the ' + `\`${STRUCTURED_OUTPUT_TOOL}\` tool with arguments matching its parameter schema exactly. ` + 'Do not finish with a plain text answer: only the tool call counts as your result.' -/** One structured run's state: the schema to enforce and the captured value, once recorded. */ -interface RunState { - readonly schema: StructuredOutputSchema +/** One structured run's live handle: read the captured value once the child settles. */ +export interface StructuredAttachment { /** - * A validated value awaiting the post-execute verdict on ITS OWN call. Set - * by the capture tool's body, promoted to {@link RunState.captured} only - * when the final `tools/post-execute` decision accepts the call — a - * downstream block turns the logged result into `isError`, and a value - * committed at body time would let the run report success for a call the - * model saw fail. + * The captured value, once the child called the tool with valid arguments + * and the final post-execute decision accepted that call. + * @returns the committed value, or undefined while none was accepted. */ - pending?: { value: unknown } - captured?: { value: unknown } -} - -/** The per-root-context runtime: run states plus the shared registrations. */ -interface StructuredRuntime { - refs: number - readonly states: WeakMap - readonly disposers: (() => void)[] -} - -/** One root context ⇒ one runtime (multi-app test isolation). */ -const runtimes = new WeakMap() - -/** - * One holder's handle on the shared structured runtime. `release()` is - * idempotent per acquisition; the runtime's registrations are disposed when the - * LAST holder (backend plugin or live run) releases. - */ -export interface StructuredAcquisition { - /** Enforce `schema` on `agent`'s requests and start capturing its `structured_output` call. */ - attach(agent: Agent, schema: StructuredOutputSchema): void - /** The captured value, once the child called the tool with valid arguments. */ - captured(agent: Agent): { value: unknown } | undefined - /** Stop enforcing/capturing for `agent` (WeakMap-backed; safe to call twice). */ - detach(agent: Agent): void - /** Drop this holder's reference (idempotent); the last release unregisters everything. */ - release(): void + captured(): { value: unknown } | undefined } /** - * Acquire the per-root-context structured runtime, registering the capture tool - * and the runtime's listeners on the FIRST acquisition. See the module doc - * for the enforcement and lifetime design. - * @param ctx - any context of the app; the runtime keys off `ctx.root`. - * @returns this holder's handle (attach/captured/detach + idempotent release). + * Attach the structured-output runtime to a child for `schema`: register the + * scoped capture tool (real schema), the scoped instruction section, and the + * four scoped enforcement listeners (see the module doc). Call from the + * agent-creation `setup` window with the child's scope context — every + * registration rides the child's fiber and unwinds with the child. + * @param childCtx - the child agent's scope context (`setup`'s argument). + * @param schema - the isolation-cloned, already-asserted schema subset to + * enforce (see `assertSupportedOutputSchema` in dsh-tools). + * @returns the attachment handle (read `captured()` after the child settles). */ -export function acquireStructuredRuntime(ctx: Context): StructuredAcquisition { - const root: Context = ctx.root - let runtime = runtimes.get(root) - if (!runtime) { - runtime = { refs: 0, states: new WeakMap(), disposers: [] } - runtimes.set(root, runtime) - registerRuntime(root, runtime) - } - runtime.refs += 1 +export function attachStructuredRuntime(childCtx: Context, schema: StructuredOutputSchema): StructuredAttachment { + /** A validated value staged by the capture tool body, awaiting ITS OWN call's post-execute verdict. */ + let pending: { callId: CallId; value: unknown } | undefined + let captured: { value: unknown } | undefined - let released = false - return { - attach(agent: Agent, schema: StructuredOutputSchema): void { - runtime.states.set(agent, { schema }) - }, - captured(agent: Agent): { value: unknown } | undefined { - return runtime.states.get(agent)?.captured - }, - detach(agent: Agent): void { - runtime.states.delete(agent) - }, - release(): void { - if (released) return - released = true - runtime.refs -= 1 - if (runtime.refs > 0) return - runtimes.delete(root) - for (const dispose of runtime.disposers.splice(0)) dispose() - }, + const schemaEntry: ToolSchema = { + name: STRUCTURED_OUTPUT_TOOL, + description: + 'Report your final structured result. Call this exactly once, when your answer is complete; ' + + 'the arguments must match this tool\'s parameter schema exactly.', + // ToolSchema.parameters is the wire-level JSON Schema object; the + // asserted subset type is structurally exactly that. + parameters: schema as unknown as Record, } -} -/** Register the capture tool + the two listeners on the root context (first acquire). */ -function registerRuntime(root: Context, runtime: StructuredRuntime): void { - // The registered parameters are a PLACEHOLDER: the request listener below - // swaps in the run's real schema per child, and strips the tool entirely for - // every agent without a structured run — so this shape is never model-visible. - // - // Registration does NOT ride on the acquiring backend's plugin-level - // `inject`: a backend that waited on `tools` would apply later than it did - // before this module existed, shifting when its PROVIDER registers — and the - // delegation tool mirrors provider lifecycle, so that shift would reorder - // the model-visible tool list of every existing prompt. Instead the capture - // tool registers synchronously when `tools` is already live (the common - // case), and through a scoped inject fiber when the Loader happens to start - // the backend first. Either way the registration lands on root and is - // disposed by the runtime's refcount; disposing the fiber also covers the - // never-activated case. - let disposeTool: (() => void) | undefined - const registerCapture = (tools: Context['tools']): void => { - disposeTool = tools.register({ - name: STRUCTURED_OUTPUT_TOOL, - description: - 'Report your final structured result. Call this exactly once, when your answer is complete; ' - + 'the arguments must match this tool\'s parameter schema exactly.', - parameters: { type: 'object', properties: {} }, - execute(args: unknown, exec: ToolExecution): Promise { - const state = exec.agent ? runtime.states.get(exec.agent) : undefined - if (!state) { - // Reachable only if a non-structured agent somehow calls the tool (the - // request listener strips it, so the model never sees it) — fail loud - // rather than capture into nowhere. - throw new Error(`${STRUCTURED_OUTPUT_TOOL} is only available to subagents started with an output schema`) - } - const violations = validateStructuredValue(state.schema, args) - // ToolArgsError → isError result with INVALID_ARGS: the model retries - // within the same turn, exactly like a schema-validated defineTool call. - if (violations.length > 0) throw new ToolArgsError(violations) - // Two-phase commit: the body only STAGES the value; the post-execute - // listener below promotes it once the final decision accepts the call. - state.pending = { value: args } - return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }]) - }, - }) - } - const liveTools = root.get('tools') - const toolsFiber = liveTools ? undefined : root.inject(['tools'], (childCtx: Context) => { - registerCapture(childCtx.root.tools) - }) - if (liveTools) registerCapture(liveTools) - runtime.disposers.push(() => { - disposeTool?.() - void toolsFiber?.dispose() + childCtx.tools.register({ + ...schemaEntry, + execute(args: unknown, exec: ToolExecution): Promise { + const violations = validateStructuredValue(schema, args) + // ToolArgsError → isError result with INVALID_ARGS: the model retries + // within the same turn, exactly like a schema-validated defineTool call. + if (violations.length > 0) throw new ToolArgsError(violations) + // Two-phase commit, KEYED BY THIS CALL: the body only stages; the + // post-execute listener promotes exactly this call's entry when the + // final decision accepts it. + pending = { callId: exec.callId, value: args } + return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }]) + }, }) - // FINAL-ASSEMBLY enforcement (prepend: true = first registered = OUTERMOST - // wrapper): post-process whatever the downstream listeners and the registry - // produced, so a downstream listener returning a replacement assembly cannot - // leak the tool to other agents or erase the child's schema. The loop logs - // the rendered assembly as the step's request header, so the swap is - // reconstructable log state, never a wire-only mutation. - runtime.disposers.push(root.on('system-prompt/assemble', async function ( - this: unknown, _assembly: PromptAssembly, context: AssembleContext, next: () => Promise, + childCtx.systemPrompt.section({ + name: `tool:${STRUCTURED_OUTPUT_TOOL}`, + order: 190, + text: STRUCTURED_OUTPUT_INSTRUCTION, + }) + + // FINAL-ASSEMBLY re-assert (prepend = outermost): scoped dispatch means this + // fires only for the child's assemblies; `await next()` returns whatever the + // downstream chain (and any replacement assembly) produced, and the capture + // tool + instruction are re-asserted onto it if anything stripped them. + childCtx.on('system-prompt/assemble', async function ( + this: unknown, _assembly: PromptAssembly, _context: AssembleContext, next: () => Promise, ): Promise { const final = await next() - const state = context.agent ? runtime.states.get(context.agent) : undefined - if (state) { - const schemaEntry: ToolSchema = { - name: STRUCTURED_OUTPUT_TOOL, - description: - 'Report your final structured result. Call this exactly once, when your answer is complete; ' - + 'the arguments must match this tool\'s parameter schema exactly.', - // ToolSchema.parameters is the wire-level JSON Schema object; the - // asserted subset type is structurally exactly that. - parameters: state.schema as unknown as Record, - } - final.tools = [...final.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL), schemaEntry] - // The demand travels WITH the tool: a trailing section in the - // tool-guidance order band, appended after next() so it renders last - // (renderPrompt joins in array order). + if (!final.tools.some(tool => tool.name === STRUCTURED_OUTPUT_TOOL)) { + final.tools = [...final.tools, { ...schemaEntry, parameters: structuredClone(schemaEntry.parameters) }] + } + if (!final.sections.some(section => section.name === `tool:${STRUCTURED_OUTPUT_TOOL}`)) { final.sections = [...final.sections, { name: `tool:${STRUCTURED_OUTPUT_TOOL}`, order: 190, text: STRUCTURED_OUTPUT_INSTRUCTION }] - return final } - // No structured run: strip the placeholder so it is never model-visible. - // An empty tools array canonicalizes to an absent header/wire field - // (canonicalHeader pins empty ≡ absent), so no re-shaping is needed here. - final.tools = final.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL) return final - }, { prepend: true })) + }, { prepend: true }) - // Stop a structured child's turn once its output is captured: the default - // "had tool calls ⇒ continue" would otherwise buy a wasted extra model step - // after every successful capture. `prepend: true` puts the veto OUTERMOST — - // an earlier-registered listener that short-circuits the chain (a goal-style - // force-continue returning without `next()`) would otherwise decide the turn - // before this listener ever ran, and no downstream decision may resurrect a - // structured turn that is already finished. - runtime.disposers.push(root.on('agent/turn-continuation', function ( - this: unknown, agent: Agent, _turn: number, _decision: ContinuationDecision, next: () => Promise, + // Stop the child's turn once its output is captured. `prepend: true` puts + // the veto OUTERMOST — an earlier-registered listener that short-circuits + // the chain (a goal-style force-continue returning without `next()`) would + // otherwise decide the turn before this listener ever ran, and no + // downstream decision may resurrect a structured turn that is finished. + childCtx.on('agent/turn-continuation', function ( + this: unknown, _agent: Agent, _turn: number, _decision: ContinuationDecision, next: () => Promise, ): Promise { - if (runtime.states.get(agent)?.captured) return Promise.resolve({ action: 'stop' }) + if (captured) return Promise.resolve({ action: 'stop' }) return next() - }, { prepend: true })) + }, { prepend: true }) - // The capture COMMIT: promote the staged value only when the final - // post-execute decision accepts the call. The capture tool's body cannot - // decide — `tools/post-execute` runs after it, and a blocking listener (a - // PostToolUse hook) turns the logged result into `isError` feedback; a value - // committed at body time would make readResult report `structured` success - // for a call whose result the model and session log saw fail. `prepend: - // true` = outermost at registration time, so `await next()` returns the - // COMPOSED downstream decision — the same final verdict the registry maps - // onto the result. (A later-registered outer listener that blocks without - // delegating skips this commit entirely: the staged value is dropped and the - // run errors — failure-safe in the same direction.) The staging slot clears - // on every path, including a rejecting downstream listener. - runtime.disposers.push(root.on('tools/post-execute', async function ( - this: unknown, exec: ToolExecution, _result: ToolExecutionResult, next: () => Promise, - ): Promise { - const state = exec.agent ? runtime.states.get(exec.agent) : undefined - if (!state || exec.name !== STRUCTURED_OUTPUT_TOOL || state.pending === undefined) return next() - const pending = state.pending - try { - const decision = await next() - if (decision.kind === 'accept') state.captured = pending - return decision - } finally { - delete state.pending - } - }, { prepend: true })) - - // Terminal means terminal WITHIN the step, not only at its end: the - // turn-continuation veto above runs after every call in the current model - // response has executed, so a response that puts `structured_output` before - // further tool calls would still perform those side effects after the final - // answer was accepted. Deny every later call for a captured agent at the - // allow/deny gate — dispatch is skipped and the model sees an `isError` - // result naming the contract. Calls that PRECEDE the capture in the same - // response ran before `captured` was set and are untouched; a second - // `structured_output` is denied like any other call. `prepend: true` for the - // same reason as the continuation veto: no earlier-registered allow may - // short-circuit past the terminal contract. - runtime.disposers.push(root.on('tools/pre-execute', function ( + // Terminal WITHIN the step: deny every call after the capture. Calls that + // PRECEDE the capture in the same response ran before `captured` was set + // and are untouched; a second `structured_output` is denied like any other. + childCtx.on('tools/pre-execute', function ( this: unknown, exec: ToolExecution, next: () => Promise, ): Promise { - if (exec.agent && runtime.states.get(exec.agent)?.captured) { + if (captured) { return Promise.resolve({ kind: 'deny', reason: `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`, }) } return next() - }, { prepend: true })) + }, { prepend: true }) + + // The capture COMMIT: promote the staged value only when the final + // post-execute decision accepts THE SAME CALL that staged it. The staging + // slot clears on every path for that call; a stale entry from an outer + // short-circuited chain (its verdict never reached us) is dropped when any + // later call reaches the commit, never promoted. + childCtx.on('tools/post-execute', async function ( + this: unknown, exec: ToolExecution, _result: ToolExecutionResult, next: () => Promise, + ): Promise { + if (exec.name !== STRUCTURED_OUTPUT_TOOL || pending === undefined) return next() + if (pending.callId !== exec.callId) { + // A stale stage from a different call: an outer listener short-circuited + // that call's post-execute chain past this commit, so its verdict never + // reached us and the value must never be promoted — drop it. + pending = undefined + return next() + } + const staged = pending + try { + const decision = await next() + if (decision.kind === 'accept') captured = { value: staged.value } + return decision + } finally { + if (pending === staged) pending = undefined + } + }, { prepend: true }) + + return { captured: () => captured } } diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 0de036a0a0..3d676c3848 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -5,7 +5,7 @@ import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' -import type { Agent, ContinuationDecision } from '@deepseek-ai/dsh-agent' +import type { ContinuationDecision } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' @@ -13,7 +13,6 @@ import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { startInProcessRun } from '../src/index.ts' import { - acquireStructuredRuntime, STRUCTURED_OUTPUT_INSTRUCTION, STRUCTURED_OUTPUT_TOOL, } from '../src/structured.ts' @@ -47,7 +46,7 @@ async function setup(script: Script) { await ctx.plugin(SubagentService) const disposeProvider = ctx.subagents.registerProvider({ name: 'spawn', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: false }, + capabilities: { outputSchema: true, depthLimit: true, toolFilter: false, persona: false }, inheritsParentContext: false, start: (request: SubagentStartRequest) => startInProcessRun(ctx, request, { providerName: 'spawn' }), }) @@ -175,31 +174,20 @@ describe('in-process structured output', () => { }) it('the captured-turn veto is prepend: an EARLIER force-continue listener cannot short-circuit it', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - // Registered BEFORE the structured runtime exists — without prepend, this - // goal-style listener would decide the turn first (returning WITHOUT - // calling next()) and the veto would never run. + // A goal-style listener registered BEFORE the child exists, returning a + // forced continue WITHOUT calling next(). Without prepend on the scoped + // veto, this would decide the turn first and buy a wasted model step — + // the one-response script would then throw on the second request. + const { ctx, parent, adapter } = await setup([ + toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }), + ]) ctx.on('agent/turn-continuation', () => Promise.resolve({ action: 'continue' })) - const acquisition = acquireStructuredRuntime(ctx) - const agent = { id: AgentId('structured-child') } as unknown as Agent - acquisition.attach(agent, SCHEMA) - const captured = await ctx.tools.execute({ - callId: 'call-1' as never, - name: STRUCTURED_OUTPUT_TOOL, - arguments: { answer: 1 }, - agent, - }) - expect(captured.isError).toBeFalsy() - const decision = await ctx.waterfall( - 'agent/turn-continuation', agent, 1, - { action: 'continue' }, - () => Promise.resolve({ action: 'continue' }), - ) - expect(decision).toEqual({ action: 'stop' }) - acquisition.detach(agent) - acquisition.release() + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const result = await run.result + expect(result.structured).toEqual({ answer: 7 }) + expect(result.stopReason).toBe('completed') + expect(adapter.requests).toHaveLength(1) + await run.dispose() }) it('an invalid call gets an INVALID_ARGS isError result and the model retries in-turn', async () => { @@ -357,20 +345,14 @@ describe('in-process structured output', () => { await run.dispose() }) - describe('final-request enforcement (the prepend agent/request listener)', () => { - it('a plain agent assembling while the runtime is LIVE gets the placeholder stripped', async () => { - // Run-scoped acquisition means a plain deployment never registers the - // tool at all; the strip branch exists for the CONCURRENT case — a plain - // agent taking a turn while some structured child holds the runtime open. + describe('scoped registration (each child owns its capture tool)', () => { + it('a plain agent never sees the tool: nothing is registered globally at all', async () => { const { ctx, parent, adapter } = await setup([textResponse('parent answer')]) - const hold = acquireStructuredRuntime(ctx) parent.send([{ type: 'text', text: 'hello' }]) await parent.whenIdle() - // The placeholder IS in the registry during this turn; the assembly the - // loop rendered must not carry it for an agent without a structured run. - expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined() + // Scoped registration: the global view has no capture tool, ever. + expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined() expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL) - hold.release() }) it('a structured child sees structured_output with ITS schema; a plain agent never sees the tool', async () => { @@ -430,16 +412,21 @@ describe('in-process structured output', () => { await runB.dispose() }) - it('wins against a downstream listener that REPLACES the assembly object', async () => { + it('the re-assert wins against a downstream listener that REPLACES the assembly object', async () => { const { ctx, parent, adapter } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }), ]) - // A downstream (non-prepend) listener that returns a brand-new assembly — - // the composition caveat that erases cooperative mutations. Registered - // AFTER the runtime's prepend listener, so it runs INSIDE it. + // A global (every-assembly) listener that returns a brand-new assembly + // WITHOUT the capture tool or instruction — the composition caveat that + // erases cooperative mutations. The child's prepend re-assert runs + // OUTERMOST and restores both. ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { const replaced = await next() - return { sections: [...replaced.sections], tools: [...replaced.tools], variables: { ...replaced.variables } } + return { + sections: replaced.sections.filter(section => section.name !== `tool:${STRUCTURED_OUTPUT_TOOL}`), + tools: replaced.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL), + variables: { ...replaced.variables }, + } }) const run = ctx.subagents.start('spawn', structuredRequest(parent)) const result = await run.result @@ -447,140 +434,41 @@ describe('in-process structured output', () => { const entry = adapter.requests[0]!.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL) expect(entry).toBeDefined() expect(entry!.parameters).toEqual(SCHEMA) + const system = adapter.requests[0]!.system ?? '' + expect(system).toContain(STRUCTURED_OUTPUT_INSTRUCTION) await run.dispose() }) it('a non-structured agent request keeps tools ABSENT when it had none (no tools: [] materialized)', async () => { - const { parent, adapter } = await setup([ - // The registry contributes the placeholder via prompt assembly, so - // tools is an array in the raw request — but after stripping the - // placeholder (its ONLY entry), the field must not be re-added as a - // different shape. - textResponse('plain'), - ]) + const { parent, adapter } = await setup([textResponse('plain')]) parent.send([{ type: 'text', text: 'q' }]) await parent.whenIdle() const request = adapter.requests[0]! - expect(toolNames(request)).not.toContain(STRUCTURED_OUTPUT_TOOL) + expect(request.tools).toBeUndefined() await new Promise(resolve => setTimeout(resolve, 0)) }) - it('shapes a bare assembly on the waterfall: no-agent context strips the placeholder; a structured agent gains schema + trailing instruction section', async () => { - // Drive ctx.systemPrompt.assemble directly — the enforcement listener - // must tolerate a context with NO agent (a bare diagnostic assemble) - // and shape a structured agent's assembly on the same path the loop - // renders and logs as the request header. - const { ctx, parent } = await setup([]) - const acquisition = acquireStructuredRuntime(ctx) - // Bare assemble WHILE the runtime is live: the no-agent branch must - // strip the registered placeholder (before the acquisition there is - // nothing to strip — run-scoped registration). - const bare = await ctx.systemPrompt.assemble({}) - expect(bare.tools.map(tool => tool.name)).not.toContain(STRUCTURED_OUTPUT_TOOL) - - acquisition.attach(parent, SCHEMA) - const shaped = await ctx.systemPrompt.assemble({ agent: parent }) - expect(shaped.tools.map(tool => tool.name)).toContain(STRUCTURED_OUTPUT_TOOL) - expect(shaped.tools.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!.parameters).toEqual(SCHEMA) - // The demand travels with the tool: the instruction renders LAST - // (appended post-next(); renderPrompt joins in array order). - expect(shaped.sections.at(-1)).toMatchObject({ name: `tool:${STRUCTURED_OUTPUT_TOOL}`, text: STRUCTURED_OUTPUT_INSTRUCTION }) - acquisition.detach(parent) - acquisition.release() - }) - }) - - describe('runtime lifetime (refcount: live structured runs)', () => { - it('the runtime exists exactly while structured runs are live: nothing before, nothing after', async () => { - const { ctx, parent } = await setup([ + it('registrations ride the child fiber: disposing the run removes them; a provider reload mid-run cannot', async () => { + const { ctx, parent, disposeProvider } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 4 }), ]) - // No always-on global state: a context that has run no structured child - // carries no capture tool. expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined() const run = ctx.subagents.start('spawn', structuredRequest(parent)) + // A backend hot-reload mid-run must not unregister the capture tool out + // from under the live child: the registration rides the CHILD's fiber. + disposeProvider() const result = await run.result - // The capture succeeded — the registrations existed while the run lived. expect(result.structured).toEqual({ answer: 4 }) - // The run's settle released the last acquisition. - expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined() + const child = ctx.agents.get(run.id)! + expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL, child)).toBeDefined() await run.dispose() - }) - - it('concurrent structured runs share one runtime; the last settle disposes it', async () => { - const { ctx, parent } = await setup([ - toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }), - toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, { answer: 2 }), - ]) - const first = ctx.subagents.start('spawn', structuredRequest(parent)) - const second = ctx.subagents.start('spawn', structuredRequest(parent)) - const [a, b] = await Promise.all([first.result, second.result]) - expect([a.structured, b.structured].sort()).toEqual([{ answer: 1 }, { answer: 2 }].sort()) - expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined() - await first.dispose() - await second.dispose() - }) - - it('acquisition release is idempotent (double release cannot underflow the refcount)', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - const first = acquireStructuredRuntime(ctx) - const second = acquireStructuredRuntime(ctx) - first.release() - first.release() - // The second holder still keeps the tool registered. - expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined() - second.release() - expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined() - }) - - it('registers the capture tool through the scoped fiber when tools loads after the acquisition', async () => { - // The Loader starts sibling plugins concurrently, so a backend can - // acquire the runtime before dsh-tools has applied. The capture tool - // must then register as soon as `tools` exists — via the inject fiber, - // not by deferring the backend (which would reorder the prompt's tools). - const ctx = new Context() - const acquisition = acquireStructuredRuntime(ctx) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - // Fiber activation completes asynchronously after the service appears. - await new Promise(resolve => setImmediate(resolve)) - expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeDefined() - acquisition.release() - expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined() - }) - - it('releasing before tools ever loads disposes the pending fiber without registering', async () => { - const ctx = new Context() - const acquisition = acquireStructuredRuntime(ctx) - acquisition.release() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await new Promise(resolve => setImmediate(resolve)) - // The disposed fiber never fires: nothing registers after the fact. - expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined() - }) - - it('attach/captured/detach manage per-agent state through the acquisition surface', async () => { - const { ctx, parent } = await setup([]) - const acquisition = acquireStructuredRuntime(ctx) - expect(acquisition.captured(parent)).toBeUndefined() - acquisition.attach(parent, SCHEMA) - expect(acquisition.captured(parent)).toBeUndefined() - acquisition.detach(parent) - acquisition.detach(parent) - acquisition.release() - // That manual acquisition was the ONLY holder - release disposes. - expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined() + // Child disposed ⇒ its scoped registrations are gone. + expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL, child)).toBeUndefined() }) }) - it('a direct structured_output call from an agent WITHOUT a structured run is an isError', async () => { + it('a structured_output call from an agent WITHOUT a structured run is UNKNOWN_TOOL (the tool does not exist for it)', async () => { const { ctx, parent } = await setup([]) - // Hold the runtime open (run-scoped: nothing is registered otherwise) so - // the call reaches the capture tool's own fail-loud guard, not UNKNOWN_TOOL. - const hold = acquireStructuredRuntime(ctx) const result = await ctx.tools.execute({ callId: 'x' as never, name: STRUCTURED_OUTPUT_TOOL, @@ -588,19 +476,17 @@ describe('in-process structured output', () => { agent: parent, }) expect(result.isError).toBe(true) - expect(JSON.stringify(result.content)).toContain('only available to subagents') - hold.release() + expect(result.error?.code).toBe('UNKNOWN_TOOL') }) - it('a structured_output call with NO calling agent at all is an isError', async () => { + it('a structured_output call with NO calling agent at all is UNKNOWN_TOOL', async () => { const { ctx } = await setup([]) - const hold = acquireStructuredRuntime(ctx) const result = await ctx.tools.execute({ callId: 'x' as never, name: STRUCTURED_OUTPUT_TOOL, arguments: { answer: 1 }, }) expect(result.isError).toBe(true) - hold.release() + expect(result.error?.code).toBe('UNKNOWN_TOOL') }) }) diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts index 2d8f118b4e..53fa5967fd 100644 --- a/packages/subagent/subagent-spawn/src/index.ts +++ b/packages/subagent/subagent-spawn/src/index.ts @@ -43,13 +43,14 @@ export const Config: z = z.object({ }) /** - * The spawn provider. Supports `depthLimit` (it constructs the child, so it can - * enforce a recursion cap) and `outputSchema` (via the shared in-process - * structured runtime); NOT `toolFilter` in this cut — a request that needs it - * is rejected by the service before `start` runs. + * The spawn provider. Supports every start-time capability: `depthLimit` (it + * constructs the child, so it can enforce a recursion cap), `outputSchema` + * (the scoped structured runtime), and `toolFilter`/`persona` (scoped + * `restrict()` and a scoped shadowing persona section, applied in the child's + * creation window). */ class SpawnProvider implements SubagentProvider { - readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: false } + readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true } // Context contract: a spawned child starts fresh — it never sees the parent conversation. readonly inheritsParentContext = false diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 1dad9748e9..0d3bc57d26 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -241,10 +241,10 @@ describe('dsh-subagent-spawn', () => { await parentHandle.dispose() }) - it('advertises depthLimit and outputSchema but not toolFilter', async () => { + it('advertises every start-time capability (depthLimit, outputSchema, toolFilter, persona)', async () => { const { ctx } = await setup([]) const provider = ctx.subagents.getProvider('spawn')! - expect(provider.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: false }) + expect(provider.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }) }) it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { @@ -316,4 +316,65 @@ describe('dsh-subagent-spawn', () => { expect(unwrapped.inject).toEqual(['subagents', 'agents']) expect(typeof unwrapped.apply).toBe('function') }) + + describe('persona and toolFilter (the scoped child world)', () => { + it('a per-child persona shadows the deployment persona in the child request only', async () => { + const { ctx, parent, adapter } = await setup([ + textResponse('parent answer'), + textResponse('child answer'), + ]) + parent.send([{ type: 'text', text: 'hi' }]) + await parent.whenIdle() + + const run = ctx.subagents.start('spawn', { + prompt: [{ type: 'text', text: 'do X' }], + parent, + persona: 'You are the tersest test runner.', + }) + await run.result + const childRequest = adapter.requests.at(-1)! + expect(childRequest.system).toContain('You are the tersest test runner.') + // The parent's earlier request carried no such persona. + expect(adapter.requests[0]!.system ?? '').not.toContain('tersest test runner') + await run.dispose() + }) + + it('toolFilter hides denied tools from the child prompt AND refuses their execution', async () => { + const { ctx, parent, adapter } = await setup([ + // The child tries the denied tool anyway, then answers. + toolCallResponse('c1', 'forbidden_tool', {}), + textResponse('done'), + ]) + ctx.tools.register({ + name: 'forbidden_tool', description: 'global', parameters: {}, + execute: () => Promise.resolve([{ type: 'text', text: 'ran' }]), + }) + const run = ctx.subagents.start('spawn', { + prompt: [{ type: 'text', text: 'do X' }], + parent, + toolFilter: { deny: ['forbidden_tool'] }, + }) + const result = await run.result + expect(result.stopReason).toBe('completed') + // Not advertised… + const childRequest = adapter.requests[0]! + expect((childRequest.tools ?? []).map(t => t.name)).not.toContain('forbidden_tool') + // …and the attempted call executed as UNKNOWN_TOOL (visible in the log). + const child = ctx.agents.get(run.id)! + const toolResult = child.session.events.find(e => e.type === 'tool/result')! + expect(JSON.stringify(toolResult.data)).toContain('unknown tool') + await run.dispose() + }) + + it('an unknown toolFilter name fails the spawn loudly with no orphaned child', async () => { + const { ctx, parent } = await setup([]) + const before = ctx.agents.list().length + expect(() => ctx.subagents.start('spawn', { + prompt: [{ type: 'text', text: 'do X' }], + parent, + toolFilter: { deny: ['no_such_tool'] }, + })).toThrow(/unknown tool "no_such_tool"/) + expect(ctx.agents.list().length).toBe(before) + }) + }) }) diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 91230b7ff4..fc78efa674 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -33,9 +33,10 @@ */ import { Context, Service } from 'cordis' +import { scopeTarget } from '@deepseek-ai/dsh-scope' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { AgentId } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentId } from '@deepseek-ai/dsh-agent' import type { SubagentCapabilities, SubagentProvider, @@ -223,7 +224,7 @@ export class SubagentService extends Service { // acceptable. `ctx.emit` halts the dispatch on the first throw, so a single // surrounding try/catch is not enough — each listener is invoked and // contained individually. - this.emitLifecycle('subagent/start', { provider: name, id: run.id }) + this.emitLifecycle('subagent/start', { provider: name, id: run.id }, request.parent) // Emit `subagent/end` when the run settles. The result promise does not // reject on a child-level failure (it resolves with stopReason 'error'), // so a rejection here is an infrastructure fault — surface its stop reason @@ -253,9 +254,9 @@ export class SubagentService extends Service { } catch (error: unknown) { this.ctx.logger.warn(`subagent: could not clone ${name} output for subagent/end: ${String(error)}`) } - this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason, ...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {} }) + this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason, ...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {} }, request.parent) }, - () => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }) }, + () => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }, request.parent) }, ) return run } @@ -280,14 +281,22 @@ export class SubagentService extends Service { * listener unwinds the yielded rollback — the same fail-loud register-time * semantics as the system-prompt registries. */ - private emitLifecycle(name: 'subagent/start', info: SubagentRunInfo): void - private emitLifecycle(name: 'subagent/end', info: SubagentRunEndInfo): void + private emitLifecycle(name: 'subagent/start', info: SubagentRunInfo, parent: Agent): void + private emitLifecycle(name: 'subagent/end', info: SubagentRunEndInfo, parent: Agent): void private emitLifecycle(name: 'subagent/provider-removed', info: string): void private emitLifecycle( name: 'subagent/start' | 'subagent/end' | 'subagent/provider-removed', info: SubagentRunInfo | SubagentRunEndInfo | string, + parent?: Agent, ): void { - for (const callback of this.ctx.events.dispatch('emit', [name, info])) { + // Run lifecycle events dispatch in the DELEGATING PARENT's scope (a + // parent-scoped listener observes only its own delegations); the + // provider-removed registry notification stays unfiltered. The carrier is + // args[0] of the dispatch call, exactly as cordis' own emit spells it. + const dispatchArgs: unknown[] = parent === undefined + ? [name, info] + : [scopeTarget(this, parent), name, info] + for (const callback of this.ctx.events.dispatch('emit', dispatchArgs)) { try { callback(info) } catch (error: unknown) { @@ -306,6 +315,7 @@ export class SubagentService extends Service { { when: request.outputSchema !== undefined, cap: 'outputSchema' }, { when: request.maxDepth !== undefined, cap: 'depthLimit' }, { when: request.toolFilter !== undefined, cap: 'toolFilter' }, + { when: request.persona !== undefined, cap: 'persona' }, ] for (const { when, cap } of needs) { if (when && !provider.capabilities[cap]) { diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index fb512c0bdb..5e6553c48a 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -29,6 +29,8 @@ export interface SubagentCapabilities { depthLimit: boolean /** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */ toolFilter: boolean + /** Honor {@link SubagentStartRequest.persona} (a per-child persona). */ + persona: boolean } /** @@ -73,9 +75,20 @@ export interface SubagentStartRequest { maxDepth?: number /** * Optional child tool scoping. Requires {@link SubagentCapabilities.toolFilter}; - * rejected at start otherwise. + * rejected at start otherwise. In-process backends apply it as a scoped + * `tools.restrict()` in the child's creation window: the named tools vanish + * from the child's prompt AND refuse to execute (one visibility), with loud + * unknown-name validation. */ toolFilter?: { allow?: string[]; deny?: string[] } + /** + * Optional per-child persona. Requires {@link SubagentCapabilities.persona}; + * rejected at start otherwise. In-process backends register it as a scoped + * `deployment:persona` section on the child, SHADOWING the deployment's + * persona for this child alone — same template semantics as the deployment + * persona (strict `{{…}}` interpolation against the registered variables). + */ + persona?: string } /** diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index f70abf7e72..66c3e80042 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -16,8 +16,8 @@ function fakeParent(id = 'parent-1'): Agent { return { id: AgentId(id) } as unknown as Agent } -const ALL_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true } -const NO_CAPS: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false } +const ALL_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: false } +const NO_CAPS: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false, persona: false } /** A scripted provider whose run settles immediately with a fixed result. */ class StubProvider implements SubagentProvider { diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index f48ef4345e..c305ea40db 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -54,11 +54,35 @@ export interface Config { toolName?: string /** * Default per-child agent options (model) applied to every spawned child. - * Omitted fields fall back to the child loop's own defaults. There is no - * per-child persona: the deployment persona (the system-prompt plugin's - * `persona` config) is a context-wide section every agent shares. + * Omitted fields fall back to the child loop's own defaults. */ agentOptions?: AgentOptions + /** + * Per-child persona applied to every child this tool spawns: a scoped + * `deployment:persona` section shadowing the deployment's persona for the + * child alone. Requires the bound provider's `persona` capability + * (in-process backends support it; a request against one that doesn't is + * rejected at start). Omitted ⇒ the child renders the deployment persona. + */ + persona?: string + /** + * Tool scoping applied to every child this tool spawns (see + * `SubagentStartRequest.toolFilter`): the named global tools vanish from + * the child's prompt AND refuse to execute. Requires the provider's + * `toolFilter` capability. Unknown names fail the spawn loudly. Note the + * child otherwise sees every global tool — including this delegation tool + * itself; `deny`-listing it (or setting `maxDepth`) is how a deployment + * bounds recursion. + */ + toolFilter?: { allow?: string[]; deny?: string[] } + /** + * 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). + */ + maxDepth?: number } export const Config: z = z.object({ @@ -67,6 +91,17 @@ export const Config: z = z.object({ agentOptions: z.object({ model: z.string(), }), + persona: z.string(), + // A schemastery object materializes {} (with [] for nested arrays) when the + // key is omitted — for toolFilter that would mean an EMPTY ALLOW-LIST, i.e. + // deny-everything, silently. Force the omitted key to stay absent (the same + // shape discipline as SystemPrompt's toolOrder); the cast is needed because + // .default() expects the object type. + toolFilter: z.object({ + allow: z.array(z.string()), + deny: z.array(z.string()), + }).default(undefined as unknown as { allow: string[]; deny: string[] }), + maxDepth: z.number(), }) /** @@ -179,6 +214,9 @@ export function apply(ctx: Context, config: Config): void { parent, ...exec.signal ? { signal: exec.signal } : {}, ...config.agentOptions ? { agentOptions: config.agentOptions } : {}, + ...config.persona !== undefined ? { persona: config.persona } : {}, + ...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {}, + ...config.maxDepth !== undefined ? { maxDepth: config.maxDepth } : {}, } const run: SubagentRun = ctx.subagents.start(config.provider, request) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 611069ef76..90f3e0f931 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -111,7 +111,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentService) ctx.subagents.registerProvider({ name: 'weird', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: () => ({ id: AgentId('weird-child'), @@ -137,7 +137,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentService) ctx.subagents.registerProvider({ name: 'capture', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: (request) => { seen = request @@ -167,7 +167,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentService) ctx.subagents.registerProvider({ name: 'bare', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: (request) => { seen = request @@ -297,7 +297,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentService) ctx.subagents.registerProvider({ name: 'spy', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: () => ({ id: AgentId('spy-child'), @@ -320,7 +320,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentService) ctx.subagents.registerProvider({ name: 'spy', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: () => ({ id: AgentId('spy-child'), @@ -344,7 +344,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentService) ctx.subagents.registerProvider({ name: 'spy', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: () => { let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void @@ -391,7 +391,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentService) ctx.subagents.registerProvider({ name: 'spy', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: () => { let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void diff --git a/packages/support/subagent-mock/src/index.ts b/packages/support/subagent-mock/src/index.ts index e2effe5b4b..e72765ba4d 100644 --- a/packages/support/subagent-mock/src/index.ts +++ b/packages/support/subagent-mock/src/index.ts @@ -26,7 +26,7 @@ import type { const STOP_REASONS = ['completed', 'aborted', 'error', 'max-tokens', 'refusal'] as const -const DEFAULT_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true } +const DEFAULT_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true } /** * A scripted provider: every {@link start} returns a run whose `result` @@ -111,6 +111,7 @@ export const Config: z = z.object({ outputSchema: z.boolean(), depthLimit: z.boolean(), toolFilter: z.boolean(), + persona: z.boolean(), }), inheritsParentContext: z.boolean(), structured: z.any(), From 1ac785734995d25c36ed21dcfe671354c3fc0be4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:11:21 +0800 Subject: [PATCH 07/64] feat(invariants): scoped-dispatch carrier/subject checks and the setup-drives tripwire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three dev-mode invariants close the leak-by-default regression class at runtime: (1) every scope-filtered event family must dispatch with a scope carrier — a bare dispatch throws at the call site naming the carrier rule; (2) where the subject is recoverable from the arguments (agent/*, the tool pipeline, prompt assembly) the carrier's key must BE that subject, and an assembly context must never carry agent without scope (use assembleContextFor); (3) a turn/start logged before the owning agent's agent/session-start is the setup-drives teaching error (setup registers the scoped world, it never drives the agent). --- packages/support/invariants/package.json | 6 ++ packages/support/invariants/src/index.ts | 90 ++++++++++++++++ .../invariants/tests/invariants.spec.ts | 100 ++++++++++++++---- packages/support/invariants/tsconfig.json | 9 ++ pnpm-lock.yaml | 9 ++ 5 files changed, 196 insertions(+), 18 deletions(-) diff --git a/packages/support/invariants/package.json b/packages/support/invariants/package.json index 97d6160b03..4ea36f66de 100644 --- a/packages/support/invariants/package.json +++ b/packages/support/invariants/package.json @@ -24,13 +24,19 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index f35fbafa1d..d4b772b317 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -20,6 +20,9 @@ */ import type { Context } from 'cordis' +import { carrierKeyOf, isScopeCarrier } from '@deepseek-ai/dsh-scope' +import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt' +import type { ToolExecution } from '@deepseek-ai/dsh-tools' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { CallId, GenerateOptions } from '@deepseek-ai/dsh-llm' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' @@ -362,6 +365,93 @@ export function apply(ctx: Context, config: Config = {}): void { lastStatus.set(agent, status) }) + // --- Scoped-dispatch invariants (the agent-scoping seam) --------------- + // + // Every scope-filtered event family must dispatch with a scope carrier + // (scopeTarget) whose key IS the subject the event's arguments name — + // a dispatch without one silently reverts that event to global delivery + // (agent-scoped listeners over-hear foreign agents), and a mis-keyed one + // delivers to the wrong agent's listeners. `internal/dispatch` fires + // synchronously before listener delivery, so a violation throws at the + // dispatching call site. The table maps each family to how its subject is + // read from the event arguments; `null` = the subject is not recoverable + // from the arguments (session events key by the OWNING agent; subagent + // lifecycle events key by the delegating parent), so only carrier + // PRESENCE is asserted there. + const scopedSubject: Record unknown) | null> = { + 'agent/created': args => args[0], + 'agent/disposed': args => args[0], + 'agent/status': args => args[0], + 'agent/queued': args => args[0], + 'agent/session-start': args => args[0], + 'agent/pre-step': args => args[0], + 'agent/prompt-submit': args => args[0], + 'agent/request': args => args[0], + 'agent/step-result': args => args[0], + 'agent/turn-continuation': args => args[0], + 'agent/error': args => args[0], + 'tools/pre-execute': args => (args[0] as ToolExecution).agent, + 'tools/post-execute': args => (args[0] as ToolExecution).agent, + 'system-prompt/assemble': args => (args[1] as AssembleContext).scope, + 'session/created': null, + 'session/event': null, + 'session/flush': null, + 'subagent/start': null, + 'subagent/end': null, + } + ctx.on('internal/dispatch', (_mode, name, args, thisArg) => { + const subjectOf = scopedSubject[name] + if (subjectOf === undefined) return + if (!isScopeCarrier(thisArg)) { + throw new InvariantError( + `"${name}" is a scope-filtered event but was dispatched without a scope carrier — ` + + 'pass scopeTarget(base, subject) as the dispatch thisArg (agent events: use agentEvents(ctx, agent))') + } + if (subjectOf !== null && carrierKeyOf(thisArg) !== subjectOf(args)) { + throw new InvariantError( + `"${name}" was dispatched with a scope carrier keyed to a DIFFERENT subject than its arguments name — ` + + 'the carrier key and the event\'s subject must be the same object (use agentEvents(ctx, agent))') + } + // The assembly context must never carry the agent DX field without the + // scope layer selector: the assembly would silently miss the agent's + // scoped sections/tools (use assembleContextFor(agent)). + if (name === 'system-prompt/assemble') { + const context = args[1] as AssembleContext + if (context.agent !== undefined && context.scope !== context.agent) { + throw new InvariantError( + 'an assembly context carries `agent` without `scope` (or with a mismatched scope) — ' + + 'use assembleContextFor(agent) so the assembly resolves the agent\'s scoped layer') + } + } + }, { global: true }) + + // --- Setup-drives invariant --------------------------------------------- + // + // CreateAgentOptions.setup REGISTERS the agent's scoped world; it must not + // DRIVE the agent — an inject() there opens a turn before + // `agent/session-start`, inverting the "session-start fires before the + // first turn" contract every bridge keys on. A turn/start appended to a + // live agent's session before its agent/session-start fired is therefore a + // creation-time misuse, reported at the appending call site. Sessions of + // agents that exist BEFORE this plugin applies are marked started (their + // ordering is unknowable after the fact — never a false positive on HMR). + // `agents` is read via ctx.get (a strict, optional store lookup) rather + // than injected: the invariants plugin must load in harnesses that carry + // no agent registry at all (bare session tests), where this check simply + // never trips. + const sessionStarted = new WeakSet() + for (const agent of ctx.get('agents')?.list() ?? []) sessionStarted.add(agent.session) + ctx.on('agent/session-start', (agent) => { sessionStarted.add(agent.session) }) + ctx.on('session/event', (session, event) => { + if (event.type !== 'turn/start' || sessionStarted.has(session)) return + const owner = ctx.get('agents')?.list().find(agent => agent.session === session) + if (owner === undefined) return + throw new InvariantError( + `agent "${owner.id}": a turn opened before agent/session-start fired — ` + + 'CreateAgentOptions.setup registers the scoped world, it must not drive the agent ' + + '(send/steer/inject belong after creation returns)') + }) + // Request-reconstruction cross-check (the reconstructability RFC): a // loop-built request — frozen envelope + live sessionId is the marker; a // hand-built one-shot (compaction summarize) is unfrozen and skipped — must diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index 489cfb9817..98e5d675fa 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' +import { scopeTarget } from '@deepseek-ai/dsh-scope' import { CallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -41,8 +42,8 @@ describe('session-log invariants', () => { const session = ctx.sessions.create() // Session.append enforces seq-contiguity at the source, so drive the // invariants seq check directly via session/event with a regressing seq. - ctx.emit('session/event', session, { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } as never) - expect(() => { ctx.emit('session/event', session, { type: 'turn/end', seq: 0, time: 2, data: { turn: 1, reason: { kind: 'completed' } } } as never) }) + ctx.emit(scopeTarget(session, undefined), 'session/event', session, { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } as never) + expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, { type: 'turn/end', seq: 0, time: 2, data: { turn: 1, reason: { kind: 'completed' } } } as never) }) .toThrow(/seq must strictly increase/) }) @@ -340,11 +341,11 @@ describe('dev-freeze', () => { // handler directly via hand-built session/events — exactly the shape the // invariants listener receives. Open a turn first (seq 0) so the cyclic // user/message (seq 1) satisfies the turn-enclosure invariant. - ctx.emit('session/event', session, { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } as never) + ctx.emit(scopeTarget(session, undefined), 'session/event', session, { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } as never) const cyclic: Record = { type: 'text', text: 'x' } cyclic['self'] = cyclic const event = { type: 'user/message', seq: 1, time: 1, data: { content: [cyclic], source: { kind: 'user' } } } - expect(() => { ctx.emit('session/event', session, event as never) }).not.toThrow() + expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, event as never) }).not.toThrow() expect(Object.isFrozen(cyclic)).toBe(true) }) }) @@ -354,41 +355,41 @@ describe('agent status invariants', () => { const { ctx } = await setup({ freeze: false }) const agent = mockAgent('a1') expect(() => { - ctx.emit('agent/status', agent, 'idle') - ctx.emit('agent/status', agent, 'running') - ctx.emit('agent/status', agent, 'idle') - ctx.emit('agent/status', agent, 'disposed') + ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') + ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') + ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') + ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed') }).not.toThrow() }) it('accepts running→disposed', async () => { const { ctx } = await setup({ freeze: false }) const agent = mockAgent('a2') - ctx.emit('agent/status', agent, 'running') - expect(() => { ctx.emit('agent/status', agent, 'disposed') }).not.toThrow() + ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') + expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed') }).not.toThrow() }) it('rejects a no-op transition', async () => { const { ctx } = await setup({ freeze: false }) const agent = mockAgent('a3') - ctx.emit('agent/status', agent, 'running') - expect(() => { ctx.emit('agent/status', agent, 'running') }).toThrow(/no-op transition/) + ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') + expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') }).toThrow(/no-op transition/) }) it('rejects leaving the terminal disposed state', async () => { const { ctx } = await setup({ freeze: false }) const agent = mockAgent('a4') - ctx.emit('agent/status', agent, 'disposed') - expect(() => { ctx.emit('agent/status', agent, 'idle') }).toThrow(/left terminal state disposed/) + ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed') + expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') }).toThrow(/left terminal state disposed/) }) it('tracks status per agent independently', async () => { const { ctx } = await setup({ freeze: false }) const a = mockAgent('a5') const b = mockAgent('b5') - ctx.emit('agent/status', a, 'running') + ctx.emit(scopeTarget(a, a), 'agent/status', a, 'running') // b's first observation is independent of a. - expect(() => { ctx.emit('agent/status', b, 'running') }).not.toThrow() + expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', b, 'running') }).not.toThrow() }) }) @@ -406,8 +407,8 @@ describe('HMR safety', () => { expect(Object.isFrozen(event)).toBe(false) // A no-op status transition no longer throws either. const agent = mockAgent('hmr') - ctx.emit('agent/status', agent, 'idle') - expect(() => { ctx.emit('agent/status', agent, 'idle') }).not.toThrow() + ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') + expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') }).not.toThrow() }) it('InvariantError carries a stable code', () => { @@ -780,3 +781,66 @@ describe('request cross-check ordering (prepend)', () => { }).toThrow(/diverges from the boundary derivation/) }) }) + +describe('scoped-dispatch invariants', () => { + async function scopedCtx() { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(Invariants) + return ctx + } + + it('rejects a scoped-family dispatch without a carrier (teaching error)', async () => { + const ctx = await scopedCtx() + const agent = { id: 'a1' } as unknown as Agent + expect(() => { ctx.emit('agent/error', agent, 1, 0, new Error('x')) }) + .toThrow(/dispatched without a scope carrier/) + }) + + it('rejects a carrier keyed to a different subject than the arguments name', async () => { + const ctx = await scopedCtx() + const agent = { id: 'a1' } as unknown as Agent + const other = { id: 'a2' } as unknown as Agent + expect(() => { ctx.emit(scopeTarget(agent, other), 'agent/error', agent, 1, 0, new Error('x')) }) + .toThrow(/keyed to a DIFFERENT subject/) + // The correct spelling passes. + expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/error', agent, 1, 0, new Error('x')) }) + .not.toThrow() + }) + + it('rejects an assembly context carrying agent without scope', async () => { + const ctx = await scopedCtx() + const agent = { id: 'a1' } as unknown as Agent + const base = { name: 'systemPrompt' } + const assembly = { sections: [], tools: [], variables: {} } + const bad = { agent } + expect(() => { + // The carrier base stands in for the SystemPrompt service (the declared `this`); the invariant only reads the carrier marks. + void ctx.waterfall(scopeTarget(base, undefined) as never, 'system-prompt/assemble', assembly as never, bad as never, () => Promise.resolve(assembly as never)) + }).toThrow(/agent.*without.*scope|assembleContextFor/) + const good = { agent, scope: agent } + expect(() => { + void ctx.waterfall(scopeTarget(base, agent) as never, 'system-prompt/assemble', assembly as never, good as never, () => Promise.resolve(assembly as never)) + }).not.toThrow() + }) + + it('rejects a turn opened before agent/session-start (setup drives the agent)', async () => { + const ctx = await scopedCtx() + // A live agent whose session is in the store but whose session-start has + // not fired: appending turn/start must throw the teaching error. + const session = ctx.sessions.create(SessionId('drive-s')) + const agent = { id: 'driver', session } as unknown as Agent + // Provide a minimal agents lookup: the invariant reads ctx.get('agents'). + const registryStub = { list: () => [agent] } + ctx.root.provide('agents', registryStub as never) + expect(() => { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + }).toThrow(/turn opened before agent\/session-start/) + // After session-start fires, turns open freely. + ctx.emit(scopeTarget(agent, agent), 'agent/session-start', agent, 'startup') + expect(() => { + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + }).not.toThrow() + }) +}) diff --git a/packages/support/invariants/tsconfig.json b/packages/support/invariants/tsconfig.json index 8dca14c786..88944f779e 100644 --- a/packages/support/invariants/tsconfig.json +++ b/packages/support/invariants/tsconfig.json @@ -22,6 +22,15 @@ }, { "path": "../../core/agent" + }, + { + "path": "../../core/scope" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../core/tools" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9238bbea1e..3ffc2719d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -799,9 +799,18 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) From f91eb39538d33ebfdae933d6230aa6702365d539 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:18:39 +0800 Subject: [PATCH 08/64] docs: regenerate catalogs and sync subagent type-equiv blocks for persona/toolFilter --- docs/config-catalog.md | 37 +++++++++++++++++--- docs/cordis-catalog/events.md | 8 ++--- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/subagent.md | 4 ++- docs/event-producer-consumer.md | 16 ++++++--- docs/module-graph.md | 11 +++--- packages/subagent/tool-subagent/src/index.ts | 7 +++- 7 files changed, 65 insertions(+), 20 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index afa88564c4..7aed35bd4d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -271,7 +271,7 @@ export interface Config { } ``` -Source: [`packages/support/invariants/src/index.ts:45`](../packages/support/invariants/src/index.ts) +Source: [`packages/support/invariants/src/index.ts:48`](../packages/support/invariants/src/index.ts) ## `@deepseek-ai/dsh-llm-deepseek` @@ -644,11 +644,40 @@ export interface Config { toolName?: string /** * Default per-child agent options (model) applied to every spawned child. - * Omitted fields fall back to the child loop's own defaults. There is no - * per-child persona: the deployment persona (the system-prompt plugin's - * `persona` config) is a context-wide section every agent shares. + * Omitted fields fall back to the child loop's own defaults. */ agentOptions?: AgentOptions + /** + * Per-child persona applied to every child this tool spawns: a scoped + * `deployment:persona` section shadowing the deployment's persona for the + * child alone. Requires the bound provider's `persona` capability + * (in-process backends support it; a request against one that doesn't is + * rejected at start). Omitted ⇒ the child renders the deployment persona. + */ + persona?: string + /** + * Tool scoping applied to every child this tool spawns (see + * `SubagentStartRequest.toolFilter`): the named global tools vanish from + * the child's prompt AND refuse to execute. Requires the provider's + * `toolFilter` capability. Unknown names fail the spawn loudly. Note the + * child otherwise sees every global tool — including this delegation tool + * itself; `deny`-listing it (or setting `maxDepth`) is how a deployment + * bounds recursion. + */ + toolFilter?: { + /** Global tool names the child keeps; everything else is removed. */ + allow?: string[] + /** Global tool names removed from the child. */ + deny?: string[] + } + /** + * 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). + */ + maxDepth?: number } ``` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index ec418b0576..c748f30690 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -243,7 +243,7 @@ A subagent run settled — emitted when SubagentRun.result resolves (any stop re 'subagent/end'(info: SubagentRunEndInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:98`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:99`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -253,7 +253,7 @@ A provider became resolvable in the SubagentService registry. Consumers that der 'subagent/provider-added'(provider: SubagentProvider): void ``` -Source: [`packages/subagent/subagent/src/index.ts:72`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:73`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -263,7 +263,7 @@ A provider left the registry (its plugin's fiber was disposed — an unload or a 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:83`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:84`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit @@ -273,7 +273,7 @@ A subagent run started — emitted after the provider is resolved and its capabi 'subagent/start'(info: SubagentRunInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:91`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:92`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 47c080d9a7..8ebc65c5b3 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -197,7 +197,7 @@ list(): string[] start(name: string, request: SubagentStartRequest): SubagentRun ``` -Source: [`packages/subagent/subagent/src/index.ts:144`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:145`](../../packages/subagent/subagent/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index f21ef15669..6f2e23e37e 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -15,12 +15,13 @@ interface SubagentCapabilities { outputSchema: boolean depthLimit: boolean toolFilter: boolean + persona: boolean } ``` ## The start request -What a caller asks for when starting a subagent. The tool layer builds this from the model's `{ description, prompt }` plus its own config; the service validates the start-time capabilities against the named provider, then passes it to `provider.start`. `parent` is REQUIRED — in-process backends read `parent.session.header` for the working directory, the `parentSession` lineage, and the delegation depth. The three optional fields (`outputSchema`, `maxDepth`, `toolFilter`) each gate on the matching `SubagentCapabilities` flag. `outputSchema` is an object-rooted JSON Schema within the subset `assertSupportedOutputSchema` (dsh-tools) enforces — a schema outside it is rejected loud at start; the in-process backends realize it with a forced `structured_output` capture tool (see the [driver README](../../packages/subagent/subagent-inprocess/README.md)). +What a caller asks for when starting a subagent. The tool layer builds this from the model's `{ description, prompt }` plus its own config; the service validates the start-time capabilities against the named provider, then passes it to `provider.start`. `parent` is REQUIRED — in-process backends read `parent.session.header` for the working directory, the `parentSession` lineage, and the delegation depth. The four optional fields (`outputSchema`, `maxDepth`, `toolFilter`, `persona`) each gate on the matching `SubagentCapabilities` flag — in-process backends realize `toolFilter` as a scoped `tools.restrict()` and `persona` as a scoped shadowing `deployment:persona` section, both composed in the child's creation window. `outputSchema` is an object-rooted JSON Schema within the subset `assertSupportedOutputSchema` (dsh-tools) enforces — a schema outside it is rejected loud at start; the in-process backends realize it with a forced `structured_output` capture tool (see the [driver README](../../packages/subagent/subagent-inprocess/README.md)). ```ts type-equiv interface SubagentStartRequest { @@ -31,6 +32,7 @@ interface SubagentStartRequest { outputSchema?: StructuredOutputSchema maxDepth?: number toolFilter?: { allow?: string[]; deny?: string[] } + persona?: string } ``` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 3306a22cef..dc33defc00 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -14,7 +14,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:414`](../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/queued` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:442`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:346`](../packages/core/agent/src/types.ts) | - | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:346`](../packages/core/agent/src/types.ts) | - | [`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:312`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:457`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:475`](../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) | @@ -25,14 +25,20 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../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:61`](../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:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:98`](../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:72`](../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:83`](../packages/subagent/subagent/src/index.ts) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:91`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:99`](../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:73`](../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:84`](../packages/subagent/subagent/src/index.ts) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:92`](../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:44`](../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:54`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:112`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:102`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:82`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +## Non-harness or undeclared event strings seen in package source + +| Event string | Dispatchers | Listeners | +| --- | --- | --- | +| `internal/dispatch` | - | [`invariants`](../packages/support/invariants) | + Maintenance mode: hybrid generated: Cordis event declarations and most producer/listener edges are AST-scanned; dynamic dispatch sites are classified in `scripts/gen-doc-graphs.ts`. diff --git a/docs/module-graph.md b/docs/module-graph.md index d17352fa27..a1a45e5b54 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -126,9 +126,6 @@ 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_session pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_llm pkg_agent_loop --> pkg_scope @@ -161,6 +158,12 @@ 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_llm pkg_acp --> pkg_session @@ -245,7 +248,6 @@ flowchart TD | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`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), [`session`](../packages/core/session) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -253,6 +255,7 @@ flowchart TD | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`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), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | | [`agent-core`](../packages/core/agent-core) | `core` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/bash/tool-bash), [`tools`](../packages/core/tools) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) | diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index c305ea40db..7cf6f0ddbb 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -74,7 +74,12 @@ export interface Config { * itself; `deny`-listing it (or setting `maxDepth`) is how a deployment * bounds recursion. */ - toolFilter?: { allow?: string[]; deny?: string[] } + toolFilter?: { + /** Global tool names the child keeps; everything else is removed. */ + allow?: string[] + /** Global tool names removed from the child. */ + deny?: string[] + } /** * Recursion cap applied to every child this tool spawns (see * `SubagentStartRequest.maxDepth`): a spawn whose child would sit deeper From e7bcbb8bc6fd1ec09243c97d42ccf0887773f6e8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:38:54 +0800 Subject: [PATCH 09/64] feat(dx): scopeHost, agent-aware ACP presentation, and the scoped-dispatch drift gate scopeHost(ctx, services) is the sanctioned way to mint scopes in tests: it names absent services loudly instead of the cryptic cordis without-inject dead end, and catches the silent-no-op host (cordis resolves a dependency-pending fiber's await without running the inject callback). The ACP ToolPresenter resolves presentations through the session agent's view (tools.get(name, agent)) so a scoped/shadowed tool renders with the same definition that executed. verify-scoped-dispatch (doc-sync + pre-push) pins the dev-invariants carrier table against the declaration JSDoc set: an event enforced but undocumented, documented but unenforced, or a registry-subject notification leaking into the table fails the build. subagent/start|end docs gain their scoped-dispatch sentence (a real gap the gate caught on first run). --- docs/cordis-catalog/events.md | 8 +-- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 4 +- package.json | 3 +- packages/core/scope/src/index.ts | 56 ++++++++++++++++++ packages/core/scope/tests/scope.spec.ts | 23 +++++++- packages/subagent/subagent/src/index.ts | 8 +++ packages/ui/acp/src/index.ts | 20 +++++-- scripts/run-gates.ts | 1 + scripts/verify-scoped-dispatch.ts | 78 +++++++++++++++++++++++++ 10 files changed, 188 insertions(+), 15 deletions(-) create mode 100644 scripts/verify-scoped-dispatch.ts diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index c748f30690..798b38b02f 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -237,13 +237,13 @@ Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/sr ### `subagent/end` — emit -A subagent run settled — emitted when SubagentRun.result resolves (any stop reason). Paired with Events['subagent/start']. +A subagent run settled — emitted when SubagentRun.result resolves (any stop reason). Paired with Events['subagent/start']. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by the DELEGATING PARENT — a listener registered through the parent's `agent.ctx` observes only its own delegations; a plain plugin listener observes every run. ```ts cordis-catalog 'subagent/end'(info: SubagentRunEndInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:99`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:107`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -267,13 +267,13 @@ Source: [`packages/subagent/subagent/src/index.ts:84`](../../packages/subagent/s ### `subagent/start` — emit -A subagent run started — emitted after the provider is resolved and its capabilities validated, as the child run begins. Paired with Events['subagent/end']. +A subagent run started — emitted after the provider is resolved and its capabilities validated, as the child run begins. Paired with Events['subagent/end']. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by the DELEGATING PARENT — a listener registered through the parent's `agent.ctx` observes only its own delegations; a plain plugin listener observes every run. ```ts cordis-catalog 'subagent/start'(info: SubagentRunInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:92`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:96`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 8ebc65c5b3..42336012de 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -197,7 +197,7 @@ list(): string[] start(name: string, request: SubagentStartRequest): SubagentRun ``` -Source: [`packages/subagent/subagent/src/index.ts:145`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:153`](../../packages/subagent/subagent/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index dc33defc00..f93e808694 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -25,10 +25,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../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:61`](../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:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:99`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:107`](../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:73`](../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:84`](../packages/subagent/subagent/src/index.ts) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:92`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:96`](../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:44`](../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:54`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:112`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | diff --git a/package.json b/package.json index bba471ea5f..df22b4846e 100644 --- a/package.json +++ b/package.json @@ -57,9 +57,10 @@ "gen-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts", "verify-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts --check", "gen-module-graph": "tsx scripts/gen-module-graph.ts", + "verify-scoped-dispatch": "tsx scripts/verify-scoped-dispatch.ts", "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", - "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets", + "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-dispatch && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types", "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml", "demo:repl": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml", diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index ceb1cccba9..63ca338de7 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -235,3 +235,59 @@ export function carrierKeyOf(value: unknown): ScopeKey | undefined { // the Scoped<> brand carries no structural kCarrier member to narrow from. return (value as { [kCarrier]?: { key: ScopeKey | undefined } })[kCarrier]?.key } + +/** + * A test/tooling host for minting scopes: one mounted plugin whose `inject` + * list is the service surface every scope minted through it can reach. + */ +export interface ScopeHost { + /** + * Mint a scope under the host (see {@link createScope}); the scoped context + * resolves exactly the host's injected services. + * @param key - the scope's identity ({@link ScopeKey}). + * @returns the minted scope. + */ + mint(key: ScopeKey): Scope + /** + * Dispose the host fiber and with it every scope minted through it. + * @returns resolves when all collected disposers have settled. + */ + dispose(): Promise +} + +/** + * Mount a scope-minting host plugin that injects `services`, THE sanctioned + * way to mint scopes in tests (production scopes are minted by the agent + * loop). Exists because the naive spelling fails confusingly twice over: + * a plugin with no `inject` mints scopes whose service reads throw Cordis's + * cryptic `cannot get property … without inject`, and a plugin whose inject + * can never be satisfied RESOLVES its fiber await without ever running the + * callback — a silent no-op host. This helper fails LOUD instead: when the + * callback did not run, it names the absent services and disposes the host. + * @param ctx - the context to mount the host under. + * @param services - the service names scopes minted through this host reach + * (the host plugin's `inject` list). + * @returns the host (mint scopes, dispose them all at once). + * @throws when any of `services` is not available on `ctx` — named, not the + * Cordis dead end. + */ +export async function scopeHost(ctx: Context, services: string[]): Promise { + let hostCtx: Context | undefined + // A named function statement (not Object.assign({name}) — Function.name is + // read-only) so diagnostics read `scopeHost`. + function scopeHostPlugin(inner: Context): void { hostCtx = inner } + const fiber = ctx.plugin(Object.assign(scopeHostPlugin, { inject: services })) + await fiber + if (hostCtx === undefined) { + // Dependency-pending: cordis resolves the await without running the + // callback. Name the absentees and unwind the pending fiber. + const missing = services.filter(name => ctx.get(name) === undefined) + await fiber.dispose() + throw new Error(`scopeHost: service${missing.length === 1 ? '' : 's'} ${missing.map(name => `"${name}"`).join(', ') || '(unknown)'} not available on this context — load the providing plugin(s) before minting scopes`) + } + const host = hostCtx + return { + mint: (key: ScopeKey) => createScope(host, key), + dispose: () => Promise.resolve(fiber.dispose()), + } +} diff --git a/packages/core/scope/tests/scope.spec.ts b/packages/core/scope/tests/scope.spec.ts index 5350a539f0..85281a65be 100644 --- a/packages/core/scope/tests/scope.spec.ts +++ b/packages/core/scope/tests/scope.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from 'cordis' -import { carrierKeyOf, createScope, isScopeCarrier, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' +import { carrierKeyOf, createScope, isScopeCarrier, scopeHost, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scope, ScopeKey, Scoped } from '@deepseek-ai/dsh-scope' declare module 'cordis' { @@ -207,3 +207,24 @@ describe('carrier marks', () => { expectTypeOf(base).not.toExtend>() }) }) + +describe('scopeHost', () => { + it('mints scopes that reach the injected services; dispose unwinds them all', async () => { + const ctx = new Context() + ctx.provide('answers', { value: 42 }) + const host = await scopeHost(ctx, ['answers']) + const scope = host.mint({ name: 'a' }) + expect((scope.ctx as Context & { answers: { value: number } }).answers.value).toBe(42) + const order: string[] = [] + scope.ctx.effect(() => () => void order.push('scoped-disposed')) + await host.dispose() + expect(order).toEqual(['scoped-disposed']) + expect(() => scope.ctx.effect(() => () => {})).toThrow(/inactive context/) + }) + + it('fails LOUD naming absent services instead of resolving as a silent no-op host', async () => { + const ctx = new Context() + await expect(scopeHost(ctx, ['tools', 'systemPrompt'])) + .rejects.toThrow('scopeHost: services "tools", "systemPrompt" not available') + }) +}) diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index fc78efa674..850958048a 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -86,6 +86,10 @@ declare module 'cordis' { * A subagent run started — emitted after the provider is resolved and its * capabilities validated, as the child run begins. Paired with * {@link Events['subagent/end']}. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed + * by the DELEGATING PARENT — a listener registered through the parent's + * `agent.ctx` observes only its own delegations; a plain plugin listener + * observes every run. * @param info - which provider started which child agent. * @mode emit */ @@ -93,6 +97,10 @@ declare module 'cordis' { /** * A subagent run settled — emitted when {@link SubagentRun.result} * resolves (any stop reason). Paired with {@link Events['subagent/start']}. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed + * by the DELEGATING PARENT — a listener registered through the parent's + * `agent.ctx` observes only its own delegations; a plain plugin listener + * observes every run. * @param info - the run identity plus stop reason and final output. * @mode emit */ diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index b4fa277311..e11133aa36 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -213,7 +213,7 @@ export function apply(ctx: Context, config: AcpConfig): void { const tools = ctx.tools // A new ToolPresenter per session (and a throwaway per load replay), each given // this warn sink so a throwing tool presenter is logged, not propagated. - const makePresenter = (): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) }) + const makePresenter = (agent?: Agent): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) }, agent) // Live sessions keyed by id (RFC 011 multi-session), plus an agent→sessionId // reverse map so `agent/*` events (which carry only the Agent) demux in O(1). @@ -449,7 +449,7 @@ export function apply(ctx: Context, config: AcpConfig): void { sessionId, agent: handle.agent, dispose: () => handle.dispose(), - presenter: makePresenter(), + presenter: makePresenter(handle.agent), terminalEnabled: terminalOutputCap, inflight: undefined, }) @@ -526,7 +526,7 @@ export function apply(ctx: Context, config: AcpConfig): void { sessionId, agent, dispose: () => handle.dispose(), - presenter: makePresenter(), + presenter: makePresenter(agent), terminalEnabled, inflight: undefined, } @@ -544,7 +544,7 @@ export function apply(ctx: Context, config: AcpConfig): void { // future live events for this session. The throwaway pairs call→result // as the log replays in order (same as live) and is discarded after, // so the record's presenter starts clean for the post-load live stream. - const replayPresenter = makePresenter() + const replayPresenter = makePresenter(agent) const replayTerminal: TerminalRendering = { enabled: terminalEnabled, cwd: agent.session.header.cwd, @@ -897,6 +897,13 @@ export class ToolPresenter { constructor( private readonly tools: Pick, private readonly onError: (message: string) => void = () => {}, + /** + * The agent whose view resolves tool presentations: a scoped/shadowed + * tool presents with ITS OWN presentCall/presentResult — the same + * definition that executed — not a same-named global's. Absent (a replay + * with no live agent) the global view presents. + */ + private readonly agent?: Agent, ) {} /** @@ -913,7 +920,7 @@ export class ToolPresenter { const args = parseToolArguments(argsJson) let present: ToolCallView | undefined try { - present = this.tools.get(name)?.presentCall?.(args) + present = this.tools.get(name, this.agent)?.presentCall?.(args) } catch (error: unknown) { // A throwing presentCall must not break streaming: log and fall back. this.onError(`acp: tool "${name}" presentCall threw, using generic presentation: ${String(error)}`) @@ -947,7 +954,8 @@ export class ToolPresenter { if (call === undefined) return { card: 'generic', content } let present: ToolResultView | undefined try { - present = this.tools.get(call.name)?.presentResult?.(call.args, { content, isError, ...meta !== undefined ? { meta } : {} }) + present = this.tools.get(call.name, this.agent) + ?.presentResult?.(call.args, { content, isError, ...meta !== undefined ? { meta } : {} }) } catch (error: unknown) { // A throwing presentResult must not break streaming/replay: log + fall back. this.onError(`acp: tool "${call.name}" presentResult threw, using raw result: ${String(error)}`) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 6c844f842a..51b634267e 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -262,6 +262,7 @@ function docSyncLeafGates(): Gate[] { pnpmScript('config-catalog', 'verify-config-catalog', { label: 'config catalog' }), pnpmScript('persistence-catalog', 'verify-persistence-catalog', { label: 'persistence catalog' }), pnpmScript('doc-graphs', 'verify-doc-graphs', { label: 'doc graphs' }), + pnpmScript('scoped-dispatch', 'verify-scoped-dispatch', { label: 'scoped dispatch' }), pnpmScript('markdown-wrap', 'verify-md-wrap', { label: 'markdown wrap' }), pnpmScript('markdown-links', 'verify-md-links', { label: 'markdown links' }), pnpmScript('doc-refs', 'verify-doc-refs', { label: 'doc refs' }), diff --git a/scripts/verify-scoped-dispatch.ts b/scripts/verify-scoped-dispatch.ts new file mode 100644 index 0000000000..214d55f7dc --- /dev/null +++ b/scripts/verify-scoped-dispatch.ts @@ -0,0 +1,78 @@ +/** + * Scoped-dispatch drift gate: the set of scope-filtered events is declared in + * TWO places that must never diverge — the dev-invariants runtime table (the + * `scopedSubject` map in `packages/support/invariants/src/index.ts`, which + * enforces carriers at dispatch time) and the event declarations' JSDoc (the + * "Scope-filtered dispatch" sentence rendered into the events catalog, which + * tells plugin authors what a scoped listener will and won't hear). An event + * added to one side without the other either silently escapes runtime + * enforcement or documents filtering that never happens; this gate fails the + * build instead. + * + * Sources of truth: the invariant table is parsed from the invariants source; + * the documented set is parsed from every `declare module 'cordis'` Events + * JSDoc in packages/*\/*\/src carrying the marker sentence. Registry-subject + * notifications (`tools/change`, `system-prompt/change`, `subagent/provider-*`) + * are deliberately unfiltered and must appear in NEITHER set. + */ + +import { globSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const root = resolve(import.meta.dirname, '..') + +/** The marker sentence every scope-filtered event's JSDoc carries. */ +const MARKER = 'Scope-filtered dispatch' + +/** Events that are deliberately UNFILTERED registry-subject notifications. */ +const REGISTRY_SUBJECT = new Set(['tools/change', 'system-prompt/change', 'subagent/provider-added', 'subagent/provider-removed']) + +function invariantTable(): Set { + const source = readFileSync(resolve(root, 'packages/support/invariants/src/index.ts'), 'utf8') + const start = source.indexOf('const scopedSubject') + if (start < 0) throw new Error('verify-scoped-dispatch: cannot find the scopedSubject table in dsh-invariants') + const block = source.slice(start, source.indexOf('}', start)) + return new Set([...block.matchAll(/'([a-z-]+\/[a-z-]+)':/g)].flatMap(match => match[1] === undefined ? [] : [match[1]])) +} + +function documentedSet(): Set { + const documented = new Set() + for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: root })) { + const source = readFileSync(resolve(root, rel), 'utf8') + if (!source.includes(MARKER)) continue + // Each event declaration: a JSDoc block followed by the quoted event name. + // Tolerate `//` comment lines between the JSDoc and the declaration + // (e.g. an inline TODO under the doc block). + for (const match of source.matchAll(/\/\*\*([\s\S]*?)\*\/\s*\n(?:\s*\/\/[^\n]*\n)*\s*'([a-z-]+\/[a-z-]+)'\(/g)) { + const [, doc, event] = match + if (doc === undefined || event === undefined) continue + if (doc.includes(MARKER)) documented.add(event) + } + } + return documented +} + +const table = invariantTable() +const documented = documentedSet() + +const problems: string[] = [] +for (const event of table) { + if (!documented.has(event)) { + problems.push(`"${event}" is enforced by the dev-invariants carrier table but its declaration JSDoc carries no "${MARKER}" sentence — document the filtering plugin authors will observe.`) + } + if (REGISTRY_SUBJECT.has(event)) { + problems.push(`"${event}" is a registry-subject notification (deliberately unfiltered) but appears in the dev-invariants carrier table.`) + } +} +for (const event of documented) { + if (!table.has(event)) { + problems.push(`"${event}" documents scope-filtered dispatch but is missing from the dev-invariants carrier table (packages/support/invariants) — a bare dispatch of it would silently revert to global delivery.`) + } +} + +if (problems.length > 0) { + console.error(`verify-scoped-dispatch: ${problems.length} drift(s) between the invariant table and the documented scoped-event set:`) + for (const problem of problems) console.error(` - ${problem}`) + process.exit(1) +} +console.log(`verify-scoped-dispatch: ${table.size} scope-filtered event(s) consistent between the invariant table and the declaration docs.`) From cc24e79cd2b0650880886143a5bf6abdb5840d88 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 03:01:11 +0800 Subject: [PATCH 10/64] docs: agent-scope RFC, CONTEXT.md glossary, architecture scope section, README sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent-scope-contexts RFC (implemented) records the decision tree: the dsh-scope primitive over cordis extend/Context.filter/no-op fibers, two-level flat scope with shadowing, restriction/grant semantics, the scoped-dispatch rule with fused helpers, the setup window, and the alternatives (explicit scope params, isolate, event-filtering-only, vendored support) with why each lost. CONTEXT.md pins the glossary. architecture.md gains the Agent Scope section, the dsh-scope spine row, the scoped turn-flow line, and an extension-table row (ceiling 1640→1790: the two-layer registration model is a new architectural axis; additions are condensed to pointers). READMEs of every touched package re-state their scoped facts; the stale structured-runtime README section is replaced by the scoped-registration description. --- CONTEXT.md | 15 ++++++++ docs/architecture.md | 8 ++++- docs/rfc/INDEX.md | 1 + .../2026-07-08-agent-scope-contexts.md | 35 +++++++++++++++++++ packages/core/README.md | 3 ++ packages/core/agent-loop/README.md | 2 ++ packages/core/agent/README.md | 2 ++ packages/core/session/README.md | 3 +- packages/core/system-prompt/README.md | 12 +++---- packages/core/tools/README.md | 13 ++++--- .../subagent/subagent-inprocess/README.md | 16 ++++----- packages/subagent/subagent/README.md | 2 +- scripts/doc-budgets.manifest.json | 2 +- 13 files changed, 90 insertions(+), 24 deletions(-) create mode 100644 CONTEXT.md create mode 100644 docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000000..7fd11ae59e --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,15 @@ +# Context glossary + +Domain vocabulary for the DeepSeek Harness SDK — one canonical term per concept. Terms link with `[[name]]`; implementation detail stays in the package READMEs and RFCs. + +## agent-scope + +- **scope** — the unit of per-agent registration: a contribution (tool, prompt section, variable, restriction, listener) is either *global* (visible to every agent) or *scoped* (owned by exactly one [[scope-key]]). Two levels, flat: nothing inherits down to subagents; subtree behavior is expressed with [[lineage]] data, never structure. +- **scope key** — the opaque identity a scope is keyed by, compared by object identity. The harness convention: a live agent is the key of its own scope. +- **agent context (`agent.ctx`)** — the agent's scoped context; registrations through it are scope-visible AND scope-lifetime (one fact drives both), and listeners on it hear only that agent's dispatches. +- **scope carrier** — the `thisArg` a scope-filtered dispatch carries (built by `scopeTarget`); its filter admits untagged listeners plus the subject's own. A *subject-less* carrier (no key) admits untagged listeners only. +- **scoped dispatch** — the rule: an event about one agent's activity dispatches with that agent's carrier. Events about a registry itself (a tool was added) are *registry-subject* and stay unfiltered. +- **shadowing** — most-specific-wins name resolution: a scoped tool/section/variable replaces its same-named global twin for that scope alone. The per-agent persona and per-agent tool-variant mechanism. +- **restriction / grant** — a restriction (`tools.restrict`) masks the GLOBAL tool surface for one scope (compose by intersection); a scoped registration is an explicit grant that bypasses restrictions. A restricted-away tool is absent from the prompt AND refuses execution, indistinguishably from a nonexistent one. +- **setup window** — the creation slot where a creator composes an agent's scoped world (`CreateAgentOptions.setup`): after the scope exists and the agent is registered, before `agent/session-start` and the first prompt assembly. Setup registers; it never drives the agent. +- **lineage** — parent/child facts carried as data (`parentSession`, `subagentDepth`); never affects visibility. diff --git a/docs/architecture.md b/docs/architecture.md index 02dac4a2dd..b668edfefd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,6 +14,7 @@ The default distribution is a composition, not a hierarchy. `packages/core/` is | ctx key | Package | Role | |---|---|---| +| — | [`dsh-scope`](../packages/core/scope/README.md) | scoped-context registration primitive (library) | | `ctx.sessions` | `dsh-session` | in-memory event-sourced sessions | | `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and prompt variables | | `ctx.tools` | `dsh-tools` | tool registry and [execution pipeline](tool-execution-pipeline.md) | @@ -58,7 +59,7 @@ A **session** is one agent's append-only event log. A **turn** drains one queued ### Turn Flow ```text -create agent -> emit agent/session-start(source) +create agent -> mint agent scope (agent.ctx) -> run creation setup -> emit agent/session-start(source) forever: wait for queued messages emit agent/status(running) @@ -103,6 +104,10 @@ Every session event is turn-enclosed. Reloading a crashed session preserves the `ctx.agents` owns live agents and returns an `AgentHandle { agent, dispose() }`. `Agent` is the surface 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()`. +### 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). + ## State And Model Surface ### Session Log @@ -145,5 +150,6 @@ New behavior should attach to a documented seam; changing the shipped loop requi | Add UI or editor integration | drive `ctx.agents` and render from `session/event` | | Add durable session state | add a `SessionEventMap` member and render/replay from the log | | Fork a live session | use `ctx.sessions.fork(source, boundary?, childSessionId?)` | +| Scope a tool, prompt section, or listener to ONE agent | register it through that agent's `agent.ctx` (see Agent Scope) | 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). diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index d6d0ce747b..677e4eafb5 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -123,6 +123,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Prompt variables and tool-guidance ownership](implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md) | 2026-07-05 | | [Every LLM request is reconstructable from the session log](implemented/architecture/2026-07-05-reconstructable-requests.md) | 2026-07-05 | | [Subagent provider-lifecycle events — `subagent/provider-added` / `subagent/provider-removed`](implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md) | 2026-07-05 | +| [The agent is a registration scope](implemented/architecture/2026-07-08-agent-scope-contexts.md) | 2026-07-08 | ### Process diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md new file mode 100644 index 0000000000..7c140f1ec6 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -0,0 +1,35 @@ +# RFC: The agent is a registration scope + +Status: implemented + +## Problem + +The runtime is multi-agent — configuration can declare several agents, the ACP bridge creates one agent per client session, and the in-process subagent backends spawn/fork children as sibling agents on the same Cordis context — yet every extension surface was context-global. One tool registry fed every agent's prompt (a child spawned to summarize a file was offered bash, file-write, and the delegation tool itself, unbounded); one section list rendered the same persona for everyone (`SubagentStartRequest` could not express a per-child persona at all); every `agent/*`, `session/*`, and `tools/*` listener fired for every agent, so a decider waterfall written for one agent silently governed all of them unless its author remembered to self-filter. The gap was visible in the API: `SubagentCapabilities.toolFilter` was public vocabulary, yet every real provider declared `toolFilter: false` because per-agent tool visibility was unimplementable, and `structured.ts` carried a FIXME documenting the placeholder-schema/final-assembly-swap/refcount dance forced by global registration. + +## Decision + +Make the agent a registration scope, using the framework's own machinery rather than per-registry bolt-ons: + +- **`dsh-scope`** (`packages/core/scope`, peer-deps cordis only, below `dsh-session`/`dsh-system-prompt` in the module-graph DAG): `createScope(ctx, key)` mints a tagged context over a synchronously-usable no-op-plugin fiber; `scopeOf(ctx)` reads the tag through the prototype chain; `scopeTarget(base, key)` builds the scope-filtered dispatch carrier over cordis `Context.filter`, composing the base's own filter, branded `Scoped` and runtime-marked for the dev invariants; `Scope.rawDispose` exposes the exact cordis disposer so a composite effect nests the scope's teardown at its yield position; `scopeHost` is the fail-loud test-side minter. +- **Ownership and visibility derive from ONE fact** — which context a registration went through: the scope's fiber owns the disposal, and the tag decides who sees it. An explicit `{ scope }` registration parameter could express "visible to X, disposed with Y", which is almost always a bug; the scoped context makes it unrepresentable. +- **`Agent.ctx`**: every live agent owns a scope context (key = the agent), minted inside the loop's composite lifecycle effect. Yield order gives teardown stop/drain → unregister → detach session → unwind scope; detach before the (async) scope unwind keeps store/registry rollback synchronous on every failure path, so a caller catching a throwing `create()` observes no half-created agent or session. `CreateAgentOptions.setup(agentCtx)` runs after the scope is minted and the agent registered, before `agent/session-start` and the loop start — setup REGISTERS the scoped world, it never drives (a dev invariant makes a pre-session-start turn a teaching error). +- **Two registration layers with shadowing**: `ctx.tools` and `ctx.systemPrompt` file a registration by the calling context's tag; a scoped tool/section/variable is visible to that agent alone, unwinds with it, and SHADOWS a same-named global contribution for that agent (most-specific-wins; within one layer duplicates still throw). Shadowing is the per-agent persona mechanism (a scoped `deployment:persona`) and the per-agent tool-variant mechanism (a scoped `bash` with the same model-facing name). +- **`tools.restrict({allow?, deny?})`**: a scoped, snapshot-at-registration mask over the GLOBAL tool surface with loud unknown-name validation; multiple restrictions intersect; scoped registrations are explicit grants that bypass restriction (what keeps a structured capture tool alive under an allow-list). One visibility function feeds prompt assembly, `get(name, scope?)`, and `execute`, so what the model is shown, what a presenter renders, and what dispatches can never disagree; out-of-view execution is `UNKNOWN_TOOL`, indistinguishable from nonexistent. +- **Scoped dispatch by rule**: an event about one agent's activity dispatches with that agent's carrier — all `agent/*` (via the fused `agentEvents(ctx, agent)`, which injects carrier and subject in one move so the correct dispatch is the shortest spelling), `session/created|event|flush` (carrier captured at `SessionStore.enter` from the entering context; `ctx.sessions.flush(session)` owns the awaited checkpoint dispatch), `tools/pre|post-execute` (by `exec.agent`), `system-prompt/assemble` (by `context.scope`; `assembleContextFor(agent)` builds the context), and `subagent/start|end` (by the delegating parent). Registry-subject notifications (`tools/change`, `system-prompt/change`, `subagent/provider-*`) stay deliberately unfiltered. A listener registered through `agent.ctx` hears only its agent; plain plugin listeners keep hearing everything; `{ global: true }` bypasses filtering. +- **Enforcement**: dev-invariants assert at cordis's `internal/dispatch` seam that every scoped-family dispatch carries a carrier keyed to the same subject its arguments name, and that an assembly context never carries `agent` without `scope`; the `verify-scoped-dispatch` gate pins the invariant table against the declaration docs so the two cannot drift. +- **The seam becomes honest**: spawn/fork advertise `{ outputSchema, depthLimit, toolFilter, persona }` all true (ACP all false); the driver composes the child's scoped world in the setup window; a parent-scope teardown effect links each child to its parent through the memoized handle (structured concurrency — a disposed parent reaches its subtree even if the delegating tool's `finally` never runs); `structured.ts` collapses to scoped registrations with a call-keyed two-phase commit and one scoped prepend re-assert listener. + +## Alternatives considered + +- **Explicit scope parameters on every registration API** (`tools.register(def, {agent})`): forgettable — omitting the option is global, so leak-by-default survives; no lifecycle coupling; and it can express visible-to-X-disposed-with-Y, which is almost always a bug. +- **Per-agent `ctx.isolate()` service instances**: isolation is a bulkhead for co-hosting independent applications, not intra-app scoping. Resolution picks exactly one instance per name — "deployment tools plus my tools" needs a hand-built delegating merge registry per service — and single-subscription observers (persistence, the ACP bridge) would have to discover and subscribe per agent. +- **Event-filtering only** (scoped listeners, global registries): leaves the model-visible surfaces — tool schemas, personas — unscoped, which is the half that makes `toolFilter` and per-child personas impossible. +- **Vendored-cordis support** (a first-class scope concept in the framework): more invasive vendor drift for no additional capability; `extend` + `Context.filter` + a no-op plugin fiber already compose the same semantics from public primitives. + +## Consequences + +- Plugin authors get one new concept: register through `agent.ctx` for one agent, through your plugin context for everyone. The registration APIs are unchanged; scope-filtered events document themselves in the catalog. +- The loop's dispatch discipline is enforced three ways: `Scoped` `this`-types make a bare subject a compile error, the fused helpers make the correct spelling the shortest, and the dev invariants throw on a mis-keyed or missing carrier at the dispatching call site. +- `toolOrder` validates against the providers' pre-restriction `knownNames` universe, so a deployment order listing a global tool stays compatible with children that `restrict()` it away (a typo still fails every assembly loudly). +- A scoped listener's own disposer runs after the session leaves the store on teardown (detach precedes the scope unwind); it heard the final stop/drain flush while attached, so nothing durable is lost. +- Deliberately out of scope, buildable on the primitive with no core change: named profile registries (`agentCtx.plugin(...)` already works), per-agent `fs/*` policy, `llm/*` scoping, and background subagents (the parent-scope teardown effect is already shaped for them). diff --git a/packages/core/README.md b/packages/core/README.md index eee8e3eed0..4e61293034 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -4,6 +4,7 @@ The packages every harness build is assembled from: the session log, the system- | Package | Role | ctx key | |---|---|---| +| `scope/` | Scoped-context registration primitive (scope tags, scope-filtered dispatch) | (library — no ctx key) | | `session/` | Event-sourced session log + in-memory store | `ctx.sessions` | | `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` | | `tools/` | Tool registry + `tools/pre-execute`/`tools/post-execute` pipeline | `ctx.tools` | @@ -11,6 +12,8 @@ The packages every harness build is assembled from: the session log, the system- | `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` | | `agent-core/` | Bundle plugin: the providerless/executor-less/UI-less spine as code | (loads the spine) | +`scope/` is the one non-service package here: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) the registries and the loop build per-agent scoping on — it sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle. + `agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop; everything else in `core/` is interface/vocabulary. Plugins depend on the `agent` vocabulary, never on `agent-loop` directly, so the loop stays swappable. `agent-core` is the composition counterpart: one bundle plugin that loads the whole providerless spine (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + `tool-bash` + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. App packages (`ui/stdio-agent`, `ui/acp-agent`) consume it and add only a front door; a leaf adds the swappable backends plus any optional product tools it wants to expose. It lives in `core/` because it composes exclusively `core/` + interface packages and ships no provider, executor, or UI of its own. diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index ed651ca7f0..b00ad6aad1 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -8,6 +8,8 @@ This is the only package in the harness that contains concrete loop logic. Every ### Public API +Lifecycle (scoped): the composite creation effect mints the agent's scope (`agent.ctx`), enters the session through it (the session's dispatch carrier), registers the agent, runs `CreateAgentOptions.setup`, emits `agent/session-start`, then starts the loop; teardown runs stop/drain → unregister → detach session → unwind scope, keeping store/registry rollback synchronous on every failure path. All `agent/*` dispatches go through `agentEvents(ctx, agent)`; per-step assembly through `assembleContextFor(agent)`; the turn-end durability checkpoint through `ctx.sessions.flush(session)`. + - `ctx.agentLoop.create(id: string, options?: AgentOptions): ReactLoopAgent` — config-driven create: an agent on a fresh per-run session id `${id}-session-` (no cwd). Used for `cordis.yml`-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber. `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 9e15356ed0..e0668ef761 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -8,6 +8,8 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i ### Public API +The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher every agent-subject event goes through (carrier + injected subject in one move); `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` composes a child's scoped world at creation — setup registers, it never drives. + - `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber. - `ctx.agents.get(id: AgentId): Agent | undefined` - `ctx.agents.list(): Agent[]` diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 0ae5bbf39e..767008d8ee 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -9,6 +9,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Public API - `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage, seed boundary) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` and persisted `seedLength` to preserve them. Disposed with the calling fiber. +- `ctx.sessions.flush(session: Session): Promise` Dispatch the awaited `session/flush` durability checkpoint with the carrier captured at enter — THE flush entry point (the loop's turn-end checkpoint and idle injection call it; never dispatch a raw `ctx.parallel`). - `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata. - `ctx.sessions.get(id: SessionId): Session | undefined` - `ctx.sessions.list(): Session[]` @@ -28,7 +29,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall | Event | Mode | Purpose | |---|---|---| | `session/created` | emit | A session was created | -| `session/event` | emit | An event was appended (sync, fire-and-forget) | +| `session/event` | emit (scope-filtered by the owning session's scope) | An event was appended (sync, fire-and-forget) | | `session/flush` | parallel | Awaited durability checkpoint (persistence plugins drain buffers here) | ### Class: `Session` diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 704cd8e80f..b890ad3cd5 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -13,21 +13,21 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool- ### Public API -- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. Duplicate names throw. Disposed with the calling fiber. -- `ctx.systemPrompt.tools(provider: () => ToolSchema[]): () => void` Contribute tool schemas (evaluated at each assembly). A provider must not return a schema named `TOOL_ORDER_REST`; that name is reserved for `toolOrder`'s rest entry. Disposed with the calling fiber. -- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Duplicate or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber. -- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` Assemble the prompt for one caller. Runs through the `system-prompt/assemble` waterfall. Rejects when a configured `toolOrder` names a tool no provider contributed, or when a provider returns the reserved rest-entry name. +- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the CALLING context's scope: `agent.ctx` contributes to that agent alone, SHADOWING a same-named global section there (the per-agent persona mechanism — a scoped `deployment:persona`). Duplicate names within one layer throw. Disposed with the calling fiber. +- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set for `context.scope`; `knownNames` (defaulting to the schemas' names) is the pre-restriction universe `toolOrder` validates against. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber. +- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables (via `agent.ctx`) shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber. +- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer (scoped shadows global). Runs through the `system-prompt/assemble` waterfall (scope-filtered by `context.scope`). Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe (a restricted-away KNOWN tool is a normal absence), or when a provider returns the reserved rest-entry name. ### Events | Event | Mode | Purpose | |---|---|---| | `system-prompt/assemble` | waterfall | Mutate/extend the assembly (with the caller's context) before it reaches the model | -| `system-prompt/change` | emit | A section, tool provider, or variable was registered or unregistered | +| `system-prompt/change` | emit | A section, tool provider, or variable was registered or unregistered (possibly for one scope); deliberately unfiltered | ### Key types -- `AssembleContext` — what one `assemble()` call is FOR. Declared empty here and merge-extensible; `dsh-agent` declares `agent?: Agent`, so providers project per-agent facts. Providers must tolerate absent fields (a bare `assemble()` carries an empty context). +- `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) here, and `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent)`). Providers must tolerate absent fields (a bare `assemble()` carries an empty, scope-less context). - `PromptSection` — `{ name, order, text: string | ((context) => string) }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona (both registered by this plugin), tool guidance uses `100–199`; other negative orders also render before the persona. - `PromptAssembly` — `{ sections: AssembledSection[], tools: ToolSchema[], variables: Record }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field. - `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference (`Object.hasOwn` lookup — prototype names like `{{constructor}}` are unknown), a registered-but-valueless reference, a malformed complete `{{…}}` group, or a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`) throws — fail loud beats shipping a malformed prompt. A lone `{{` with no `}}` anywhere after it passes through verbatim; substituted values are never re-scanned. diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index aea87ad76c..b42087b5d6 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -6,9 +6,12 @@ Tool registry and execution pipeline. Tool plugins register their schemas and ex ### Public API -- `ctx.tools.register(definition: ToolDefinition): () => void` Register a tool. Disposed with the calling fiber. -- `ctx.tools.get(name: string): ToolDefinition | undefined` -- `ctx.tools.schemas(): ToolSchema[]` Schemas of all registered tools (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)). +- `ctx.tools.register(definition: ToolDefinition): () => void` Register a tool. The layer is the CALLING context's scope (`dsh-scope`): a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, SHADOWING a same-named global tool there (per-agent tool variants). Duplicate names within one layer throw. Disposed with the calling fiber (= the agent, for scoped registrations). +- `ctx.tools.restrict(filter: ToolRestriction): () => void` Scoped-only (throws on a plain context): mask the GLOBAL tool surface for the calling agent — `allow` keeps only the listed tools, `deny` removes them; multiple restrictions intersect; scoped registrations bypass restriction as explicit grants. Snapshot-at-registration, loud unknown-name validation, `restrict({})` rejects (the materialized-empty-config trap). +- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. +- `ctx.tools.visible(scope?: ScopeKey): ToolDefinition[]` THE visibility function — restricted global layer ∪ the scope's own layer — feeding prompt assembly, `get`, and `execute`, so what the model sees and what dispatches can never disagree. +- `ctx.tools.knownNames(scope?: ScopeKey): string[]` The PRE-restriction name universe configuration (`toolOrder`, `restrict`) validates against: a typo fails loud while a restricted-away tool stays a normal absence. +- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)). - `ctx.tools.execute(exec: ToolExecution): Promise` Execute one tool call through the `tools/pre-execute` → dispatch → `tools/post-execute` pipeline. ### Injected services @@ -19,9 +22,9 @@ Tool registry and execution pipeline. Tool plugins register their schemas and ex | Event | Mode | Purpose | |---|---|---| -| `tools/pre-execute` | waterfall | Allow/deny gate BEFORE a tool runs (sandbox, permission, hooks); returns `PreToolDecision` | +| `tools/pre-execute` | waterfall | Allow/deny gate BEFORE a tool runs (sandbox, permission, hooks); returns `PreToolDecision`. Scope-filtered by `exec.agent`: an `agent.ctx` listener gates only its own agent | | `tools/post-execute` | waterfall | Inspect/replace the result AFTER a tool runs, attach context; returns `PostToolDecision` | -| `tools/change` | emit | A tool was registered or unregistered | +| `tools/change` | emit | A tool or restriction was registered or unregistered (possibly for one scope); deliberately unfiltered | ### Key types diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index f6870929a8..a5c293c7da 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -21,16 +21,14 @@ Runs a child as a child [`Agent`](../../core/agent) on the same cordis context ( ### Structured output (package-internal runtime) -The mechanism behind `outputSchema` for in-process children — acquired per structured RUN inside `startInProcessRun` (nothing is registered on a context that never runs a structured child; only the model-facing constants `STRUCTURED_OUTPUT_TOOL`/`STRUCTURED_OUTPUT_INSTRUCTION` are exported). One globally registered `structured_output` capture tool (its registered parameters are a placeholder) plus four listeners: +`attachStructuredRuntime(childCtx, schema)` registers the run's whole enforcement surface as SCOPED registrations on the child's `agent.ctx` — riding the child's fiber (a backend hot-reload mid-run cannot unregister anything; a disposed child leaves no residue) and visible to that child alone (two concurrent structured runs never interact; no placeholder schema, no strip-for-everyone-else, no refcounted global state): -- a `system-prompt/assemble` waterfall listener registered `prepend: true` that post-processes `await next()` — **final-assembly enforcement**: the assembly the loop renders never carries `structured_output` for an agent without a structured run, and for one that has it always carries the run's OWN schema (as the tool's `parameters`) plus the calling instruction as a trailing prompt section (the demand travels with the tool — `AgentOptions` has no per-agent prompt field to carry it). The loop logs the rendered assembly as the step's `request/header`, so the injection is reconstructable log state, never a wire-only mutation. Per-agent shaping lives here because the tool registry and prompt assembly are context-global while schemas differ per concurrent child (FIXME in the module doc: per-agent/per-session scoping would dissolve this); cooperative mutate-then-`next()` would not survive a downstream listener returning a replacement assembly. -- a `tools/post-execute` listener (`prepend: true` = outermost, so `await next()` yields the composed final decision) that COMMITS the capture: the tool body only stages the validated value, and it becomes the run's result only when the final decision accepts the call — a downstream block (a PostToolUse hook) turns the logged result into `isError`, and the run must not report `structured` success for a call the model and session log saw fail. -- a `tools/pre-execute` deny for any call arriving after the agent's capture — terminal means terminal WITHIN the step: a response listing `structured_output` before further tool calls cannot run side effects after the final answer was accepted. -- an `agent/turn-continuation` listener (also `prepend: true` — an earlier-registered force-continue listener returning without `next()` must not decide the turn before the veto runs) that stops a child's turn once its output is captured, so a successful capture doesn't buy a wasted extra model step. - -The capture tool validates each call against the run's schema (`validateStructuredValue`) — violations become an `INVALID_ARGS` isError result the model retries in-turn; a valid call stages the value for the post-execute commit. - -Lifetime is refcounted by live structured runs: each acquires at start and releases at settle, so the registrations exist exactly while at least one structured child is live, a backend hot-reload mid-run cannot unregister the capture tool under a live child, and the last settle disposes everything. `release()` is idempotent per acquisition. +- the `structured_output` capture tool with the run's REAL schema as its registered `parameters`, validating each call (`validateStructuredValue`) — violations become an `INVALID_ARGS` isError the model retries in-turn; a valid call STAGES the value keyed by its call id; +- the calling instruction as an ordinary order-190 scoped prompt section (the demand travels with the tool, as prompt state of exactly one agent); +- a scoped `system-prompt/assemble` re-assert (`prepend: true` = outermost): whatever downstream listeners mutate or replace, the child's assembly always carries its capture tool and instruction — the loop logs the rendered assembly as the step's `request/header`, so the demand is reconstructable log state; +- a scoped `tools/post-execute` COMMIT (`prepend: true`): the staged value becomes the run's result only when the final decision accepts THE SAME CALL that staged it — call-keyed, so a stale stage orphaned by an outer short-circuiting listener is dropped, never promoted on a later call's acceptance; +- a scoped `tools/pre-execute` deny for any call arriving after the capture — terminal means terminal WITHIN the step; +- a scoped `agent/turn-continuation` veto (`prepend: true`) stopping the child's turn once its output is captured, so a successful capture doesn't buy a wasted extra model step. ### `depthOf(agent): number` diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 8bab4c61c9..695bd8f952 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -25,7 +25,7 @@ Unlike the bash seam (one executor per context, second load throws), **multiple ## Capabilities: two kinds, discovered two ways -- **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter`) are a static `provider.capabilities` descriptor, checked by the service BEFORE a run exists. A request that needs one the provider lacks is **rejected loud** (`UNSUPPORTED_CAPABILITY`), never accepted-then-ignored. +- **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter/persona`) are a static `provider.capabilities` descriptor, checked by the service BEFORE a run exists. A request that needs one the provider lacks is **rejected loud** (`UNSUPPORTED_CAPABILITY`), never accepted-then-ignored. - **Runtime features** (steering, resume) are **optional methods** on `SubagentRun` (`sendMessage?`, `resume?`). The method's presence IS the capability; TS narrowing is the discovery mechanism — a consumer cannot call an absent method without narrowing first, so there is no silent degradation path. Beside `capabilities` sits one DESCRIPTIVE fact, not validated by the service: `provider.inheritsParentContext` — whether a child sees the parent conversation (`fork`: true — seeded with the completed-turn prefix; `spawn`/`acp`: false). The model-facing consumer (`dsh-tool-subagent`) derives truthful tool wording from it. diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index fc2b9d12c2..33217097b2 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,7 +1,7 @@ { "AGENTS.md": 1691, "docs/AGENTS.md": 1315, - "docs/architecture.md": 1640, + "docs/architecture.md": 1790, "docs/cordis-primer.md": 550, "docs/defensive-patterns.md": 550, "docs/testing.md": 800, From e7b712453add87751457bf4e2466df184cacd264 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 03:41:37 +0800 Subject: [PATCH 11/64] test: close the per-file coverage gaps for the scoping surface Every subject-extractor row of the invariants carrier table is exercised with a matching and a foreign-keyed carrier; the HMR re-apply seed path (sessions of agents that predate the plugin are marked started) is pinned; the scoped tool-provider disposal, plural restrict() validation, singular scopeHost absentee, tool-subagent passthrough, stale-stage drop, and disposing-parent spawn (INACTIVE_EFFECT, no orphan) each gain their test. Two genuinely defensive branches carry justified v8-ignore markers. --- packages/core/scope/src/index.ts | 6 ++- packages/core/scope/tests/scope.spec.ts | 5 ++ .../core/system-prompt/tests/scoped.spec.ts | 13 +++++ packages/core/tools/tests/scoped.spec.ts | 1 + .../subagent/subagent-inprocess/src/index.ts | 2 + .../subagent-inprocess/src/structured.ts | 3 ++ .../tests/structured.spec.ts | 41 ++++++++++++++++ .../tests/subagent-spawn.spec.ts | 21 ++++++++- .../tool-subagent/tests/tool-subagent.spec.ts | 33 +++++++++++++ .../invariants/tests/invariants.spec.ts | 47 ++++++++++++++++++- 10 files changed, 169 insertions(+), 3 deletions(-) diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index 63ca338de7..6b40eff05d 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -283,7 +283,11 @@ export async function scopeHost(ctx: Context, services: string[]): Promise ctx.get(name) === undefined) await fiber.dispose() - throw new Error(`scopeHost: service${missing.length === 1 ? '' : 's'} ${missing.map(name => `"${name}"`).join(', ') || '(unknown)'} not available on this context — load the providing plugin(s) before minting scopes`) + /* v8 ignore next -- the '(unknown)' fallback is defensive: a pending + * fiber with zero absent services cannot occur (an all-present inject + * list runs the callback) */ + const named = missing.map(name => `"${name}"`).join(', ') || '(unknown)' + throw new Error(`scopeHost: service${missing.length === 1 ? '' : 's'} ${named} not available on this context — load the providing plugin(s) before minting scopes`) } const host = hostCtx return { diff --git a/packages/core/scope/tests/scope.spec.ts b/packages/core/scope/tests/scope.spec.ts index 85281a65be..e932ed80db 100644 --- a/packages/core/scope/tests/scope.spec.ts +++ b/packages/core/scope/tests/scope.spec.ts @@ -227,4 +227,9 @@ describe('scopeHost', () => { await expect(scopeHost(ctx, ['tools', 'systemPrompt'])) .rejects.toThrow('scopeHost: services "tools", "systemPrompt" not available') }) + + it('names a single absent service in the singular', async () => { + const ctx = new Context() + await expect(scopeHost(ctx, ['tools'])).rejects.toThrow('scopeHost: service "tools" not available') + }) }) diff --git a/packages/core/system-prompt/tests/scoped.spec.ts b/packages/core/system-prompt/tests/scoped.spec.ts index 9c448bc2ac..99e6b1c113 100644 --- a/packages/core/system-prompt/tests/scoped.spec.ts +++ b/packages/core/system-prompt/tests/scoped.spec.ts @@ -100,6 +100,19 @@ describe('scoped tool providers and toolOrder × restriction', () => { expect(global.tools.map(t => t.name)).toEqual(['global_tool']) }) + it('disposing a scoped tool provider empties its layer without residue', async () => { + const ctx = await mount() + const scope = await mintScope(ctx, 'child') + const dispose = scope.ctx.systemPrompt.tools(() => ({ schemas: [schema('scoped_tool')] })) + dispose() + const after = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }) + expect(after.tools.map(t => t.name)).toEqual([]) + // Re-registering through the same scope starts a fresh layer. + scope.ctx.systemPrompt.tools(() => ({ schemas: [schema('again')] })) + const again = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }) + expect(again.tools.map(t => t.name)).toEqual(['again']) + }) + it('a toolOrder entry restricted away for a scope is a normal absence, while a typo still throws', async () => { const ctx = await mount({ toolOrder: ['bash', TOOL_ORDER_REST] }) // A provider mimicking the registry's restriction split: bash exists diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index 027b5798de..d7c0f020d0 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -148,6 +148,7 @@ describe('restrict()', () => { expect(() => ctx.tools.restrict({ deny: ['real'] })).toThrow(/requires a scoped context/) expect(() => scope.ctx.tools.restrict({})).toThrow(/no-op/) expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown tool "reall"; known tools for this scope: real/) + expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown tools "ghost", "wraith"/) }) }) diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 889a8afb5d..cd0202f1bb 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -203,6 +203,8 @@ export function startInProcessRun( try { unlink = request.parent.ctx.effect(() => () => handle.dispose()) } catch (error: unknown) { + // Fire-and-forget: start() must rethrow synchronously; the child's + // teardown (stop → unregister → detach) reaches quiescence on its own. void handle.dispose() throw error } diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index 9d8b4cb70b..158d08ce1f 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -184,6 +184,9 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut if (decision.kind === 'accept') captured = { value: staged.value } return decision } finally { + /* v8 ignore next -- defensive false branch: a concurrent re-stage + * would need a second capture call INSIDE the first's post-execute + * chain */ if (pending === staged) pending = undefined } }, { prepend: true }) diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 3d676c3848..c05310d70a 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -489,4 +489,45 @@ describe('in-process structured output', () => { expect(result.isError).toBe(true) expect(result.error?.code).toBe('UNKNOWN_TOOL') }) + + it('drops a stale stage from a short-circuited chain: a later call never promotes it (call-keyed commit)', async () => { + const { ctx, parent } = await setup([ + toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }), + ]) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const child = ctx.agents.get(run.id)! + // An OUTER post-execute listener (registered after attach, prepend ⇒ + // outermost) that BLOCKS the first capture WITHOUT delegating: the commit + // listener never runs for c1, so its staged value would linger. + let blocks = 1 + ctx.on('tools/post-execute', (exec, _result, next) => { + if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) { + blocks -= 1 + return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected' }] }) + } + return next() + }, { prepend: true }) + const result = await run.result + // The blocked capture must NOT surface as structured success… + expect(result.stopReason).toBe('error') + expect(result.structured).toBeUndefined() + // …and a LATER invalid call (its own body staged nothing) must not + // resurrect c1's orphaned value: drive the pipeline directly. + const invalid = await ctx.tools.execute({ + callId: 'c2' as never, + name: STRUCTURED_OUTPUT_TOOL, + arguments: { answer: 'not-a-number' }, + agent: child, + }) + expect(invalid.isError).toBe(true) + // A fresh valid call still captures ITS OWN value. + const valid = await ctx.tools.execute({ + callId: 'c3' as never, + name: STRUCTURED_OUTPUT_TOOL, + arguments: { answer: 9 }, + agent: child, + }) + expect(valid.isError).toBeFalsy() + await run.dispose() + }) }) diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 0d3bc57d26..88f02070a6 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import LlmService from '@deepseek-ai/dsh-llm' @@ -377,4 +377,23 @@ describe('dsh-subagent-spawn', () => { expect(ctx.agents.list().length).toBe(before) }) }) + + it('spawning from a DISPOSING parent fails loud with no orphaned child (INACTIVE_EFFECT teaching error)', async () => { + const { ctx } = await setup([]) + // A handle-owned parent we can dispose (config agents dispose with the loop fiber). + const parentHandle = ctx.agents.create({ + agentId: AgentId('doomed-parent'), + sessionId: SessionId('doomed-s'), + agentOptions: { model: 'mock' }, + }) + await parentHandle.dispose() + const before = ctx.agents.list().length + expect(() => ctx.subagents.start('spawn', { + prompt: [{ type: 'text', text: 'do X' }], + parent: parentHandle.agent, + })).toThrow(/inactive context/) + // The freshly created child's disposal was initiated before the rethrow + // (fire-and-forget — start() throws synchronously); quiescence follows. + await vi.waitFor(() => { expect(ctx.agents.list().length).toBe(before) }) + }) }) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 90f3e0f931..9510df53ab 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -451,4 +451,37 @@ describe('dsh-tool-subagent', () => { expect(typeof unwrapped.apply).toBe('function') expect(unwrapped.Config).toBeDefined() }) + + it('passes persona/toolFilter/maxDepth config through to the start request', async () => { + let seen: { persona?: string; toolFilter?: unknown; maxDepth?: number } | undefined + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'capture2', + capabilities: { outputSchema: false, depthLimit: true, toolFilter: true, persona: true }, + inheritsParentContext: false, + start: (request) => { + seen = request + return { + id: AgentId('capture2-child'), + result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), + cancel() {}, + dispose: async () => {}, + } + }, + }) + await ctx.plugin(tool, { + provider: 'capture2', + persona: 'You are the child.', + toolFilter: { deny: ['subagent'] }, + maxDepth: 2, + }) + + await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(seen?.persona).toBe('You are the child.') + expect(seen?.toolFilter).toMatchObject({ deny: ['subagent'] }) + expect(seen?.maxDepth).toBe(2) + }) }) diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index 98e5d675fa..9bd796955d 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import { CallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import * as Invariants from '@deepseek-ai/dsh-invariants' import { InvariantError } from '@deepseek-ai/dsh-invariants' @@ -797,6 +797,37 @@ describe('scoped-dispatch invariants', () => { .toThrow(/dispatched without a scope carrier/) }) + it('accepts a matching carrier and rejects a mismatched one for EVERY agent-subject event', async () => { + const ctx = await scopedCtx() + // Real Session objects: the session-start tracker WeakSet-keys them. + const agent = { id: 'a1', session: new Session(SessionId('a1-s')) } as unknown as Agent + const other = { id: 'a2', session: new Session(SessionId('a2-s')) } as unknown as Agent + // One dispatch per table row keeps every subject extractor covered: the + // matching carrier passes, the foreign-keyed one throws. + const rows: [string, unknown[]][] = [ + ['agent/created', [agent]], + ['agent/disposed', [agent]], + ['agent/status', [agent, 'idle']], + ['agent/queued', [agent, [], { source: { kind: 'user' }, steering: false }]], + ['agent/session-start', [agent, 'startup']], + ['agent/pre-step', [agent, 1, 1, '', new AbortController().signal]], + ['agent/prompt-submit', [agent, [], { kind: 'user' }, () => Promise.resolve({ kind: 'allow' })]], + ['agent/request', [agent, 1, 1, { model: 'm' }, () => Promise.resolve({ model: 'm' })]], + ['agent/step-result', [agent, 1, 1, { role: 'assistant', content: [] }, () => Promise.resolve({ role: 'assistant', content: [] })]], + ['agent/turn-continuation', [agent, 1, { action: 'stop' }, () => Promise.resolve({ action: 'stop' })]], + ['agent/error', [agent, 1, 0, new Error('x')]], + ['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]], + ['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { callId: 'c', content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]], + ] + for (const [event, args] of rows) { + const subject = event.startsWith('tools/') ? agent : agent + expect(() => { (ctx.emit as (...a: unknown[]) => void)(scopeTarget(agent, subject), event, ...args) }, + `${event} with matching carrier`).not.toThrow() + expect(() => { (ctx.emit as (...a: unknown[]) => void)(scopeTarget(agent, other), event, ...args) }, + `${event} with foreign carrier`).toThrow(/DIFFERENT subject/) + } + }) + it('rejects a carrier keyed to a different subject than the arguments name', async () => { const ctx = await scopedCtx() const agent = { id: 'a1' } as unknown as Agent @@ -843,4 +874,18 @@ describe('scoped-dispatch invariants', () => { session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) }).not.toThrow() }) + + it('marks sessions of agents that predate the plugin as started (HMR re-apply safety)', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('pre-s')) + const agent = { id: 'pre', session } as unknown as Agent + ctx.root.provide('agents', { list: () => [agent] } as never) + // Invariants apply AFTER the agent exists: its ordering is unknowable, so + // a turn opening without an observed session-start must NOT false-positive. + await ctx.plugin(Invariants) + expect(() => { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + }).not.toThrow() + }) }) From 513ba2716d0f8fa539a68adc04d890328d0f5b97 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 03:58:15 +0800 Subject: [PATCH 12/64] fix(agent-loop): one quiescence boundary across owner unload and handle.dispose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cordis effect disposers are single-shot but not await-idempotent: when the owning fiber's unload invokes the raw wrapper first, a concurrent handle.dispose() got an immediate undefined and resolved before teardown finished — violating the driver's stated one-boundary contract (Codex implementation-review finding). The teardown chain's FIRST-yielded (so disposed-last) disposer now resolves a shared completion promise; the handle path awaits it after the wrapper, so tool-finally, parent-teardown, and owner-unload all observe the same fully-torn-down state. Regression test: owner unload begins first, concurrent handle.dispose still awaits unregistration + session detach. --- packages/core/agent-loop/src/index.ts | 15 ++++++++++++- .../agent-loop/tests/scope-lifecycle.spec.ts | 21 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 306f8707e2..47b2607881 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -303,7 +303,20 @@ export class AgentLoop extends Service implements AgentFactory { setup?: (agentCtx: Context) => void, ): { agent: ReactLoopAgent; disposeAgent: () => Promise } { const agent = new ReactLoopAgent(this.ctx, id, options, session) + // The ONE quiescence boundary every disposal path observes. Cordis effect + // disposers are single-shot but not await-idempotent: when the OWNING + // fiber's unload invokes the raw wrapper first, a concurrent + // `handle.dispose()` calling the same wrapper gets an immediate undefined + // (epoch already cleared) — so the handle path must await THIS promise, + // resolved by the teardown chain's final disposer, not the wrapper's + // return. Every disposer in the chain is deliberately infallible (stop() + // is infallible by contract, unregister/detach contain their listeners, + // the scope unwind is cordis-contained), so the final disposer always + // runs — a throwing link would skip the rest of a cordis dispose chain. + const { promise: torndown, resolve: markTorndown } = Promise.withResolvers() const dispose = this.ctx.effect(function* (this: AgentLoop) { + // First-yielded ⇒ disposed LAST: marks true teardown completion. + yield () => { markTorndown() } // Mint the agent's scope (key = the agent) and wire the two-phase // reference: the scope context tags registrations + filters dispatch; // the extend adds the `ctx.agent` DX own-property on top. The raw @@ -349,7 +362,7 @@ export class AgentLoop extends Service implements AgentFactory { // disposed later) is still attached. yield async () => { stop(); await agent.done } }.bind(this), 'agentLoop.start()') - return { agent, disposeAgent: async () => { await dispose() } } + return { agent, disposeAgent: async () => { await dispose(); await torndown } } } /** diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 28d1da192d..628339f1e5 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -169,4 +169,25 @@ describe('agent scope lifecycle', () => { agentEvents(ctx, agent).emit('agent/error', 2, 0, new Error('for a1')) expect(heard).toEqual(['a1:2']) }) + + it('handle.dispose() during owner unload still awaits true quiescence (shared boundary)', async () => { + const ctx = await harness() + let handle!: ReturnType + const owner = await ctx.plugin(Object.assign((inner: Context) => { + handle = inner.agents.create({ agentId: AgentId('h1'), sessionId: SessionId('h1-s'), agentOptions: { model: 'mock' } }) + }, { inject: ['agents'] })) + + const teardownDone: string[] = [] + ctx.on('agent/disposed', () => void teardownDone.push('unregistered')) + + // Owner unload begins FIRST (invokes the raw cordis wrapper)… + const unload = owner.dispose() + // …and a concurrent handle.dispose() must not resolve before the chain + // actually finished (the raw wrapper returns undefined on a repeat call). + await handle.dispose() + expect(teardownDone).toContain('unregistered') + expect(ctx.agents.get(AgentId('h1'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('h1-s'))).toBeUndefined() + await unload + }) }) From 9ff8720da5904b67d14642a6dc4fc6859d0de619 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 04:03:22 +0800 Subject: [PATCH 13/64] fix(tool-subagent): a partial toolFilter must not materialize an empty allow-list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ds-review-bot finding: forcing only the OUTER toolFilter key absent left the nested arrays materializing — a deny-only config gained allow: [], which means deny-EVERYTHING. The nested arrays now default to undefined too; an explicit allow: [] (grant-only children) still survives. Pinned by a capture-provider regression test. --- packages/subagent/tool-subagent/src/index.ts | 9 +++++-- .../tool-subagent/tests/tool-subagent.spec.ts | 26 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 7cf6f0ddbb..35c23c7fed 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -102,9 +102,14 @@ export const Config: z = z.object({ // deny-everything, silently. Force the omitted key to stay absent (the same // shape discipline as SystemPrompt's toolOrder); the cast is needed because // .default() expects the object type. + // The NESTED arrays get the same treatment as the object itself: a partial + // filter ({deny: […]}) must not materialize allow: [] beside it — an empty + // allow-list means deny-EVERYTHING, so the materialized default would turn + // a deny-one config into deny-all. An EXPLICIT allow: [] (grant-only + // children) survives, since only the omitted key defaults to undefined. toolFilter: z.object({ - allow: z.array(z.string()), - deny: z.array(z.string()), + allow: z.array(z.string()).default(undefined as unknown as string[]), + deny: z.array(z.string()).default(undefined as unknown as string[]), }).default(undefined as unknown as { allow: string[]; deny: string[] }), maxDepth: z.number(), }) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 9510df53ab..5fd2ace838 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -484,4 +484,30 @@ describe('dsh-tool-subagent', () => { expect(seen?.toolFilter).toMatchObject({ deny: ['subagent'] }) expect(seen?.maxDepth).toBe(2) }) + + it('a partial toolFilter (deny only) does not materialize an empty allow-list (deny-all trap)', async () => { + let seen: { toolFilter?: { allow?: string[]; deny?: string[] } } | undefined + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'capture3', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: true, persona: false }, + inheritsParentContext: false, + start: (request) => { + seen = request + return { + id: AgentId('capture3-child'), + result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), + cancel() {}, + dispose: async () => {}, + } + }, + }) + await ctx.plugin(tool, { provider: 'capture3', toolFilter: { deny: ['subagent'] } }) + await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(seen?.toolFilter).toEqual({ deny: ['subagent'] }) + expect(seen?.toolFilter).not.toHaveProperty('allow') + }) }) From 547aacee2fb0821cd602f1aba92ce97354d818e1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 04:48:52 +0800 Subject: [PATCH 14/64] fix: honor the teardown order on owner unload; make the structured commit unconditional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial-review findings (own reviewer agent), each verified and pinned: B1: agents.register() returned a wrapper lambda, so the factory composite's yield could not identity-nest it — on OWNER unload the unregistration (and agent/disposed) disposed as a concurrent sibling, firing mid-drain while the final turn was still closing (pre-existing on master; this branch's docs re-assert the order, so it must be true). register() now returns the EXACT cordis effect disposer (the Scope.rawDispose move); the composite nests it and owner unload runs stop/drain -> unregister -> detach -> scope like every other path. Regression test pins turn-end before disposed before detach on owner unload. B2: the structured two-phase commit could promote a stale stage when a later capture call REUSED the orphaned stage's call id with a body that never staged (denied downstream, or invalid args throwing pre-stage). The runtime's pre-execute listener now clears any stale stage unconditionally when a new capture call enters the pipeline — only a call's own body can stage for its commit; the call-id mismatch guard becomes a defensive second layer. Repro test: blocked capture then same-id invalid call. C1: an explicit empty toolFilter config now fails at plugin LOAD (the check is self-contained) instead of killing every delegation at child setup. C2: Scope.dispose/ScopeHost.dispose @returns state the single-shot repeat-call semantics honestly. --- .../agent-loop/tests/scope-lifecycle.spec.ts | 34 +++++++++++++++++ packages/core/agent/src/index.ts | 15 +++++--- packages/core/scope/src/index.ts | 10 ++++- .../subagent-inprocess/src/structured.ts | 14 +++++-- .../tests/structured.spec.ts | 38 +++++++++++++++++++ packages/subagent/tool-subagent/src/index.ts | 6 +++ .../tool-subagent/tests/tool-subagent.spec.ts | 15 ++++++++ 7 files changed, 122 insertions(+), 10 deletions(-) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 628339f1e5..7a45898b8e 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -170,6 +170,40 @@ describe('agent scope lifecycle', () => { expect(heard).toEqual(['a1:2']) }) + it('owner unload honors the documented teardown order: unregistration AFTER the drain, before detach', async () => { + const ctx = await harness() + let handle!: ReturnType + const owner = await ctx.plugin(Object.assign((inner: Context) => { + handle = inner.agents.create({ agentId: AgentId('o1'), sessionId: SessionId('o1-s'), agentOptions: { model: 'mock' } }) + }, { inject: ['agents'] })) + const { agent } = handle + + const order: string[] = [] + ctx.on('session/event', (_s, event) => { + if (event.type === 'turn/end') order.push('turn-end') + }) + ctx.on('agent/disposed', () => { + order.push(`disposed(listed=${ctx.agents.get(AgentId('o1')) !== undefined})`) + order.push(`session-still-stored=${ctx.sessions.get(SessionId('o1-s')) !== undefined}`) + }) + + // Open a turn so the drain has real work: the loop must finish it BEFORE + // the registry entry goes away (the agent/disposed contract: "its fiber + // and any in-flight turn have been torn down"). Wait for the turn to be + // OPEN in the log — a dispose landing in the pre-step window would drop + // the queued prompt without ever opening a turn. + const turnOpen = new Promise((resolve) => { + const off = ctx.on('session/event', (_s, event) => { + if (event.type === 'turn/start') { off(); resolve() } + }) + }) + agent.send(text('work')) + await turnOpen + await owner.dispose() + expect(order).toEqual(['turn-end', 'disposed(listed=false)', 'session-still-stored=true']) + expect(ctx.sessions.get(SessionId('o1-s'))).toBeUndefined() + }) + it('handle.dispose() during owner unload still awaits true quiescence (shared boundary)', async () => { const ctx = await harness() let handle!: ReturnType diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index b9c9e6f0df..3821acd442 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -208,9 +208,16 @@ export class AgentRegistry extends Service { * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always * requires passing the carrier). Returns the disposer. * @param agent - the already-constructed agent to record in the store. - * @returns the disposer that removes the agent and emits `agent/disposed`. + * @returns the EXACT Cordis effect disposer (single-shot; a repeat call + * returns undefined without awaiting an in-flight teardown). Exact + * identity is load-bearing: a composite (generator) effect that owns a + * teardown ORDER — the agent factory's lifecycle chain — must yield THIS + * function so Cordis nests the unregistration at that yield position; + * yielding a wrapper would leave it disposing as a concurrent sibling on + * owner unload, unregistering the agent (and emitting `agent/disposed`) + * while its final turn is still draining. */ - register(agent: Agent): () => void { + register(agent: Agent): () => Promise | void { const dispose = this.ctx.effect(function* (this: AgentRegistry) { if (this.store.has(agent.id)) { throw new Error(`agent "${agent.id}" is already registered`) @@ -242,9 +249,7 @@ export class AgentRegistry extends Service { } this.ctx.emit(scopeTarget(agent, agent), 'agent/created', agent) }.bind(this), 'agents.register()') - // ctx.effect's disposer returns Promise; our disposer API is - // synchronous fire-and-forget — discard the (always-resolved) promise. - return () => void dispose() + return dispose } /** diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index 6b40eff05d..9c4fd0019e 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -83,7 +83,12 @@ export interface Scope { * returns undefined the second time; this wrapper Promise-normalizes it). * After disposal the scoped context is inert — a further registration * through it throws Cordis's INACTIVE_EFFECT. - * @returns resolves when every registration's disposer has settled. + * @returns for the call that initiates teardown: resolves when every + * registration's disposer has settled. A repeat/racing call resolves + * immediately WITHOUT awaiting the in-flight teardown (the underlying + * Cordis disposer is single-shot) — a caller needing a shared quiescence + * boundary across racing disposers keeps its own completion promise (the + * agent factory's pattern). */ dispose(): Promise } @@ -250,7 +255,8 @@ export interface ScopeHost { mint(key: ScopeKey): Scope /** * Dispose the host fiber and with it every scope minted through it. - * @returns resolves when all collected disposers have settled. + * @returns resolves when all collected disposers have settled (first call; + * a repeat call resolves immediately — single-shot, like Scope.dispose). */ dispose(): Promise } diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index 158d08ce1f..9a00c62029 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -159,6 +159,13 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut reason: `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`, }) } + // A NEW capture call invalidates any stale stage UNCONDITIONALLY, before + // dispatch: only THIS call's own body may stage for this call's commit. + // Without this, a stale entry orphaned by an outer short-circuited chain + // could be promoted by a later call REUSING the same call id whose body + // never staged (pre-execute-denied downstream, or invalid args throwing + // before the stage) — reporting success for a value the model saw fail. + if (exec.name === STRUCTURED_OUTPUT_TOOL) pending = undefined return next() }, { prepend: true }) @@ -171,13 +178,14 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut this: unknown, exec: ToolExecution, _result: ToolExecutionResult, next: () => Promise, ): Promise { if (exec.name !== STRUCTURED_OUTPUT_TOOL || pending === undefined) return next() + /* v8 ignore start -- defensive second layer: the pre-execute clear above + * already drops every stale stage before a new capture call dispatches, + * so a call-id mismatch cannot be reached through the tool pipeline */ if (pending.callId !== exec.callId) { - // A stale stage from a different call: an outer listener short-circuited - // that call's post-execute chain past this commit, so its verdict never - // reached us and the value must never be promoted — drop it. pending = undefined return next() } + /* v8 ignore stop */ const staged = pending try { const decision = await next() diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index c05310d70a..479bce58b9 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -530,4 +530,42 @@ describe('in-process structured output', () => { expect(valid.isError).toBeFalsy() await run.dispose() }) + + it('a later capture call REUSING a stale stage\'s call id never promotes it (unconditional commit safety)', async () => { + const { ctx, parent } = await setup([ + toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }), + ]) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const child = ctx.agents.get(run.id)! + // Orphan a stage: an outer short-circuiting post-execute BLOCK on the + // first capture (its chain never reaches the commit listener). + let blocks = 1 + ctx.on('tools/post-execute', (exec, _result, next) => { + if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) { + blocks -= 1 + return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected' }] }) + } + return next() + }, { prepend: true }) + await run.result + // A SECOND capture call with the SAME call id whose body never stages + // (invalid args throw before the stage): the stale value must not ride + // its acceptance. + const reused = await ctx.tools.execute({ + callId: 'c1' as never, + name: STRUCTURED_OUTPUT_TOOL, + arguments: { answer: 'not-a-number' }, + agent: child, + }) + expect(reused.isError).toBe(true) + // Nothing was ever committed: a fresh valid call is still required. + const valid = await ctx.tools.execute({ + callId: 'c1' as never, + name: STRUCTURED_OUTPUT_TOOL, + arguments: { answer: 5 }, + agent: child, + }) + expect(valid.isError).toBeFalsy() + await run.dispose() + }) }) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 35c23c7fed..f677076d3f 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -184,6 +184,12 @@ export function providerWording(inherits: boolean): { description: string; promp } export function apply(ctx: Context, config: Config): void { + // Misconfiguration fails loud AT LOAD (the check is self-contained): an + // explicit `toolFilter: {}` would otherwise pass the capability gate and + // kill every delegation later, in the child-setup `restrict({})` throw. + if (config.toolFilter !== undefined && config.toolFilter.allow === undefined && config.toolFilter.deny === undefined) { + throw new Error('tool-subagent: `toolFilter` is configured but names neither `allow` nor `deny` — remove the key or fill the filter') + } // The tool MIRRORS its provider's lifecycle instead of assuming load order: // the cordis Loader starts sibling entries concurrently, so "backend listed // first in cordis.yml" does not guarantee "provider registered first", and diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 5fd2ace838..da560350e4 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -510,4 +510,19 @@ describe('dsh-tool-subagent', () => { expect(seen?.toolFilter).toEqual({ deny: ['subagent'] }) expect(seen?.toolFilter).not.toHaveProperty('allow') }) + + it('an explicit empty toolFilter fails at plugin load, not at first delegation', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'p', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: true, persona: false }, + inheritsParentContext: false, + start: () => { throw new Error('unreachable') }, + }) + const fiber = ctx.plugin(tool, { provider: 'p', toolFilter: {} }) + await expect(fiber).rejects.toThrow(/names neither `allow` nor `deny`/) + }) }) From d5d5e3fa3c3aace1dd6a64c3619d67eec12f449a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 04:53:58 +0800 Subject: [PATCH 15/64] docs: regenerate services catalog for the register() disposer signature --- docs/cordis-catalog/services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 42336012de..3dc09d3fa3 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -31,7 +31,7 @@ Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator setFactory(factory: AgentFactory): () => void create(options: CreateAgentOptions): AgentHandle async resume(options: ResumeAgentOptions): Promise -register(agent: Agent): () => void +register(agent: Agent): () => Promise | void get(id: AgentId): Agent | undefined list(): Agent[] ``` From db6aed0459aee0b48dead730448a74dade805a95 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:03:44 +0800 Subject: [PATCH 16/64] fix(subagent): key the structured stage by execution identity, not call id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex confirmation-round finding: an OUTERMOST prepend pre-execute deny skips the runtime's own pre-execute clear, and the denied call still reaches post-execute — so a reused adapter-minted call id could promote an orphaned stage on the default accept path. The stage is now keyed by the ToolExecution OBJECT identity, the one token that provably ties a stage to one pipeline trip: only the execution whose own body staged can commit, whatever any call id says. The pre-execute clear is gone (one mechanism); the commit's mismatch drop is now the reachable primary guard. Repro test: orphaned stage + outer pre-execute deny with the same call id never promotes; a fresh valid call still captures. --- .../subagent-inprocess/src/structured.ts | 53 ++++++++++--------- .../tests/structured.spec.ts | 45 ++++++++++++++++ 2 files changed, 73 insertions(+), 25 deletions(-) diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index 9a00c62029..d42a9b62ee 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -31,19 +31,21 @@ * lists `structured_output` before further tool calls cannot run side * effects after the final answer was accepted. * - `tools/post-execute` (prepend, scoped): the capture COMMIT. The tool body - * only STAGES the validated value, KEYED BY CALL ID; it becomes the run's - * captured result only when the final post-execute decision accepts THAT - * call. Call-keyed staging closes a stale-stage hole: an outer - * short-circuiting post-execute listener can orphan a staged value, and an - * un-keyed commit would then promote it on a LATER call's acceptance — - * reporting success for a value the model saw fail. + * only STAGES the validated value, KEYED BY THE EXECUTION OBJECT'S + * IDENTITY; it becomes the run's captured result only when the final + * post-execute decision accepts THAT SAME pipeline trip. Execution-keyed + * staging closes the stale-stage hole unconditionally: an outer + * short-circuiting listener (post-execute block, or a pre-execute deny + * whose call never dispatched) can orphan a staged value, and neither a + * later call nor one REUSING the same adapter-minted call id can ever + * promote it — only the execution whose own body staged can commit. * * @module @deepseek-ai/dsh-subagent-inprocess/structured */ import type { Context } from 'cordis' import type { Agent, ContinuationDecision } from '@deepseek-ai/dsh-agent' -import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import type { AssembleContext, PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } from '@deepseek-ai/dsh-tools' @@ -83,8 +85,15 @@ export interface StructuredAttachment { * @returns the attachment handle (read `captured()` after the child settles). */ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOutputSchema): StructuredAttachment { - /** A validated value staged by the capture tool body, awaiting ITS OWN call's post-execute verdict. */ - let pending: { callId: CallId; value: unknown } | undefined + /** + * A validated value staged by the capture tool body, awaiting ITS OWN + * call's post-execute verdict — keyed by the {@link ToolExecution} OBJECT + * identity, the one token that provably ties a stage to one trip through + * the pipeline. A call id cannot key this: ids are adapter-minted and may + * repeat across steps, and a denied/failed later call REUSING an orphaned + * stage's id must never promote it. + */ + let pending: { exec: ToolExecution; value: unknown } | undefined let captured: { value: unknown } | undefined const schemaEntry: ToolSchema = { @@ -104,10 +113,10 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut // ToolArgsError → isError result with INVALID_ARGS: the model retries // within the same turn, exactly like a schema-validated defineTool call. if (violations.length > 0) throw new ToolArgsError(violations) - // Two-phase commit, KEYED BY THIS CALL: the body only stages; the - // post-execute listener promotes exactly this call's entry when the - // final decision accepts it. - pending = { callId: exec.callId, value: args } + // Two-phase commit, KEYED BY THIS EXECUTION: the body only stages; the + // post-execute listener promotes exactly this pipeline trip's entry + // when the final decision accepts it. + pending = { exec, value: args } return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }]) }, }) @@ -159,13 +168,6 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut reason: `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`, }) } - // A NEW capture call invalidates any stale stage UNCONDITIONALLY, before - // dispatch: only THIS call's own body may stage for this call's commit. - // Without this, a stale entry orphaned by an outer short-circuited chain - // could be promoted by a later call REUSING the same call id whose body - // never staged (pre-execute-denied downstream, or invalid args throwing - // before the stage) — reporting success for a value the model saw fail. - if (exec.name === STRUCTURED_OUTPUT_TOOL) pending = undefined return next() }, { prepend: true }) @@ -178,14 +180,15 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut this: unknown, exec: ToolExecution, _result: ToolExecutionResult, next: () => Promise, ): Promise { if (exec.name !== STRUCTURED_OUTPUT_TOOL || pending === undefined) return next() - /* v8 ignore start -- defensive second layer: the pre-execute clear above - * already drops every stale stage before a new capture call dispatches, - * so a call-id mismatch cannot be reached through the tool pipeline */ - if (pending.callId !== exec.callId) { + if (pending.exec !== exec) { + // A stale stage from a DIFFERENT pipeline trip: its own chain was + // short-circuited past this commit (an outer post-execute block, or an + // outer pre-execute deny whose call never dispatched), so its verdict + // never reached us. Whatever the current call's id, the orphan must + // never ride its acceptance — drop it. pending = undefined return next() } - /* v8 ignore stop */ const staged = pending try { const decision = await next() diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 479bce58b9..3c2fed8351 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -568,4 +568,49 @@ describe('in-process structured output', () => { expect(valid.isError).toBeFalsy() await run.dispose() }) + + it('an outer pre-execute deny with call-id reuse cannot promote an orphaned stage either', async () => { + const { ctx, parent } = await setup([ + toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }), + ]) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const child = ctx.agents.get(run.id)! + // Orphan a stage via an outer post-execute BLOCK on the first capture. + let blocks = 1 + ctx.on('tools/post-execute', (exec, _result, next) => { + if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) { + blocks -= 1 + return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected' }] }) + } + return next() + }, { prepend: true }) + await run.result + // An OUTERMOST prepend pre-execute deny: the structured runtime's own + // pre-execute never runs for this call, and the denied call still goes + // through post-execute — with the SAME call id as the orphaned stage. + const offDeny = ctx.on('tools/pre-execute', (exec) => { + if (exec.name === STRUCTURED_OUTPUT_TOOL) { + return Promise.resolve({ kind: 'deny' as const, reason: 'outer veto' }) + } + return undefined as never + }, { prepend: true }) + const denied = await ctx.tools.execute({ + callId: 'c1' as never, + name: STRUCTURED_OUTPUT_TOOL, + arguments: { answer: 2 }, + agent: child, + }) + expect(denied.isError).toBe(true) + offDeny() + // The orphan was never promoted: a fresh valid call is still required + // (and succeeds, proving the runtime is not wedged). + const valid = await ctx.tools.execute({ + callId: 'c1' as never, + name: STRUCTURED_OUTPUT_TOOL, + arguments: { answer: 5 }, + agent: child, + }) + expect(valid.isError).toBeFalsy() + await run.dispose() + }) }) From e5093244fbcf3ca5fbabea482fdaeaab7f82ebd9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:21:06 +0800 Subject: [PATCH 17/64] fix(subagent): declare the dsh-scope dependency; make the re-assert REPLACE conflicting entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ds-review-bot round-2 findings: (1) dsh-subagent's runtime import of @deepseek-ai/dsh-scope was undeclared in its manifest and tsconfig references (the root paths map masked it; the emitted package would import an undeclared dependency) — wired as peer+dev with the project reference, module graph regenerated. (2) The structured re-assert only ensured PRESENCE, so a downstream listener injecting a same-named entry with the wrong schema kept it model-visible while validateStructuredValue enforced the real one; it now REPLACES any same-named tool/section with the run's own. Pinned by a wrong-schema-injection test asserting exactly one entry carrying the run's schema. --- docs/module-graph.md | 3 ++- .../subagent-inprocess/src/structured.ts | 18 ++++++++----- .../tests/structured.spec.ts | 26 +++++++++++++++++++ packages/subagent/subagent/package.json | 2 ++ packages/subagent/subagent/tsconfig.json | 3 +++ pnpm-lock.yaml | 3 +++ 6 files changed, 48 insertions(+), 7 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index a1a45e5b54..3f997e377a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -145,6 +145,7 @@ flowchart TD pkg_tool_fs --> pkg_tools pkg_subagent --> pkg_agent pkg_subagent --> pkg_llm + pkg_subagent --> pkg_scope pkg_subagent --> pkg_tools pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt @@ -251,7 +252,7 @@ flowchart TD | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`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) | diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index d42a9b62ee..51d1ea1b34 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -135,12 +135,18 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut this: unknown, _assembly: PromptAssembly, _context: AssembleContext, next: () => Promise, ): Promise { const final = await next() - if (!final.tools.some(tool => tool.name === STRUCTURED_OUTPUT_TOOL)) { - final.tools = [...final.tools, { ...schemaEntry, parameters: structuredClone(schemaEntry.parameters) }] - } - if (!final.sections.some(section => section.name === `tool:${STRUCTURED_OUTPUT_TOOL}`)) { - final.sections = [...final.sections, { name: `tool:${STRUCTURED_OUTPUT_TOOL}`, order: 190, text: STRUCTURED_OUTPUT_INSTRUCTION }] - } + // REPLACE, not merely ensure-present: a downstream listener may have + // mutated or injected a same-named entry with the WRONG schema/text, and + // the model-visible demand must be exactly this run's own — the same + // schema validateStructuredValue enforces. + final.tools = [ + ...final.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL), + { ...schemaEntry, parameters: structuredClone(schemaEntry.parameters) }, + ] + final.sections = [ + ...final.sections.filter(section => section.name !== `tool:${STRUCTURED_OUTPUT_TOOL}`), + { name: `tool:${STRUCTURED_OUTPUT_TOOL}`, order: 190, text: STRUCTURED_OUTPUT_INSTRUCTION }, + ] return final }, { prepend: true }) diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 3c2fed8351..3384fe4aa3 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -412,6 +412,32 @@ describe('in-process structured output', () => { await runB.dispose() }) + it('the re-assert REPLACES a conflicting injected schema, not merely ensures presence', async () => { + const { ctx, parent, adapter } = await setup([ + toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }), + ]) + // A global listener that INJECTS a wrong-schema structured_output entry: + // the child's re-assert must replace it with the run's own schema. + ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { + const replaced = await next() + return { + sections: replaced.sections, + tools: [ + ...replaced.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL), + { name: STRUCTURED_OUTPUT_TOOL, description: 'wrong', parameters: { type: 'object', properties: { bogus: { type: 'string' } } } }, + ], + variables: { ...replaced.variables }, + } + }) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const result = await run.result + expect(result.structured).toEqual({ answer: 5 }) + const entries = adapter.requests[0]!.tools!.filter(tool => tool.name === STRUCTURED_OUTPUT_TOOL) + expect(entries).toHaveLength(1) + expect(entries[0]!.parameters).toEqual(SCHEMA) + await run.dispose() + }) + it('the re-assert wins against a downstream listener that REPLACES the assembly object', async () => { const { ctx, parent, adapter } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }), diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index be5baeb0e1..eb0dbf8da0 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -24,12 +24,14 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.6" } diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index 0781a1129c..f93f929241 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../../core/tools" + }, + { + "path": "../../core/scope" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ffc2719d9..196191201e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -587,6 +587,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools From 8819c71b81d800f3f765f4a0102d409c35c8d656 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 06:06:17 +0800 Subject: [PATCH 18/64] docs: teach the graph generator the CHAINED fused-dispatch spelling ds-review-bot round-3 finding: agentEvents(ctx, agent).emit(...) has a call-expression receiver the generator's identifier check missed, silently dropping agent-loop as agent/session-start's producer. The generator now recognizes a call receiver whose callee is agentEvents; graph regenerated with the producer edge restored. --- docs/event-producer-consumer.md | 2 +- scripts/gen-doc-graphs.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index f93e808694..5687ad15d8 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -14,7 +14,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:414`](../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/queued` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:442`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:346`](../packages/core/agent/src/types.ts) | - | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:346`](../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:312`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:457`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:475`](../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) | diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 54f52ead18..f7e2ce9f4b 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -519,6 +519,11 @@ function collectEventRelations(): Map { } function isCordisContextReceiver(expr: ts.PropertyAccessExpression, sf: ts.SourceFile): boolean { + // The chained fused-dispatch spelling: `agentEvents(ctx, agent).emit(…)` — + // the receiver is a call expression, not an identifier. + if (ts.isCallExpression(expr.expression) && expr.expression.expression.getText(sf) === 'agentEvents') { + return true + } const target = expr.expression.getText(sf) if (target === 'ctx' || target === 'this.ctx') return true // Scoped-dispatch spellings (the agent-scoping seam): the loop's fused From 96c3c94f852c70af6cc5aa1d751fbe61e24640f7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:27:08 +0800 Subject: [PATCH 19/64] fix(subagent): make the structured re-assert placement-preserving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-auditing the review-fix commits surfaced a regression the REPLACE re-assert (825cbab3) introduced: unconditionally rebuilding both arrays as filter(...)+append moved structured_output to the END of the model-visible tool list on every untampered assembly (overriding the registry's toolOrder/lexicographic contract) and moved the instruction section to the absolute array end — renderPrompt reads ARRAY order, so any section above order 190 would render before the trailing instruction, violating the sections-sorted-ascending contract. The presence-check version it replaced touched neither array when the entries were intact. The re-assert keeps its REPLACE content semantics but is now placement-preserving: the tool is replaced IN PLACE (duplicates collapse, append only when stripped); the section is re-inserted at its ascending-order position (the first entry above 190 — exactly where the registry's stable sort put it, so the untampered path reaches the model byte-identical). Pinned by two regression tests that fail against the filter+append form: untampered placement (tool before a lexicographically later tool, instruction before an order-200 section) and tamper recovery (stripped section re-enters its band; an added duplicate collapses to one right-schema entry). --- .../subagent-inprocess/src/structured.ts | 49 +++++++++++---- .../tests/structured.spec.ts | 62 +++++++++++++++++++ 2 files changed, 100 insertions(+), 11 deletions(-) diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index 51d1ea1b34..70605dfcc2 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -21,8 +21,11 @@ * always carries its capture tool and the trailing instruction section. The * registry already contributes both; this outermost wrapper preserves the * guarantee against a (global) listener that strips or replaces the - * assembly. The loop logs the rendered assembly as the request header, so - * the demand is reconstructable log state, never a wire-only mutation. + * assembly — placement-preserving, so an untampered assembly reaches the + * model byte-identical (tools replaced in place, the section re-inserted at + * its ascending-order position). The loop logs the rendered assembly as the + * request header, so the demand is reconstructable log state, never a + * wire-only mutation. * - `agent/turn-continuation` (prepend, scoped): stop the child's turn once * its output is captured — the loop's default "had tool calls ⇒ continue" * would buy a wasted extra model step per structured child. @@ -138,15 +141,39 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut // REPLACE, not merely ensure-present: a downstream listener may have // mutated or injected a same-named entry with the WRONG schema/text, and // the model-visible demand must be exactly this run's own — the same - // schema validateStructuredValue enforces. - final.tools = [ - ...final.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL), - { ...schemaEntry, parameters: structuredClone(schemaEntry.parameters) }, - ] - final.sections = [ - ...final.sections.filter(section => section.name !== `tool:${STRUCTURED_OUTPUT_TOOL}`), - { name: `tool:${STRUCTURED_OUTPUT_TOOL}`, order: 190, text: STRUCTURED_OUTPUT_INSTRUCTION }, - ] + // schema validateStructuredValue enforces. Placement-preserving on both + // arrays: the untampered path must reach the model byte-identical to the + // registry's output (tool order is the `toolOrder`/lexicographic + // contract, section order is the ascending contract `renderPrompt` + // trusts), so this never reorders what it only re-asserts. + const freshTool: ToolSchema = { ...schemaEntry, parameters: structuredClone(schemaEntry.parameters) } + // Tools: replace the first same-named entry IN PLACE (its position is the + // chain's product; a tool's list position carries no semantic band to + // restore), drop any duplicates, append only when stripped entirely. + const tools: ToolSchema[] = [] + let toolReplaced = false + for (const tool of final.tools) { + if (tool.name !== STRUCTURED_OUTPUT_TOOL) { + tools.push(tool) + } else if (!toolReplaced) { + tools.push(freshTool) + toolReplaced = true + } + } + if (!toolReplaced) tools.push(freshTool) + final.tools = tools + // Sections: remove every same-named entry and re-insert at the + // ascending-correct position (the first entry above order 190) — sections + // DO carry an order contract, and the renderer reads array order, so a + // stripped-or-moved instruction is restored to its band, not appended + // after unrelated higher-order sections. On the untampered path this + // lands exactly where the registry's stable sort put it (last of the 190 + // band — the scoped section registers after every load-time 190). + const sectionName = `tool:${STRUCTURED_OUTPUT_TOOL}` + const sections = final.sections.filter(section => section.name !== sectionName) + const insertAt = sections.findIndex(section => section.order > 190) + sections.splice(insertAt === -1 ? sections.length : insertAt, 0, { name: sectionName, order: 190, text: STRUCTURED_OUTPUT_INSTRUCTION }) + final.sections = sections return final }, { prepend: true }) diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 3384fe4aa3..8f2e8e69ea 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -465,6 +465,68 @@ describe('in-process structured output', () => { await run.dispose() }) + it('the re-assert preserves the untampered assembly: tool position and section band are the registry\'s own', async () => { + const { ctx, parent, adapter } = await setup([ + toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }), + ]) + // A global tool sorting lexicographically AFTER structured_output and a + // global section ABOVE the 190 band: the re-assert must leave both + // exactly where the registry's ordering put them (no move-to-end). + ctx.tools.register({ + name: 'zz_probe', + description: 'probe', + parameters: { type: 'object', properties: {} }, + execute: () => Promise.resolve([{ type: 'text', text: 'x' }]), + }) + ctx.systemPrompt.section({ name: 'after-band', order: 200, text: 'AFTER-BAND' }) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + await run.result + const request = adapter.requests[0]! + const names = toolNames(request) + expect(names.indexOf(STRUCTURED_OUTPUT_TOOL)).toBeGreaterThanOrEqual(0) + expect(names.indexOf(STRUCTURED_OUTPUT_TOOL)).toBeLessThan(names.indexOf('zz_probe')) + const system = request.system ?? '' + const instructionAt = system.indexOf(STRUCTURED_OUTPUT_INSTRUCTION) + expect(instructionAt).toBeGreaterThanOrEqual(0) + expect(system.indexOf('AFTER-BAND')).toBeGreaterThan(instructionAt) + await run.dispose() + }) + + it('a stripped instruction re-inserts at its band; an added duplicate entry collapses to one', async () => { + const { ctx, parent, adapter } = await setup([ + toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 3 }), + ]) + ctx.systemPrompt.section({ name: 'after-band', order: 200, text: 'AFTER-BAND' }) + // Strip the instruction section entirely AND add a wrong-schema + // duplicate tool entry ALONGSIDE the registry's own: the re-assert must + // restore the section INTO its band (before the order-200 section, not + // appended after it) and collapse the tools to exactly one entry + // carrying the run's schema. + ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { + const replaced = await next() + return { + sections: replaced.sections.filter(section => section.name !== `tool:${STRUCTURED_OUTPUT_TOOL}`), + tools: [ + ...replaced.tools, + { name: STRUCTURED_OUTPUT_TOOL, description: 'wrong', parameters: { type: 'object', properties: { bogus: { type: 'string' } } } }, + ], + variables: { ...replaced.variables }, + } + }) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + const result = await run.result + expect(result.structured).toEqual({ answer: 3 }) + const request = adapter.requests[0]! + const entries = request.tools!.filter(tool => tool.name === STRUCTURED_OUTPUT_TOOL) + expect(entries).toHaveLength(1) + expect(entries[0]!.parameters).toEqual(SCHEMA) + const system = request.system ?? '' + const instructionAt = system.indexOf(STRUCTURED_OUTPUT_INSTRUCTION) + expect(instructionAt).toBeGreaterThanOrEqual(0) + expect(system.indexOf('AFTER-BAND')).toBeGreaterThan(instructionAt) + await run.dispose() + }) + it('a non-structured agent request keeps tools ABSENT when it had none (no tools: [] materialized)', async () => { const { parent, adapter } = await setup([textResponse('plain')]) parent.send([{ type: 'text', text: 'q' }]) From 6f4ea8a2601fdcd1c2f3843f41f6092fbb90a0bb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:30:27 +0800 Subject: [PATCH 20/64] refactor(subagent): stage structured captures in a WeakMap keyed by execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes the single-slot staging the execution-identity fix (06c5f17e) kept: the one pending slot needed a mismatch-drop branch plus a defensive coverage-ignored finally to manage orphans, and it carried a latent trap — under the loop's documented parallel-execution TODO, two in-flight capture trips would overwrite the slot and BOTH be dropped. Staging in a WeakMap makes the stale-stage class structurally impossible instead of managed: an entry orphaned by an outer short-circuiting listener can never match a different execution's lookup (whatever call id that execution carries), needs no drop bookkeeping (the map reclaims it with the execution object), and staging cannot cross-clobber under parallel execution. Staging is the only layer this future-proofs — a parallel cut would still owe its own single-accept rule for the captured value, which is documented rather than claimed. Behavior is pinned by the existing orphan/call-id-reuse regression tests, which pass unchanged; the commit listener loses two branches and the v8-ignore. --- .../subagent-inprocess/src/structured.ts | 79 +++++++++---------- .../tests/structured.spec.ts | 2 +- 2 files changed, 39 insertions(+), 42 deletions(-) diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index 70605dfcc2..5107897c68 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -34,14 +34,14 @@ * lists `structured_output` before further tool calls cannot run side * effects after the final answer was accepted. * - `tools/post-execute` (prepend, scoped): the capture COMMIT. The tool body - * only STAGES the validated value, KEYED BY THE EXECUTION OBJECT'S - * IDENTITY; it becomes the run's captured result only when the final + * only STAGES the validated value, KEYED BY THE EXECUTION OBJECT in a + * WeakMap; it becomes the run's captured result only when the final * post-execute decision accepts THAT SAME pipeline trip. Execution-keyed - * staging closes the stale-stage hole unconditionally: an outer - * short-circuiting listener (post-execute block, or a pre-execute deny - * whose call never dispatched) can orphan a staged value, and neither a - * later call nor one REUSING the same adapter-minted call id can ever - * promote it — only the execution whose own body staged can commit. + * staging makes the stale-stage class structurally impossible: a value + * orphaned by an outer short-circuiting listener (a post-execute block, or + * a pre-execute deny whose call never dispatched) can never match another + * execution's lookup — whatever call id that execution carries — and is + * reclaimed with the execution object itself. * * @module @deepseek-ai/dsh-subagent-inprocess/structured */ @@ -89,14 +89,20 @@ export interface StructuredAttachment { */ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOutputSchema): StructuredAttachment { /** - * A validated value staged by the capture tool body, awaiting ITS OWN - * call's post-execute verdict — keyed by the {@link ToolExecution} OBJECT - * identity, the one token that provably ties a stage to one trip through - * the pipeline. A call id cannot key this: ids are adapter-minted and may - * repeat across steps, and a denied/failed later call REUSING an orphaned - * stage's id must never promote it. + * Validated values staged by the capture tool body, awaiting THEIR OWN + * call's post-execute verdict — keyed by the {@link ToolExecution} OBJECT, + * the one token that provably ties a stage to one trip through the + * pipeline. A call id cannot key this: ids are adapter-minted and may + * repeat across steps. Keying by execution makes the stale-stage class + * structurally impossible — an entry orphaned by an outer short-circuiting + * listener can never match a different execution's lookup, needs no drop + * bookkeeping (the WeakMap reclaims it with the execution object), and two + * in-flight captures can never cross-clobber each other's STAGE should + * tool execution ever go parallel (the loop's documented TODO). Staging is + * the only layer this future-proofs: a parallel-execution cut would still + * owe its own single-accept rule for `captured` itself. */ - let pending: { exec: ToolExecution; value: unknown } | undefined + const staged = new WeakMap() let captured: { value: unknown } | undefined const schemaEntry: ToolSchema = { @@ -119,7 +125,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut // Two-phase commit, KEYED BY THIS EXECUTION: the body only stages; the // post-execute listener promotes exactly this pipeline trip's entry // when the final decision accepts it. - pending = { exec, value: args } + staged.set(exec, { value: args }) return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }]) }, }) @@ -204,35 +210,26 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut return next() }, { prepend: true }) - // The capture COMMIT: promote the staged value only when the final - // post-execute decision accepts THE SAME CALL that staged it. The staging - // slot clears on every path for that call; a stale entry from an outer - // short-circuited chain (its verdict never reached us) is dropped when any - // later call reaches the commit, never promoted. + // The capture COMMIT: promote a staged value only when the final + // post-execute decision accepts THE SAME EXECUTION that staged it — the + // lookup key IS the execution, so a stale entry from a different pipeline + // trip (its own chain short-circuited past this commit by an outer + // post-execute block, or an outer pre-execute deny whose call never + // dispatched) is unreachable here by construction, whatever the current + // call's id. childCtx.on('tools/post-execute', async function ( this: unknown, exec: ToolExecution, _result: ToolExecutionResult, next: () => Promise, ): Promise { - if (exec.name !== STRUCTURED_OUTPUT_TOOL || pending === undefined) return next() - if (pending.exec !== exec) { - // A stale stage from a DIFFERENT pipeline trip: its own chain was - // short-circuited past this commit (an outer post-execute block, or an - // outer pre-execute deny whose call never dispatched), so its verdict - // never reached us. Whatever the current call's id, the orphan must - // never ride its acceptance — drop it. - pending = undefined - return next() - } - const staged = pending - try { - const decision = await next() - if (decision.kind === 'accept') captured = { value: staged.value } - return decision - } finally { - /* v8 ignore next -- defensive false branch: a concurrent re-stage - * would need a second capture call INSIDE the first's post-execute - * chain */ - if (pending === staged) pending = undefined - } + if (exec.name !== STRUCTURED_OUTPUT_TOOL) return next() + const entry = staged.get(exec) + if (entry === undefined) return next() + // Single-shot per execution: this trip's verdict is decided by the chain + // below, never revisited (the WeakMap would reclaim the entry either way; + // deleting states the intent). + staged.delete(exec) + const decision = await next() + if (decision.kind === 'accept') captured = { value: entry.value } + return decision }, { prepend: true }) return { captured: () => captured } diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 8f2e8e69ea..e93faed4f1 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -578,7 +578,7 @@ describe('in-process structured output', () => { expect(result.error?.code).toBe('UNKNOWN_TOOL') }) - it('drops a stale stage from a short-circuited chain: a later call never promotes it (call-keyed commit)', async () => { + it('a stale stage from a short-circuited chain is never promoted by a later call (execution-keyed commit)', async () => { const { ctx, parent } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }), ]) From a3244a5774b91b7224a86d8373aa403cdca4fac3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:33:18 +0800 Subject: [PATCH 21/64] fix(tool-subagent): an omitted agentOptions must not materialize an empty object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The partial-toolFilter materialization fix (da6c6d58) stopped one field short: the adjacent agentOptions key in the SAME Config has the same schemastery trap. An omitted agentOptions materializes {}, which is truthy — so every yml-configured load put a dishonest agentOptions: {} on every start request and the presence check in execute() could never be false through config (only unit tests bypassing schemastery ever exercised that branch). Harmless downstream today (the driver only spreads it), but the request shape lied and the check was production-dead. Same discipline as its toolFilter sibling: the omitted key now defaults to undefined, the presence check is spelled !== undefined like its neighbors, and a regression test (fails against the unfixed schema) pins that an omitted agentOptions stays absent from the request. Swept every other Config in the repo for the class: no further instances — omitted primitives inside a materialized object stay ABSENT (verified empirically), so subagent-mock's capabilities spread is safe, and the remaining object/array fields all carry explicit defaults or the forced-undefined discipline already. --- packages/subagent/tool-subagent/src/index.ts | 8 +++-- .../tool-subagent/tests/tool-subagent.spec.ts | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index f677076d3f..31d6dce113 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -93,9 +93,13 @@ export interface Config { export const Config: z = z.object({ provider: z.string().required(), toolName: z.string().default('subagent'), + // Omitted-object discipline (see the toolFilter note below): without the + // forced default an omitted `agentOptions` materializes `{}`, which reads as + // present — the request would carry `agentOptions: {}` and the presence + // check in execute() could never be false through config. agentOptions: z.object({ model: z.string(), - }), + }).default(undefined as unknown as { model: string }), persona: z.string(), // A schemastery object materializes {} (with [] for nested arrays) when the // key is omitted — for toolFilter that would mean an EMPTY ALLOW-LIST, i.e. @@ -229,7 +233,7 @@ export function apply(ctx: Context, config: Config): void { prompt: [{ type: 'text', text: args.prompt }], parent, ...exec.signal ? { signal: exec.signal } : {}, - ...config.agentOptions ? { agentOptions: config.agentOptions } : {}, + ...config.agentOptions !== undefined ? { agentOptions: config.agentOptions } : {}, ...config.persona !== undefined ? { persona: config.persona } : {}, ...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {}, ...config.maxDepth !== undefined ? { maxDepth: config.maxDepth } : {}, diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index da560350e4..9d9941fdff 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -511,6 +511,36 @@ describe('dsh-tool-subagent', () => { expect(seen?.toolFilter).not.toHaveProperty('allow') }) + it('an omitted agentOptions does not materialize an empty object onto the request', async () => { + // Same schemastery trap as toolFilter, adjacent field: an omitted + // `agentOptions` config key materializes `{}` without the forced default, + // which reads as present and puts a dishonest `agentOptions: {}` on every + // start request. + let seen: { agentOptions?: unknown } | undefined + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'capture4', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: (request) => { + seen = request + return { + id: AgentId('capture4-child'), + result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), + cancel() {}, + dispose: async () => {}, + } + }, + }) + await ctx.plugin(tool, { provider: 'capture4' }) + await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(seen).toBeDefined() + expect(seen).not.toHaveProperty('agentOptions') + }) + it('an explicit empty toolFilter fails at plugin load, not at first delegation', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) From 4e8dc8e8f57918e1881889c7d2440f10159d0f4a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:36:18 +0800 Subject: [PATCH 22/64] docs: fail the event matrix on a zero-dispatcher row; attribute provider-removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chained-fused-dispatch spelling fix (0dc2fe90) was whack-a-mole: each unrecognized dispatch spelling silently drops a producer edge from the generated matrix, and only a human reading the table catches it. Convert the class to a build failure: the generator now hard-errors when any DECLARED event ends with zero dispatchers — dead vocabulary or a missed spelling, both actionable ('teach the scan or add a DYNAMIC_EVENT_DISPATCHERS override'). Zero LISTENERS stays legal: seven current rows (agent/request, system-prompt/assemble, tools/change, ...) are ordinary extension points dispatched for out-of-repo plugins. The guard caught a real one on its first run: subagent/provider-removed routes through the same contained events.dispatch as subagent/start|end (it fires inside the provider registration's disposer), but the DYNAMIC_EVENT_DISPATCHERS override list never got an entry when that containment routing was introduced — the committed matrix (on master too) claimed the event has NO dispatcher while tool-subagent listens for it. Override added; matrix regenerated with the producer edge restored. --- docs/event-producer-consumer.md | 2 +- scripts/gen-doc-graphs.ts | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 5687ad15d8..3ea9c7cbbd 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:107`](../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:73`](../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:84`](../packages/subagent/subagent/src/index.ts) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:84`](../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:96`](../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:44`](../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:54`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index f7e2ce9f4b..a61bfca763 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -203,6 +203,10 @@ const DYNAMIC_EVENT_DISPATCHERS: Array<{ event: string; pkg: string; method: str // listeners or strand an already-started child run. { event: 'subagent/start', pkg: 'subagent', method: 'events.dispatch' }, { event: 'subagent/end', pkg: 'subagent', method: 'events.dispatch' }, + // provider-removed fires inside the provider registration's DISPOSER and + // routes through the same contained dispatch (see emitLifecycle in + // dsh-subagent), so the AST scan cannot attribute it either. + { event: 'subagent/provider-removed', pkg: 'subagent', method: 'events.dispatch' }, ] function generatedHeader(title: string): string[] { @@ -579,6 +583,24 @@ function renderEventRelations(pkgs: Pkg[]): string { const relation = relations.get(event.name) ?? { dispatchers: new Map>(), listeners: new Set() } lines.push(`| \`${event.name}\` | \`${event.mode}\` | ${sourceLink(event.source)} | ${relationPackages(relation.dispatchers, pkgsByShort)} | ${listenerPackages(relation.listeners, pkgsByShort)} |`) } + // Completeness guard: every DECLARED event must have at least one dispatcher + // edge — a zero-dispatcher row is either dead vocabulary or (the observed + // failure mode) a dispatch spelling the AST scan does not recognize, silently + // dropping the producer from the matrix. Fail the generation loud instead: + // teach the scan the new spelling, add a DYNAMIC_EVENT_DISPATCHERS override, + // or remove the dead event. Zero LISTENERS is deliberately legal — an event + // dispatched for out-of-repo plugins is an ordinary extension point. + const undispatched = [...events] + .filter(event => (relations.get(event.name)?.dispatchers.size ?? 0) === 0) + .map(event => event.name) + .sort() + if (undispatched.length > 0) { + throw new Error( + `event-producer-consumer matrix: no dispatcher found for declared event${undispatched.length > 1 ? 's' : ''} ` + + `${undispatched.map(name => `"${name}"`).join(', ')} — dead vocabulary, or a dispatch spelling the scan misses ` + + '(teach scripts/gen-doc-graphs.ts the spelling or add a DYNAMIC_EVENT_DISPATCHERS override)', + ) + } const declared = new Set(events.map(event => event.name)) const extra = [...relations.keys()].filter(event => !declared.has(event)).sort() if (extra.length > 0) { From 7c5133488a1fb8ca403796f86595fbcb50a3da70 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:05:44 +0800 Subject: [PATCH 23/64] refactor(core): every registry register-method returns the exact effect disposer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exact-disposer fix (5fbac8be B1) repaired agents.register but left the same wrapper (return () => void dispose()) at seven sibling sites: tools.register, tools.restrict, systemPrompt.section/tools/variable, agents.setFactory, and subagents.registerProvider. A wrapper makes correct composite usage unrepresentable — the exact disposer cannot be recovered, so a generator effect yielding it leaves the inner effect disposing as a CONCURRENT SIBLING on owner unload, silently reproducing B1's ordering corruption. The exact disposer serves both usages (composite-nestable AND fire-and-forget callable); all seven now return it, typed () => Promise | void, with the convention pinned by a discriminating test: an async-link composite probe that passes with the exact disposer and observes the sibling unregistration firing mid-drain with a wrapper. Re-auditing also surfaced that B1 itself SHIPPED a full-lint failure: it changed register()'s return type without updating cross-file consumers (agent.spec.ts dispose() statements, tool-bash's disposer list), which the staged-scoped pre-commit lint never saw — pnpm run lint was red at HEAD. Those three sites and this change's own fallout are fixed together: tests now await disposers (stronger — they observe the full unwind), sync paths void them, and the two annotation sites carry the honest union type. agents.register's README line had drifted the same way (B1 updated the JSDoc, not the README) — all seven README signatures now match; services catalog regenerated. --- docs/cordis-catalog/services.md | 14 +++--- packages/bash/tool-bash/tests/tools.spec.ts | 4 +- packages/core/agent/README.md | 4 +- packages/core/agent/src/index.ts | 12 +++-- packages/core/agent/tests/agent.spec.ts | 6 +-- packages/core/system-prompt/README.md | 6 +-- packages/core/system-prompt/src/index.ts | 48 +++++++++++++------ .../core/system-prompt/tests/scoped.spec.ts | 2 +- .../system-prompt/tests/system-prompt.spec.ts | 8 ++-- packages/core/tools/README.md | 4 +- packages/core/tools/src/index.ts | 32 +++++++++---- packages/core/tools/tests/scoped.spec.ts | 2 +- packages/core/tools/tests/tools.spec.ts | 33 ++++++++++++- .../tests/structured.spec.ts | 2 +- packages/subagent/subagent/src/index.ts | 16 +++++-- .../subagent/subagent/tests/service.spec.ts | 8 ++-- packages/subagent/tool-subagent/src/index.ts | 4 +- 17 files changed, 138 insertions(+), 67 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 3dc09d3fa3..783ef7c83e 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -28,7 +28,7 @@ Source: [`packages/core/agent-loop/src/index.ts:70`](../../packages/core/agent-l Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package. Agent *creation* is provided by whichever plugin implements the AgentFactory (phase 1: `@deepseek-ai/dsh-agent-loop`), registered via setFactory. ```ts cordis-catalog -setFactory(factory: AgentFactory): () => void +setFactory(factory: AgentFactory): () => Promise | void create(options: CreateAgentOptions): AgentHandle async resume(options: ResumeAgentOptions): Promise register(agent: Agent): () => Promise | void @@ -191,7 +191,7 @@ Source: [`packages/core/session/src/index.ts:427`](../../packages/core/session/s The `subagents` service: a registry of named SubagentProviders and a capability-checked start surface. ```ts cordis-catalog -registerProvider(provider: SubagentProvider): () => void +registerProvider(provider: SubagentProvider): () => Promise | void getProvider(name: string): SubagentProvider | undefined list(): string[] start(name: string, request: SubagentStartRequest): SubagentRun @@ -204,9 +204,9 @@ Source: [`packages/subagent/subagent/src/index.ts:153`](../../packages/subagent/ Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, and named prompt variables; 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): () => void -tools(provider: (context: AssembleContext) => ToolProviderResult): () => void -variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void +section(section: PromptSection): () => Promise | void +tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise | void +variable(name: string, provider: (context: AssembleContext) => string | undefined): () => Promise | void async assemble(context: AssembleContext = {}): Promise ``` @@ -219,8 +219,8 @@ Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop e Two registration layers (`@deepseek-ai/dsh-scope`): a registration through a plain plugin context is GLOBAL (visible to every agent); one through a scoped context (`agent.ctx`) is filed in that scope's layer — visible to that agent alone, disposed with the scope, and SHADOWING a global tool of the same name for that agent (most-specific-wins; within one layer a duplicate name still throws). restrict masks the global layer per scope. One visibility function (visible) feeds prompt assembly, get, and execute, so what the model is shown, what a presenter renders, and what dispatches can never disagree. ```ts cordis-catalog -register(definition: ToolDefinition): () => void -restrict(filter: ToolRestriction): () => void +register(definition: ToolDefinition): () => Promise | void +restrict(filter: ToolRestriction): () => Promise | void visible(scope?: ScopeKey): ToolDefinition[] get(name: string, scope?: ScopeKey): ToolDefinition | undefined schemas(scope?: ScopeKey): ToolSchema[] diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 7d4b34f74f..6299aea7d3 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -35,7 +35,7 @@ async function setup() { * The registration disposer is tracked so {@link unregisterFakeAgents} can drop * it (simulating the owning session disconnecting before a task completes). */ -const fakeAgentDisposers = new Map void)[]>() +const fakeAgentDisposers = new Map Promise | void)[]>() function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void): Agent { // The registry KEY (agent.id) is deliberately DIFFERENT from the session // token (session.header.id) — a config agent has `agentId !== sessionId`. The @@ -53,7 +53,7 @@ function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: un /** Unregister every fake agent in this ctx (simulate the owning session disconnecting). */ function unregisterFakeAgents(ctx: Context): void { - for (const dispose of fakeAgentDisposers.get(ctx) ?? []) dispose() + for (const dispose of fakeAgentDisposers.get(ctx) ?? []) void dispose() fakeAgentDisposers.delete(ctx) } diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index e0668ef761..5c7b723739 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -10,7 +10,7 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher every agent-subject event goes through (carrier + injected subject in one move); `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` composes a child's scoped world at creation — setup registers, it never drives. -- `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber. +- `ctx.agents.register(agent: Agent): () => Promise | void` — record an **already-constructed** agent. Disposed with the calling fiber. - `ctx.agents.get(id: AgentId): Agent | undefined` - `ctx.agents.list(): Agent[]` @@ -18,7 +18,7 @@ The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh- Agent *creation* is provided by whichever plugin implements `AgentFactory` (phase 1: `dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. -- `ctx.agents.setFactory(factory: AgentFactory): () => void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose. +- `ctx.agents.setFactory(factory: AgentFactory): () => Promise | void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose. - `ctx.agents.create(options: CreateAgentOptions): AgentHandle` — construct, start, AND register a new agent on a caller-supplied `sessionId` (with optional `meta.cwd`/`meta.parentSession`/`meta.seedLength` and optional `seed` events for forked children). Distinct from `register` (which only records). Throws if no factory is registered. - `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. Async; rejects if no factory is registered, or if the factory finds session persistence unconfigured. diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 3821acd442..588a3c8ee1 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -162,15 +162,21 @@ export class AgentRegistry extends Service { * effect-scoped). Throws if a factory is already registered. Returns the * disposer; on dispose the factory slot is cleared. * @param factory - the loop-owned factory {@link create}/{@link resume} delegate to. - * @returns the disposer that clears the factory slot. + * @returns the disposer that clears the factory slot. The exact + * Cordis effect disposer (single-shot): composite (generator) effects may + * yield it directly — exact identity nests the teardown in order. */ - setFactory(factory: AgentFactory): () => void { + setFactory(factory: AgentFactory): () => Promise | void { const dispose = this.ctx.effect(() => { if (this.factory !== undefined) throw new Error('an agent factory is already registered') this.factory = factory return () => { this.factory = undefined } }, 'agents.setFactory()') - return () => void dispose() + // The exact cordis effect disposer (the agents.register() convention): a + // caller's composite effect can yield it for in-order teardown; the + // loop's constructor effect returns it directly, identity-nesting the + // registration under that effect. + return dispose } /** diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 43b4752f1b..08623549ec 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -37,7 +37,7 @@ describe('AgentRegistry', () => { expect(ctx.agents.get(AgentId('a1'))).toBe(agent) expect(ctx.agents.list()).toEqual([agent]) - dispose() + await dispose() expect(disposed).toEqual(['a1']) expect(ctx.agents.get(AgentId('a1'))).toBeUndefined() }) @@ -74,7 +74,7 @@ describe('AgentRegistry', () => { // tracked exactly once (the duplicate-id check is not wedged). const dispose = ctx.agents.register(stubAgent('main')) expect(ctx.agents.list().map(a => a.id)).toEqual(['main']) - dispose() + await dispose() expect(ctx.agents.get(AgentId('main'))).toBeUndefined() }) }) @@ -128,7 +128,7 @@ describe('AgentRegistry factory seam', () => { it('disposing the setFactory fiber clears the factory (HMR safety)', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) - let dispose!: () => void + let dispose!: () => Promise | void const fiber = await ctx.plugin(Object.assign((inner: Context) => { dispose = inner.agents.setFactory(stubFactory().factory) }, { inject: ['agents'] })) diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index b890ad3cd5..bf8c8bc4cc 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -13,9 +13,9 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool- ### Public API -- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the CALLING context's scope: `agent.ctx` contributes to that agent alone, SHADOWING a same-named global section there (the per-agent persona mechanism — a scoped `deployment:persona`). Duplicate names within one layer throw. Disposed with the calling fiber. -- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set for `context.scope`; `knownNames` (defaulting to the schemas' names) is the pre-restriction universe `toolOrder` validates against. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber. -- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables (via `agent.ctx`) shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber. +- `ctx.systemPrompt.section(section: PromptSection): () => Promise | void` Contribute a section. The layer is the CALLING context's scope: `agent.ctx` contributes to that agent alone, SHADOWING a same-named global section there (the per-agent persona mechanism — a scoped `deployment:persona`). Duplicate names within one layer throw. Disposed with the calling fiber. +- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise | void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set for `context.scope`; `knownNames` (defaulting to the schemas' names) is the pre-restriction universe `toolOrder` validates against. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber. +- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => Promise | void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables (via `agent.ctx`) shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber. - `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer (scoped shadows global). Runs through the `system-prompt/assemble` waterfall (scope-filtered by `context.scope`). Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe (a restricted-away KNOWN tool is a normal absence), or when a provider returns the reserved rest-entry name. ### Events diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 8fa2daf3b0..f89370447f 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -389,9 +389,11 @@ export class SystemPrompt extends Service { * alternative). Removed when the calling fiber is disposed. Emits * `system-prompt/change` on register/unregister. * @param section - the section to contribute (name, order, text or provider). - * @returns the disposer that removes the section. + * @returns the disposer that removes the section. The exact + * Cordis effect disposer (single-shot): composite (generator) effects may + * yield it directly — exact identity nests the teardown in order. */ - section(section: PromptSection): () => void { + section(section: PromptSection): () => Promise | void { const scope = scopeOf(this.ctx) const dispose = this.ctx.effect(function* (this: SystemPrompt) { const layer = scope === undefined @@ -420,9 +422,13 @@ export class SystemPrompt extends Service { } this.ctx.emit('system-prompt/change') }.bind(this), 'systemPrompt.section()') - // ctx.effect's disposer returns Promise; our disposer API is - // synchronous fire-and-forget — discard the (always-resolved) promise. - return () => void dispose() + // The EXACT cordis effect disposer, not a wrapper: a composite (generator) + // effect that owns a teardown ORDER must be able to yield THIS function — + // cordis nests a disposer out of the fiber's concurrent sibling list by + // exact function identity, so a wrapper would silently break the nesting + // (the agents.register() lesson). Fire-and-forget callers may still + // discard the (always-resolved) promise. + return dispose } /** @@ -437,9 +443,11 @@ export class SystemPrompt extends Service { * {@link Config.toolOrder}'s rest entry and rejects the assembly. Emits * `system-prompt/change`. * @param provider - evaluated at every {@link assemble} for fresh schemas. - * @returns the disposer that removes the provider. + * @returns the disposer that removes the provider. The exact + * Cordis effect disposer (single-shot): composite (generator) effects may + * yield it directly — exact identity nests the teardown in order. */ - tools(provider: (context: AssembleContext) => ToolProviderResult): () => void { + tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise | void { const scope = scopeOf(this.ctx) const dispose = this.ctx.effect(function* (this: SystemPrompt) { const layer = scope === undefined @@ -460,9 +468,13 @@ export class SystemPrompt extends Service { } this.ctx.emit('system-prompt/change') }.bind(this), 'systemPrompt.tools()') - // ctx.effect's disposer returns Promise; our disposer API is - // synchronous fire-and-forget — discard the (always-resolved) promise. - return () => void dispose() + // The EXACT cordis effect disposer, not a wrapper: a composite (generator) + // effect that owns a teardown ORDER must be able to yield THIS function — + // cordis nests a disposer out of the fiber's concurrent sibling list by + // exact function identity, so a wrapper would silently break the nesting + // (the agents.register() lesson). Fire-and-forget callers may still + // discard the (always-resolved) promise. + return dispose } /** @@ -479,9 +491,11 @@ export class SystemPrompt extends Service { * emits `system-prompt/change` on register/unregister. * @param name - the reference name (matches `[a-z][a-z0-9_]*`). * @param provider - evaluated at every {@link assemble} for the value. - * @returns the disposer that removes the variable. + * @returns the disposer that removes the variable. The exact + * Cordis effect disposer (single-shot): composite (generator) effects may + * yield it directly — exact identity nests the teardown in order. */ - variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void { + variable(name: string, provider: (context: AssembleContext) => string | undefined): () => Promise | void { const scope = scopeOf(this.ctx) const dispose = this.ctx.effect(function* (this: SystemPrompt) { if (!VARIABLE_NAME.test(name)) { @@ -508,9 +522,13 @@ export class SystemPrompt extends Service { } this.ctx.emit('system-prompt/change') }.bind(this), 'systemPrompt.variable()') - // ctx.effect's disposer returns Promise; our disposer API is - // synchronous fire-and-forget — discard the (always-resolved) promise. - return () => void dispose() + // The EXACT cordis effect disposer, not a wrapper: a composite (generator) + // effect that owns a teardown ORDER must be able to yield THIS function — + // cordis nests a disposer out of the fiber's concurrent sibling list by + // exact function identity, so a wrapper would silently break the nesting + // (the agents.register() lesson). Fire-and-forget callers may still + // discard the (always-resolved) promise. + return dispose } /** diff --git a/packages/core/system-prompt/tests/scoped.spec.ts b/packages/core/system-prompt/tests/scoped.spec.ts index 99e6b1c113..729ec52b1c 100644 --- a/packages/core/system-prompt/tests/scoped.spec.ts +++ b/packages/core/system-prompt/tests/scoped.spec.ts @@ -104,7 +104,7 @@ describe('scoped tool providers and toolOrder × restriction', () => { const ctx = await mount() const scope = await mintScope(ctx, 'child') const dispose = scope.ctx.systemPrompt.tools(() => ({ schemas: [schema('scoped_tool')] })) - dispose() + await dispose() const after = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }) expect(after.tools.map(t => t.name)).toEqual([]) // Re-registering through the same scope starts a fresh layer. diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index 560a640643..3d4cb9d02c 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -247,7 +247,7 @@ describe('SystemPrompt', () => { // registration emits change expect(changeCount).toBe(1) - dispose() + await dispose() // disposal emits change again expect(changeCount).toBe(2) }) @@ -272,7 +272,7 @@ describe('SystemPrompt', () => { const dispose = ctx.systemPrompt.section({ name: 'direct', order: 0, text: 'direct section' }) expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(1) - dispose() + await dispose() expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(0) }) @@ -283,7 +283,7 @@ describe('SystemPrompt', () => { const dispose = ctx.systemPrompt.tools(() => ({ schemas: [{ name: 'direct-tool', description: '', parameters: {} }] })) expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(1) - dispose() + await dispose() expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(0) }) @@ -301,7 +301,7 @@ describe('SystemPrompt', () => { // A provider returning undefined records "registered but no value here". expect((await ctx.systemPrompt.assemble()).variables).toEqual({ who: undefined }) - dispose() + await dispose() expect(changeCount).toBe(2) expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) }) diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index b42087b5d6..e5e953bc84 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -6,8 +6,8 @@ Tool registry and execution pipeline. Tool plugins register their schemas and ex ### Public API -- `ctx.tools.register(definition: ToolDefinition): () => void` Register a tool. The layer is the CALLING context's scope (`dsh-scope`): a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, SHADOWING a same-named global tool there (per-agent tool variants). Duplicate names within one layer throw. Disposed with the calling fiber (= the agent, for scoped registrations). -- `ctx.tools.restrict(filter: ToolRestriction): () => void` Scoped-only (throws on a plain context): mask the GLOBAL tool surface for the calling agent — `allow` keeps only the listed tools, `deny` removes them; multiple restrictions intersect; scoped registrations bypass restriction as explicit grants. Snapshot-at-registration, loud unknown-name validation, `restrict({})` rejects (the materialized-empty-config trap). +- `ctx.tools.register(definition: ToolDefinition): () => Promise | void` Register a tool. The layer is the CALLING context's scope (`dsh-scope`): a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, SHADOWING a same-named global tool there (per-agent tool variants). Duplicate names within one layer throw. Disposed with the calling fiber (= the agent, for scoped registrations). +- `ctx.tools.restrict(filter: ToolRestriction): () => Promise | void` Scoped-only (throws on a plain context): mask the GLOBAL tool surface for the calling agent — `allow` keeps only the listed tools, `deny` removes them; multiple restrictions intersect; scoped registrations bypass restriction as explicit grants. Snapshot-at-registration, loud unknown-name validation, `restrict({})` rejects (the materialized-empty-config trap). - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. - `ctx.tools.visible(scope?: ScopeKey): ToolDefinition[]` THE visibility function — restricted global layer ∪ the scope's own layer — feeding prompt assembly, `get`, and `execute`, so what the model sees and what dispatches can never disagree. - `ctx.tools.knownNames(scope?: ScopeKey): string[]` The PRE-restriction name universe configuration (`toolOrder`, `restrict`) validates against: a typo fails loud while a restricted-away tool stays a normal absence. diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index cfbeffbd57..d449fb7801 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -344,9 +344,11 @@ export class ToolRegistry extends Service { * Emits `tools/change` on register/unregister. * @param definition - the tool's schema plus its execute (and optional * presentation) functions. - * @returns the disposer that unregisters the tool. + * @returns the disposer that unregisters the tool. The exact + * Cordis effect disposer (single-shot): composite (generator) effects may + * yield it directly — exact identity nests the teardown in order. */ - register(definition: ToolDefinition): () => void { + register(definition: ToolDefinition): () => Promise | void { const scope = scopeOf(this.ctx) const dispose = this.ctx.effect(function* (this: ToolRegistry) { const layer = scope === undefined ? this.global : this.layerFor(scope) @@ -370,9 +372,13 @@ export class ToolRegistry extends Service { } this.ctx.emit('tools/change') }.bind(this), 'tools.register()') - // ctx.effect's disposer returns Promise; our disposer API is - // synchronous fire-and-forget — discard the (always-resolved) promise. - return () => void dispose() + // The EXACT cordis effect disposer, not a wrapper: a composite (generator) + // effect that owns a teardown ORDER must be able to yield THIS function — + // cordis nests a disposer out of the fiber's concurrent sibling list by + // exact function identity, so a wrapper would silently break the nesting + // (the agents.register() lesson). Fire-and-forget callers may still + // discard the (always-resolved) promise. + return dispose } /** @@ -389,9 +395,11 @@ export class ToolRegistry extends Service { * Scoped registrations bypass restrictions (explicit grants win). Disposed * with the calling fiber (revocable independently); emits `tools/change`. * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove). - * @returns the disposer that lifts this restriction. + * @returns the disposer that lifts this restriction. The exact + * Cordis effect disposer (single-shot): composite (generator) effects may + * yield it directly — exact identity nests the teardown in order. */ - restrict(filter: ToolRestriction): () => void { + restrict(filter: ToolRestriction): () => Promise | void { const scope = scopeOf(this.ctx) if (scope === undefined) { throw new Error('tools.restrict() requires a scoped context (agent.ctx): a context-global restriction would mask every agent — deny the tool for the intended agent instead') @@ -422,9 +430,13 @@ export class ToolRegistry extends Service { } this.ctx.emit('tools/change') }.bind(this), 'tools.restrict()') - // ctx.effect's disposer returns Promise; our disposer API is - // synchronous fire-and-forget — discard the (always-resolved) promise. - return () => void dispose() + // The EXACT cordis effect disposer, not a wrapper: a composite (generator) + // effect that owns a teardown ORDER must be able to yield THIS function — + // cordis nests a disposer out of the fiber's concurrent sibling list by + // exact function identity, so a wrapper would silently break the nesting + // (the agents.register() lesson). Fire-and-forget callers may still + // discard the (always-resolved) promise. + return dispose } /** The (created-on-demand) scoped layer for `scope`. */ diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index d7c0f020d0..86bdd1414a 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -125,7 +125,7 @@ describe('restrict()', () => { const liftAllow = scope.ctx.tools.restrict({ allow: ['a', 'b'] }) scope.ctx.tools.restrict({ deny: ['b'] }) expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['a']) - liftAllow() + await liftAllow() // The deny remains after the allow-list is lifted. expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['a', 'c']) }) diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 09158b8398..8892cadd1a 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -358,7 +358,7 @@ describe('ToolRegistry', () => { const dispose = ctx.tools.register({ ...echoTool, name: 'disposable' }) expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo', 'disposable']) - dispose() + await dispose() expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo']) }) @@ -379,9 +379,38 @@ describe('ToolRegistry', () => { // exposed exactly once (the duplicate-name check is not wedged). const dispose = ctx.tools.register(echoTool) expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo']) - dispose() + await dispose() expect(ctx.tools.get('echo')).toBeUndefined() }) + + it('register() returns the EXACT effect disposer: a composite yield nests the teardown in order', async () => { + // The registry-disposer convention (set by agents.register): the returned + // function IS the cordis effect disposer, so a composite (generator) + // effect that yields it has the unregistration run at that yield's LIFO + // position on owner unload. A wrapper would leave the inner effect + // disposing as a CONCURRENT SIBLING of the composite; the async probe + // below (disposed first, LIFO) yields the event loop exactly like the + // agent factory's stop-and-drain link, and a sibling unregistration fires + // in that window — the probe would observe the tool already gone. Pins + // the convention for the whole register-method family (system-prompt + // registrars, registerProvider, setFactory share the same return). + const ctx = await setup() + const order: string[] = [] + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.effect(function* () { + yield () => { order.push('disposed-last') } + yield inner.tools.register({ ...echoTool, name: 'nested' }) + order.push('registered') + yield async () => { + await new Promise(resolve => setTimeout(resolve, 0)) + order.push(inner.tools.get('nested') ? 'first: still registered' : 'first: already gone') + } + }) + }, { inject: ['tools'] })) + await fiber.dispose() + expect(order).toEqual(['registered', 'first: still registered', 'disposed-last']) + expect(ctx.tools.get('nested')).toBeUndefined() + }) }) describe('defineTool / schema DSL', () => { diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index e93faed4f1..3a024c7e66 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -544,7 +544,7 @@ describe('in-process structured output', () => { const run = ctx.subagents.start('spawn', structuredRequest(parent)) // A backend hot-reload mid-run must not unregister the capture tool out // from under the live child: the registration rides the CHILD's fiber. - disposeProvider() + await disposeProvider() const result = await run.result expect(result.structured).toEqual({ answer: 4 }) const child = ctx.agents.get(run.id)! diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 850958048a..0a7cea32e1 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -164,9 +164,11 @@ export class SubagentService extends Service { * the registration and `subagent/provider-removed` on unregistration, so * consumers can mirror provider lifecycle instead of assuming load order. * @param provider - the provider; its `name` is the registry key. - * @returns the disposer that unregisters the provider. + * @returns the disposer that unregisters the provider. The exact + * Cordis effect disposer (single-shot): composite (generator) effects may + * yield it directly — exact identity nests the teardown in order. */ - registerProvider(provider: SubagentProvider): () => void { + registerProvider(provider: SubagentProvider): () => Promise | void { const dispose = this.ctx.effect(function* (this: SubagentService) { if (this.providers.has(provider.name)) { throw new SubagentError(`a subagent provider named "${provider.name}" is already registered`, 'DUPLICATE_PROVIDER') @@ -184,9 +186,13 @@ export class SubagentService extends Service { } this.ctx.emit('subagent/provider-added', provider) }.bind(this), 'subagents.registerProvider()') - // ctx.effect's disposer returns Promise; our disposer API is - // synchronous fire-and-forget — discard the (always-resolved) promise. - return () => void dispose() + // The EXACT cordis effect disposer, not a wrapper: a composite (generator) + // effect that owns a teardown ORDER must be able to yield THIS function — + // cordis nests a disposer out of the fiber's concurrent sibling list by + // exact function identity, so a wrapper would silently break the nesting + // (the agents.register() lesson). Fire-and-forget callers may still + // discard the (always-resolved) promise. + return dispose } /** diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 66c3e80042..afacd48a9b 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -57,7 +57,7 @@ describe('SubagentService', () => { expect(added).toEqual(['alpha']) expect(removed).toEqual([]) - dispose() + await dispose() expect(removed).toEqual(['alpha']) }) @@ -92,7 +92,7 @@ describe('SubagentService', () => { ctx.on('subagent/provider-removed', name => void heard.push(name)) const dispose = ctx.subagents.registerProvider(new StubProvider('alpha')) - expect(() => { dispose() }).not.toThrow() + expect(() => void dispose()).not.toThrow() expect(heard).toEqual(['alpha']) // the listener AFTER the thrower still ran expect(ctx.subagents.getProvider('alpha')).toBeUndefined() // teardown reached quiescence expect(warnings.some(w => w.includes('boom removed listener'))).toBe(true) @@ -167,12 +167,12 @@ describe('SubagentService', () => { const dispose = ctx.subagents.registerProvider(new StubProvider('reuse')) expect(ctx.subagents.list()).toEqual(['reuse']) - dispose() + await dispose() expect(ctx.subagents.list()).toEqual([]) const disposeAgain = ctx.subagents.registerProvider(new StubProvider('reuse')) expect(ctx.subagents.list()).toEqual(['reuse']) - disposeAgain() + await disposeAgain() expect(ctx.subagents.list()).toEqual([]) }) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 31d6dce113..4f2a1bc33d 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -202,7 +202,7 @@ export function apply(ctx: Context, config: Config): void { // available — deriving the wording from THAT provider — and unregister it // when the provider goes away, so the description can never outlive or // predate the provider it describes. - let disposeTool: (() => void) | undefined + let disposeTool: (() => Promise | void) | undefined const mount = (provider: SubagentProvider): void => { const wording = providerWording(provider.inheritsParentContext) disposeTool = ctx.tools.register(defineTool({ @@ -284,7 +284,7 @@ export function apply(ctx: Context, config: Config): void { }) ctx.on('subagent/provider-removed', (name) => { if (name !== config.provider || disposeTool === undefined) return - disposeTool() + void disposeTool() disposeTool = undefined }) const present = ctx.subagents.getProvider(config.provider) From 06cebf1bf4cbd840b00a6689b78cf44ca99fb562 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:21:58 +0800 Subject: [PATCH 24/64] fix(scope): make the dispatch carrier method-transparent for native-private subjects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ds-review-bot delta-round finding, verified: cordis hands the carrier to listeners as `this`, and the event declarations type it Scoped — so driving the subject through it (this.send(...) in an agent/* listener) is a SUPPORTED shape. The withProps-based carrier delegated gets with the PROXY as receiver, so ReactLoopAgent's send/steer/cancel — which read the native-private #carrier through a getter — threw TypeError when called that way (private members do not exist on proxy receivers). scopeTarget now builds its own proxy: overlay props (the composed filter and the carrier mark) answer from a null-shadowed literal via hasOwn (`in` would let Object.prototype's toString/constructor shadow the subject's), every other get delegates with the BASE as receiver (getters see the real object) and returns functions bound to the base (method calls execute on the real receiver), sets land on the base. A proxy-invariant guard reports frozen own function props unchanged (binding them would violate the get invariant). This kills the class at the seam — any subject with native privates works, today's agents and whatever carries them next — instead of patching the one #carrier field. Pinned both ways: a scope.spec matrix (native-#private method/getter through the carrier mutates the real object; set delegation; frozen-own-prop invariant; overlay non-shadowing) and the bot's exact end-to-end scenario (an agent/session-start listener calling this.send drives a real turn) — both fail with TypeError against the withProps carrier. --- .../agent-loop/tests/scope-lifecycle.spec.ts | 23 +++++++ packages/core/scope/src/index.ts | 60 ++++++++++++++----- packages/core/scope/tests/scope.spec.ts | 43 +++++++++++++ 3 files changed, 111 insertions(+), 15 deletions(-) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 7a45898b8e..b89f06727d 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -170,6 +170,29 @@ describe('agent scope lifecycle', () => { expect(heard).toEqual(['a1:2']) }) + it('a listener may drive the agent through its declared `this` (the carrier is method-transparent)', async () => { + // ds-review-bot regression: agent/* listeners are typed + // `this: Scoped`, and ReactLoopAgent's send/steer/cancel read the + // native-private #carrier — a proxy-receiver carrier made + // `this.send(...)` throw TypeError. The carrier binds methods to the real + // agent, so driving through the event `this` is a working supported shape. + const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + let followUpSent = false + ctx.on('agent/session-start', function (this: Agent) { + // Deliberately through `this`, not the args subject. + this.send(text('driven through this')) + followUpSent = true + }) + const second = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' }) + expect(followUpSent).toBe(true) + await second.whenIdle() + // The send actually reached the loop: the prompt ran a turn. + expect(second.session.events.some(e => e.type === 'turn/start')).toBe(true) + await agent.whenIdle() + }) + it('owner unload honors the documented teardown order: unregistration AFTER the drain, before detach', async () => { const ctx = await harness() let handle!: ReturnType diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index 9c4fd0019e..1243b7d715 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -23,7 +23,7 @@ */ import type { Context } from 'cordis' -import { Context as CordisContext, withProps } from 'cordis' +import { Context as CordisContext } from 'cordis' /** * The identity a scope is keyed by. Opaque and compared by object identity — @@ -178,10 +178,14 @@ export function scopeOf(ctx: Context): ScopeKey | undefined { * * Use it as the `thisArg` of the dispatch: * `ctx.waterfall(scopeTarget(this, exec.agent), 'tools/pre-execute', …)`. The - * carrier is a proxy over `base` — listener `this` stays `base`-shaped, but - * identity-comparing `this` against the subject is not supported; the subject - * always travels in the event's arguments. The returned carrier is branded - * {@link Scoped} and runtime-marked ({@link isScopeCarrier} / + * carrier is a TRANSPARENT proxy over `base`: reads delegate with `base` as + * the receiver and retrieved methods are bound to `base`, so a listener may + * call subject methods through its `this` (`this.send(…)` on a + * `Scoped`) even when the subject uses native `#private` fields — a + * bare proxy receiver would throw on those. Identity is still not + * transparent: `this !== subject` and method identity varies per read; the + * subject always travels in the event's arguments. The returned carrier is + * branded {@link Scoped} and runtime-marked ({@link isScopeCarrier} / * {@link carrierKeyOf}) so both the type system and the dev invariants can * tell a carrier from a bare subject. * @param base - the object the event is dispatched on behalf of (the owning @@ -198,15 +202,41 @@ export function scopeTarget(base: T, key: ScopeKey | undefined const tag = scopeOf(ctx) return tag === undefined || tag === key } - // withProps overlays own-property reads; the symbol-keyed props have no - // structural overlap with T. withProps is typed `any` upstream (a generic - // proxy helper); the carrier is structurally the same T it overlays plus - // the compile-time brand, so pin the type via the return annotation. - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return withProps(base, { + const overlay: Record = { [CordisContext.filter]: filter, [kCarrier]: { key }, - }) + } + // A hand-rolled proxy, NOT cordis withProps: withProps delegates gets with + // the PROXY as receiver, so a getter on `base` runs with proxy `this` and a + // method call through the carrier gets a proxy receiver — either one throws + // on a native `#private` field of the subject (TypeError: private member + // not declared). Cordis hands the carrier to listeners as `this`, and the + // event declarations type it `Scoped` — so subject method calls + // through it are a SUPPORTED shape and must reach the real object: gets + // delegate with `base` as receiver, functions come back bound to `base`, + // and sets land on `base` directly. + return new Proxy(base, { + get(target, prop) { + // hasOwn, not `in`: the overlay literal inherits Object.prototype, so + // `in` would claim `toString`/`constructor` and shadow the subject's. + if (Object.hasOwn(overlay, prop)) return overlay[prop] + const value: unknown = Reflect.get(target, prop, target) + if (typeof value !== 'function') return value + // Proxy invariant guard: a non-configurable, non-writable OWN data + // property must be reported unchanged, so it cannot be bound. Class + // methods live on the prototype (no own descriptor) and bind freely; + // only a frozen own-function prop keeps the raw (unbound) function. + const own = Reflect.getOwnPropertyDescriptor(target, prop) + if (own !== undefined && own.configurable === false && own.writable === false) return value + // `Function.prototype.bind` types as `any`; the value is structurally + // T[prop] and the trap's contract is untyped (`any`), so unknown is the + // honest safe return. + return value.bind(target) as unknown + }, + set(target, prop, value) { + return Reflect.set(target, prop, value, target) + }, + }) as Scoped } /** @@ -219,9 +249,9 @@ export function scopeTarget(base: T, key: ScopeKey | undefined */ export function isScopeCarrier(value: unknown): value is Scoped { if (typeof value !== 'object' || value === null) return false - // A property READ, not an `in` check: withProps overlays props via get/set - // traps only (no `has` trap), so `kCarrier in carrier` would fall through to - // the wrapped base and always answer false. + // A property READ, not an `in` check: the carrier overlays its marks in the + // get trap only (no `has` trap), so `kCarrier in carrier` would fall + // through to the wrapped base and always answer false. return (value as { [kCarrier]?: { key: ScopeKey | undefined } })[kCarrier] !== undefined } diff --git a/packages/core/scope/tests/scope.spec.ts b/packages/core/scope/tests/scope.spec.ts index e932ed80db..53e5bc931b 100644 --- a/packages/core/scope/tests/scope.spec.ts +++ b/packages/core/scope/tests/scope.spec.ts @@ -179,6 +179,49 @@ describe('scopeTarget dispatch filtering', () => { expect(result).toBe('seed+v') expect(seenLabel).toBe('the-base') }) + + it('is transparent for subjects with native #private fields: methods and getters through the carrier reach the real object', () => { + // The ds-review-bot regression: cordis hands the carrier to listeners as + // `this` (typed Scoped), so subject method calls through it are a + // supported shape. A proxy that delegates with the PROXY as receiver + // (cordis withProps) throws TypeError on any native #private the method + // or getter touches; the carrier must delegate with the BASE as receiver + // and bind retrieved methods to it. + class Subject { + #count = 0 + bump(): number { return ++this.#count } + get count(): number { return this.#count } + } + const subject = new Subject() + const carrier = scopeTarget(subject, subject) + expect(carrier.bump()).toBe(1) // method call: bound to the base + expect(subject.count).toBe(1) // ...and it mutated the REAL object + expect(carrier.count).toBe(1) // getter: runs with the base as receiver + // The get trap returns the method already bound to the base; + // detachability IS the assertion. + // eslint-disable-next-line @typescript-eslint/unbound-method + const detached = carrier.bump + expect(detached()).toBe(2) + }) + + it('delegates sets to the base and leaves frozen own function props unbound (proxy invariant)', () => { + const frozenFn = (): string => 'frozen' + const base: { mutable: number; pinned: () => string; toString: () => string } = { + mutable: 0, + pinned: frozenFn, + toString: () => 'base-str', + } + Object.defineProperty(base, 'pinned', { value: frozenFn, writable: false, configurable: false }) + const carrier = scopeTarget(base, undefined) + carrier.mutable = 7 + expect(base.mutable).toBe(7) // sets land on the base, not a detached overlay + // A non-configurable, non-writable own data prop must be reported + // unchanged (binding it would violate the proxy get invariant). + expect(carrier.pinned).toBe(frozenFn) + // The overlay literal inherits Object.prototype; hasOwn (not `in`) keeps + // it from shadowing the subject's own prototype-surface members. + expect(String(carrier)).toBe('base-str') + }) }) describe('carrier marks', () => { From 013f12963bacfbc83f0c71ac12ca66942a81800d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:39:17 +0800 Subject: [PATCH 25/64] fix(scope): generalize the carrier's proxy-invariant guard; keep the real constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex follow-up findings on the carrier commit, both verified: B: the invariant guard only protected the bind path — an overlay key colliding with a non-configurable, non-writable OWN prop of a (pathological) base would have the get trap report the overlay value, which the engine rejects as a proxy invariant violation (TypeError at read time). The pin check now runs FIRST and covers both invariant-pinned shapes (non-writable own data prop reported as-is; getterless non-configurable accessor reported undefined via the delegated read) before overlay and bind alike. Such a base forgoes scope filtering by construction — correctness of the read beats filtering for a base no production code ships. C: `constructor` is looked up for identity, never invoked as a subject method — binding it broke `carrier.constructor === Subject` for no benefit; it now returns raw (the same special-case withProps had). Both pinned in scope.spec: the frozen-own-filter collision yields the base's value without throwing, and class identity survives the carrier. --- packages/core/scope/src/index.ts | 24 ++++++++++++++++-------- packages/core/scope/tests/scope.spec.ts | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index 1243b7d715..61b46978bd 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -217,17 +217,25 @@ export function scopeTarget(base: T, key: ScopeKey | undefined // and sets land on `base` directly. return new Proxy(base, { get(target, prop) { + // Proxy get invariants pin what this trap may report for a + // non-configurable OWN property of the base: a non-writable data prop + // must be reported AS-IS (neither overlaid nor bound), a getterless + // accessor as undefined — checked FIRST so even an overlay key + // colliding with a frozen own prop of a (pathological) base yields the + // base's value instead of an engine TypeError. Such a base forgoes + // scope filtering; no production base freezes these keys. + const own = Reflect.getOwnPropertyDescriptor(target, prop) + const pinned = own !== undefined && own.configurable === false + && own.get === undefined && own.writable !== true // hasOwn, not `in`: the overlay literal inherits Object.prototype, so // `in` would claim `toString`/`constructor` and shadow the subject's. - if (Object.hasOwn(overlay, prop)) return overlay[prop] + if (!pinned && Object.hasOwn(overlay, prop)) return overlay[prop] const value: unknown = Reflect.get(target, prop, target) - if (typeof value !== 'function') return value - // Proxy invariant guard: a non-configurable, non-writable OWN data - // property must be reported unchanged, so it cannot be bound. Class - // methods live on the prototype (no own descriptor) and bind freely; - // only a frozen own-function prop keeps the raw (unbound) function. - const own = Reflect.getOwnPropertyDescriptor(target, prop) - if (own !== undefined && own.configurable === false && own.writable === false) return value + if (typeof value !== 'function' || pinned) return value + // `constructor` is looked up, never invoked as a subject method — keep + // the real one (withProps special-cases it the same way), so + // `carrier.constructor` still identifies the subject's class. + if (prop === 'constructor') return value // `Function.prototype.bind` types as `any`; the value is structurally // T[prop] and the trap's contract is untyped (`any`), so unknown is the // honest safe return. diff --git a/packages/core/scope/tests/scope.spec.ts b/packages/core/scope/tests/scope.spec.ts index 53e5bc931b..664ae00d7e 100644 --- a/packages/core/scope/tests/scope.spec.ts +++ b/packages/core/scope/tests/scope.spec.ts @@ -222,6 +222,28 @@ describe('scopeTarget dispatch filtering', () => { // it from shadowing the subject's own prototype-surface members. expect(String(carrier)).toBe('base-str') }) + + it('honors the get invariant even when an overlay key collides with a frozen own prop of the base', () => { + // Pathological but engine-enforced: a base whose own [Context.filter] is + // a non-configurable, non-writable data prop pins what any proxy over it + // may report for that key. The carrier must yield the base's value (an + // overlay there would be a runtime TypeError from the engine, not a + // filtering choice). Such a base forgoes scope filtering by construction. + const pinnedFilter = (): boolean => true + const base = {} + Object.defineProperty(base, Context.filter, { value: pinnedFilter, writable: false, configurable: false }) + const carrier = scopeTarget(base, { name: 'key' }) + expect((carrier as Record)[Context.filter]).toBe(pinnedFilter) + }) + + it('keeps the real constructor: class identity survives the carrier', () => { + class Subject { work(): string { return 'w' } } + const subject = new Subject() + const carrier = scopeTarget(subject, subject) + // `constructor` is looked up, never invoked as a subject method — binding + // it would break `carrier.constructor === Subject` for no benefit. + expect(carrier.constructor).toBe(Subject) + }) }) describe('carrier marks', () => { From fd2c682477798c7198a2ab70cbe45d5e25d792c5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:42:26 +0800 Subject: [PATCH 26/64] docs(subagent): state the re-assert placement guarantee precisely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer-agent C1 on the audit delta: "byte-identical" overstated the untampered-path guarantee — a 190-order section registered AFTER the structured runtime sorts before the instruction in the registry's stable sort but after it in the re-assert's band insertion. Intra-band section order carries no contract, so the behavior is right and unchanged; the module doc and both in-code comments now say exactly that instead of claiming byte identity. --- .../subagent-inprocess/src/structured.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index 5107897c68..def740ec25 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -21,9 +21,10 @@ * always carries its capture tool and the trailing instruction section. The * registry already contributes both; this outermost wrapper preserves the * guarantee against a (global) listener that strips or replaces the - * assembly — placement-preserving, so an untampered assembly reaches the - * model byte-identical (tools replaced in place, the section re-inserted at - * its ascending-order position). The loop logs the rendered assembly as the + * assembly — placement-preserving: tools are replaced in place, the section + * re-inserted at its ascending-order position, so the untampered path keeps + * the registry's ordering (identical output, up to intra-band section order + * — which carries no contract). The loop logs the rendered assembly as the * request header, so the demand is reconstructable log state, never a * wire-only mutation. * - `agent/turn-continuation` (prepend, scoped): stop the child's turn once @@ -148,10 +149,12 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut // mutated or injected a same-named entry with the WRONG schema/text, and // the model-visible demand must be exactly this run's own — the same // schema validateStructuredValue enforces. Placement-preserving on both - // arrays: the untampered path must reach the model byte-identical to the - // registry's output (tool order is the `toolOrder`/lexicographic - // contract, section order is the ascending contract `renderPrompt` - // trusts), so this never reorders what it only re-asserts. + // arrays: the untampered path keeps the registry's ordering (tool order + // is the `toolOrder`/lexicographic contract, section order the ascending + // contract `renderPrompt` trusts), so this never reorders what it only + // re-asserts — up to intra-band section order, which carries no contract + // (a 190-order section registered AFTER this runtime sorts before the + // instruction in the registry but after it here). const freshTool: ToolSchema = { ...schemaEntry, parameters: structuredClone(schemaEntry.parameters) } // Tools: replace the first same-named entry IN PLACE (its position is the // chain's product; a tool's list position carries no semantic band to @@ -173,8 +176,8 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut // DO carry an order contract, and the renderer reads array order, so a // stripped-or-moved instruction is restored to its band, not appended // after unrelated higher-order sections. On the untampered path this - // lands exactly where the registry's stable sort put it (last of the 190 - // band — the scoped section registers after every load-time 190). + // lands at the end of the 190 band — where the registry's stable sort + // put it too, unless another 190-order section registered later. const sectionName = `tool:${STRUCTURED_OUTPUT_TOOL}` const sections = final.sections.filter(section => section.name !== sectionName) const insertAt = sections.findIndex(section => section.order > 190) From 6091c0a3dc192924fe7007e7cf12f1147c3f79d1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:01:49 +0800 Subject: [PATCH 27/64] docs: rewrite the agent-scope RFC --- docs/config-catalog.md | 2 +- .../2026-07-08-agent-scope-contexts.md | 314 ++++++++++++++++-- packages/core/scope/README.md | 2 +- packages/subagent/subagent-fork/README.md | 2 +- .../subagent/subagent-inprocess/README.md | 6 +- .../subagent-inprocess/src/structured.ts | 42 +-- packages/subagent/subagent-spawn/README.md | 2 +- packages/subagent/subagent-spawn/src/index.ts | 17 +- packages/subagent/tool-subagent/README.md | 5 +- 9 files changed, 336 insertions(+), 56 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f96286c5fa..65f37e59d3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -635,7 +635,7 @@ export interface Config { } ``` -Source: [`packages/subagent/subagent-spawn/src/index.ts:36`](../packages/subagent/subagent-spawn/src/index.ts) +Source: [`packages/subagent/subagent-spawn/src/index.ts:35`](../packages/subagent/subagent-spawn/src/index.ts) ## `@deepseek-ai/dsh-system-prompt` diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md index 7c140f1ec6..8d7a5ab251 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -4,32 +4,308 @@ Status: implemented ## Problem -The runtime is multi-agent — configuration can declare several agents, the ACP bridge creates one agent per client session, and the in-process subagent backends spawn/fork children as sibling agents on the same Cordis context — yet every extension surface was context-global. One tool registry fed every agent's prompt (a child spawned to summarize a file was offered bash, file-write, and the delegation tool itself, unbounded); one section list rendered the same persona for everyone (`SubagentStartRequest` could not express a per-child persona at all); every `agent/*`, `session/*`, and `tools/*` listener fired for every agent, so a decider waterfall written for one agent silently governed all of them unless its author remembered to self-filter. The gap was visible in the API: `SubagentCapabilities.toolFilter` was public vocabulary, yet every real provider declared `toolFilter: false` because per-agent tool visibility was unimplementable, and `structured.ts` carried a FIXME documenting the placeholder-schema/final-assembly-swap/refcount dance forced by global registration. +The harness runs multiple agents inside one application, but those agents need different capabilities and policies. A child created to summarize a file may need a different persona, a smaller tool set, and listeners that govern only its work; applying those contributions to every agent would leak authority and couple otherwise independent runs. + +This is not the same problem as running several isolated applications. The agents intentionally share the deployment's model adapters, persistence backend, tool implementations, and other services. What varies is the view assembled for one agent and the policy attached to its activity. + +The affected extension surfaces include both data and behavior: + +| Surface | Per-agent need | Failure when global | +|---|---|---| +| Tools | Hide dangerous or irrelevant tools; add a child-only result tool; replace one implementation for one agent | The model sees excess authority, or a child-specific tool leaks into every prompt | +| Prompt sections and variables | Give a child its own persona or runtime facts | Every agent receives the same instructions or values | +| Event listeners | Apply a hook, guard, or continuation policy to one agent | A listener written for one agent can veto or mutate another agent's work | +| Lifetime | Remove all of the above when the agent ends | Manual cleanup misses failure, cancellation, hot-reload, or owner-teardown paths | + +The model-visible and executable views must also agree. Hiding a tool only from the prompt is not a security boundary if a generated call can still execute it; hiding it only from execution produces a prompt that advertises unusable capabilities. The same consistency requirement extends to Code Mode bindings and user-interface presentation. + +The subagent API exposes the practical gap. A provider can accept a child persona, a tool filter, and a structured-output schema, but those options are honest only if two concurrent children can receive different registrations without mutating shared global state. ## Decision -Make the agent a registration scope, using the framework's own machinery rather than per-registry bolt-ons: +Each live agent owns a registration context named `agent.ctx`. Registering through the application's ordinary plugin context contributes globally; registering through `agent.ctx` contributes to that agent alone and ties the contribution's lifetime to the agent. -- **`dsh-scope`** (`packages/core/scope`, peer-deps cordis only, below `dsh-session`/`dsh-system-prompt` in the module-graph DAG): `createScope(ctx, key)` mints a tagged context over a synchronously-usable no-op-plugin fiber; `scopeOf(ctx)` reads the tag through the prototype chain; `scopeTarget(base, key)` builds the scope-filtered dispatch carrier over cordis `Context.filter`, composing the base's own filter, branded `Scoped` and runtime-marked for the dev invariants; `Scope.rawDispose` exposes the exact cordis disposer so a composite effect nests the scope's teardown at its yield position; `scopeHost` is the fail-loud test-side minter. -- **Ownership and visibility derive from ONE fact** — which context a registration went through: the scope's fiber owns the disposal, and the tag decides who sees it. An explicit `{ scope }` registration parameter could express "visible to X, disposed with Y", which is almost always a bug; the scoped context makes it unrepresentable. -- **`Agent.ctx`**: every live agent owns a scope context (key = the agent), minted inside the loop's composite lifecycle effect. Yield order gives teardown stop/drain → unregister → detach session → unwind scope; detach before the (async) scope unwind keeps store/registry rollback synchronous on every failure path, so a caller catching a throwing `create()` observes no half-created agent or session. `CreateAgentOptions.setup(agentCtx)` runs after the scope is minted and the agent registered, before `agent/session-start` and the loop start — setup REGISTERS the scoped world, it never drives (a dev invariant makes a pre-session-start turn a teaching error). -- **Two registration layers with shadowing**: `ctx.tools` and `ctx.systemPrompt` file a registration by the calling context's tag; a scoped tool/section/variable is visible to that agent alone, unwinds with it, and SHADOWS a same-named global contribution for that agent (most-specific-wins; within one layer duplicates still throw). Shadowing is the per-agent persona mechanism (a scoped `deployment:persona`) and the per-agent tool-variant mechanism (a scoped `bash` with the same model-facing name). -- **`tools.restrict({allow?, deny?})`**: a scoped, snapshot-at-registration mask over the GLOBAL tool surface with loud unknown-name validation; multiple restrictions intersect; scoped registrations are explicit grants that bypass restriction (what keeps a structured capture tool alive under an allow-list). One visibility function feeds prompt assembly, `get(name, scope?)`, and `execute`, so what the model is shown, what a presenter renders, and what dispatches can never disagree; out-of-view execution is `UNKNOWN_TOOL`, indistinguishable from nonexistent. -- **Scoped dispatch by rule**: an event about one agent's activity dispatches with that agent's carrier — all `agent/*` (via the fused `agentEvents(ctx, agent)`, which injects carrier and subject in one move so the correct dispatch is the shortest spelling), `session/created|event|flush` (carrier captured at `SessionStore.enter` from the entering context; `ctx.sessions.flush(session)` owns the awaited checkpoint dispatch), `tools/pre|post-execute` (by `exec.agent`), `system-prompt/assemble` (by `context.scope`; `assembleContextFor(agent)` builds the context), and `subagent/start|end` (by the delegating parent). Registry-subject notifications (`tools/change`, `system-prompt/change`, `subagent/provider-*`) stay deliberately unfiltered. A listener registered through `agent.ctx` hears only its agent; plain plugin listeners keep hearing everything; `{ global: true }` bypasses filtering. -- **Enforcement**: dev-invariants assert at cordis's `internal/dispatch` seam that every scoped-family dispatch carries a carrier keyed to the same subject its arguments name, and that an assembly context never carries `agent` without `scope`; the `verify-scoped-dispatch` gate pins the invariant table against the declaration docs so the two cannot drift. -- **The seam becomes honest**: spawn/fork advertise `{ outputSchema, depthLimit, toolFilter, persona }` all true (ACP all false); the driver composes the child's scoped world in the setup window; a parent-scope teardown effect links each child to its parent through the memoized handle (structured concurrency — a disposed parent reaches its subtree even if the delegating tool's `finally` never runs); `structured.ts` collapses to scoped registrations with a call-keyed two-phase commit and one scoped prepend re-assert listener. +The rule is intentionally small enough to be the normal plugin-author mental model: + +| Registration context | Visibility | Lifetime owner | +|---|---|---| +| Ordinary plugin context | Every agent | The registering plugin | +| `agent.ctx` | Exactly that agent | That agent | + +The scope is flat. A child does not inherit registrations from its parent's scope; parent/child lineage remains explicit session data. A child sees the deployment-global layer plus its own layer, which prevents accidental authority inheritance through an agent tree. + +This decision is implemented by the [`dsh-scope` primitive](../../../../packages/core/scope/README.md), scope-aware tool and prompt registries, scope-filtered event dispatch, and an agent lifecycle that creates and destroys the entire scoped world as one ordered operation. + +## Background: the Cordis concepts used by the design + +The implementation reuses four Cordis mechanisms. Readers do not need Cordis internals beyond this section; the [Cordis primer](../../../cordis-primer.md) is the broader reference. + +### Contexts provide services + +A Cordis context is the object through which a plugin reaches shared services such as `ctx.tools`, `ctx.systemPrompt`, and `ctx.sessions`. A service call retains the context used to access it, so a registry can tell whether a registration came through an ordinary plugin context or through an agent's context without adding a scope argument to every method. + +Contexts also represent a capability view. A derived context can reach only the services made available by the plugin that created it. Handing out `agent.ctx` therefore hands out the agent loop's injected service surface, a deliberate part of the `Agent.ctx` contract rather than an ambient root context. + +### Effects own registrations + +Registrations are Cordis effects: adding a tool, prompt section, or listener returns cleanup behavior owned by the context's runtime unit, called a fiber. Disposing a fiber unwinds all effects registered through it, which is the basis for hot reload and reliable cleanup. + +`dsh-scope` creates a no-op plugin fiber for each scope. The fiber contributes no behavior of its own; it exists to provide one lifetime bucket for everything registered through the scoped context. + +### Event dispatch can filter listeners + +Cordis decides which listeners receive an event by inspecting the object used as the dispatch receiver, known as the event's `this` value. `dsh-scope` supplies a receiver with a filter: unscoped listeners are admitted for compatibility, while a scoped listener is admitted only when its key matches the event's subject. + +### Disposal order requires explicit nesting + +Cordis may dispose sibling effects concurrently. When order matters, a generator effect yields the exact disposer functions to create a nested last-in-first-out chain; wrapping a disposer in another function breaks the identity Cordis uses to remove it from the concurrent sibling set. + +This detail drives both `Scope.rawDispose` and the convention that registry `register` methods return their exact Cordis disposer. It is what lets agent teardown wait for the loop, then unregister the agent, then detach the session, rather than racing those operations. + +## Scope model + +The scope primitive joins visibility and ownership while remaining independent of the agent packages. This lets lower-level packages such as sessions and prompt assembly participate without depending upward on the agent loop. + +### One registration fact controls two properties + +The decisive fact is which context performed a registration. The scope tag selects its visibility layer, and the same context's fiber owns its disposal. + +Keeping those properties coupled prevents a dangerous state such as “visible to agent A but disposed with unrelated plugin B.” An API shaped as `register(value, { agent })` would make that state expressible and would retain global registration as the easy-to-forget default. + +### Scope keys are opaque identities + +A scope key is an object compared by identity, not a string or database identifier. The harness uses the live `Agent` object as its own key, so event payloads and execution records that already carry an agent can select the right scope without translating through another registry. + +Object identity decouples a live scope from externally meaningful or sequentially reused string IDs. The key is meaningful only during the live agent's lifetime. + +### The primitive has four responsibilities + +The public API is small; the package README carries the exact signatures. + +| Responsibility | Mechanism | +|---|---| +| Create an ownership bucket | `createScope(context, key)` mounts the no-op fiber and returns its derived context | +| Read the registration layer | `scopeOf(context)` reads the nearest inherited scope tag | +| Filter event delivery | `scopeTarget(subject, key)` creates the dispatch carrier | +| Tear down in a larger ordered lifecycle | `Scope.rawDispose` exposes the exact fiber disposer | + +Derived contexts inherit the tag, and a nested scope replaces it with the nearer key. This allows `agent.ctx.plugin(...)` to build a reusable profile whose registrations remain scoped to the same agent. + +### Scoping is explicit at each seam + +The scoped context automatically supplies effect ownership, but it does not magically change every service operation. A scope-aware registry must read `scopeOf()` when registering, and a scope-filtered event dispatcher must provide a carrier naming the operation's subject. + +Read and execution operations select their subject explicitly: callers use `tools.schemas(agent)`, `tools.get(name, agent)`, `tools.execute({ agent, ... })`, or `systemPrompt.assemble({ scope: agent, agent })`. Calling `agent.ctx.systemPrompt.assemble()` with an empty assembly context still asks for the global layer. This separation lets a shared service operate on behalf of any agent while making the subject visible at the call site. + +## Registration resolution + +The tool and system-prompt services each keep a global layer plus a map of per-scope layers. Resolution combines only the global layer and the requesting agent's own layer. + +### Scoped names shadow global names + +For named contributions, the scoped definition wins over a global definition with the same name. Duplicates inside one layer still fail loudly. + +Shadowing is what makes a child persona ordinary configuration: the system-prompt service owns a global `deployment:persona`, and a child registers another section with that name through its context. The same rule supports a per-agent implementation of a model-facing tool without renaming the tool. + +Tool-schema providers are additive rather than named, but a provider registered through `agent.ctx` is consulted only for that agent's assemblies. Prompt variables use the same named-shadowing rule as sections and tools. + +### Restrictions mask global tools; scoped tools are explicit grants + +`agent.ctx.tools.restrict({ allow, deny })` filters the global tool layer for that agent. `allow` keeps only listed names, `deny` removes listed names, and multiple restrictions intersect so independently installed policies can only reduce the global surface. + +Restrictions do not remove tools registered in the agent's own layer. A scoped tool is an explicit grant, which is necessary for facilities such as a child's `structured_output` capture tool to remain available under a restrictive allow-list. + +The restriction snapshots its arrays when registered, validates every named tool against the current pre-restriction universe, and rejects an empty filter. These choices make configuration mistakes loud and prevent later caller mutation from changing a live policy. + +An out-of-view tool resolves exactly like an unregistered tool and returns `UNKNOWN_TOOL` if called. This avoids exposing whether a hidden global implementation exists. + +### One visibility function feeds every consumer + +The tool registry defines one canonical `visible()` rule and every consumer uses it for prompt schemas, lookup, execution, Code Mode's generated SDK and bindings, timeout-policy lookup, Cordis inspection, and ACP presentation. A model cannot be shown one definition while execution or the UI resolves another. + +Code Mode introduces one intentional distinction. A restriction is per-agent runtime state, so a globally configured `toolOrder` may name a restricted-away tool and simply leave an empty position for that agent. By contrast, `mode: 'code'` is deployment configuration that deliberately collapses the wire-visible universe to `run_code`; a `toolOrder` that still names native tools is invalid configuration and fails every assembly. + +To preserve this distinction, a prompt tool provider returns both the post-restriction schemas and the pre-restriction `knownNames` universe. Ordering validates names against the latter but orders only the former. + +## Scoped event delivery + +Registrations alone are insufficient: a listener installed for one agent must hear only events about that agent. Scoped dispatch applies that rule while preserving the existing behavior of global plugins. + +### Delivery is global-plus-matching-scope + +For an event about agent A, the dispatch carrier admits ordinary unscoped listeners and listeners registered through A's context. It rejects listeners registered through every other agent context. + +A subject-less dispatch, such as an agent-less tool execution or a bare session created outside an agent scope, admits only unscoped listeners. Cordis's explicit `{ global: true }` listener option still bypasses filtering for infrastructure that intentionally observes everything. + +Events about registry membership stay unfiltered. A notification that a tool or provider was added concerns shared registry state rather than one agent's activity, so scoped subscribers to `tools/change`, `system-prompt/change`, or `subagent/provider-*` still hear the global notification. + +### Each event family has one scope source + +The event subject determines the key; callers do not choose an unrelated scope. + +| Event family | Scope key | +|---|---| +| `agent/*` | The event's agent | +| `tools/pre-execute`, `tools/execute`, `tools/post-execute` | `execution.agent`, or no key for an agent-less call | +| `system-prompt/assemble` | The assembly context's scope | +| `session/created`, `session/event`, `session/flush` | The owner scope captured when the session enters the store | +| `subagent/start`, `subagent/end` | The delegating parent agent | + +Agent events use `agentEvents(context, agent)`, which creates the carrier and injects the same agent as the first event argument in one operation. Prompt assembly similarly uses `assembleContextFor(agent)` to set both the human-friendly `agent` field and the scope selector. These fused helpers make a mismatched carrier and subject difficult to express. + +The session store captures its carrier when a session is entered because later appends and flushes may originate from code that no longer has the agent's context in hand. `ctx.sessions.flush(session)` is the only durability-checkpoint entry point, so callers cannot forget the captured carrier. + +### The carrier is method-transparent + +Cordis passes the dispatch carrier to a function-style listener as `this`. Agent event declarations allow a listener to call methods such as `this.send(...)`, so the carrier must behave like the real subject rather than merely look like it. + +The carrier is a JavaScript proxy whose property reads use the real subject as receiver and whose methods are bound to that subject. This matters for classes with native private fields: calling a method with the proxy itself as receiver would throw because the proxy does not possess the class's private-field identity. + +The proxy preserves the subject's own event filter, writes through to the subject, returns the real constructor, and obeys JavaScript's invariants for frozen own properties. Its object identity is intentionally not transparent; the actual subject is also present in event arguments whenever identity matters. + +`Scoped` is a TypeScript-only marker requiring a carrier at scoped dispatch sites. It adds no runtime behavior; runtime carrier marks and development invariants provide the corresponding check for JavaScript and casted code. + +## Agent creation and teardown + +An agent's scope, session, registry entry, and driver loop form one lifecycle. Creating them inside one composite effect gives both rollback on partial construction and deterministic teardown on every ownership path. + +### Creation has a deliberate composition window + +Agent creation proceeds in this order: + +1. Construct the live agent object. +2. Mint its scope and assign `agent.ctx`. +3. Enter the session through `agent.ctx`, capturing the session's carrier. +4. Announce `session/created`. +5. Register the agent, which announces `agent/created`, so setup code can resolve it. +6. Run `CreateAgentOptions.setup(agentCtx)` to compose scoped tools, prompt contributions, restrictions, listeners, or child plugins. +7. Emit `agent/session-start`. +8. Start the driver loop. + +The setup callback runs inside the storage rollback boundary and before the first prompt assembly. A synchronous throw removes the agent and session and unwinds every scoped registration, so no half-created entry keeps either ID occupied. + +The two creation notifications occur before setup. Observers can therefore see the pre-setup world, and listeners installed by setup do not receive this agent's `session/created` or `agent/created`; rollback cannot retract external side effects those earlier listeners performed. This is a current atomicity limitation, not a guarantee provided by the setup window. + +Setup performs direct synchronous registrations but does not drive the agent. Calling `send`, `steer`, or `inject` there could open a turn before `agent/session-start`, reversing a lifecycle contract used by bridges and hooks; development invariants report that misuse at the first `turn/start`. Mounting an asynchronously activating child plugin also does not extend the synchronous setup window unless its activation ordering is separately awaited. + +### Teardown waits for one quiescent boundary + +The yielded disposers produce this teardown order: + +1. Request the loop to stop and await its actual exit, including its closing session events and durability flush. +2. Unregister the agent. +3. Detach the session from the store. +4. Unwind the scope's listeners and registrations. + +Detaching the session before the asynchronous scope unwind keeps registry and store rollback synchronous on construction failures. Scoped listeners remain installed through the stop-and-drain phase, so they hear the final flush before the session detaches. + +Cordis disposers are single-shot but a second call does not necessarily await a first call already in progress. The agent lifecycle therefore owns a shared completion promise in addition to the raw disposer. Tool cleanup, parent teardown, explicit `AgentHandle.dispose()`, and owner-fiber unload all await the same fully quiescent result. + +Every registry returns its exact effect disposer so the composite lifecycle can preserve this order even when the whole owner fiber unloads. Returning a wrapper would leave the inner registration as a concurrently disposed sibling and could emit `agent/disposed` while the final turn was still draining. + +## Subagent composition + +The subagent seam demonstrates why agent scoping exists: a provider can compose a child-specific world with ordinary registrations and let the agent lifecycle own it. + +### Persona and tool filters become real capabilities + +The in-process spawn and fork providers advertise persona and tool-filter support because their child setup can register a shadowing `deployment:persona` section and a tool restriction through the child's context. ACP remains honest about not supporting those capabilities because it delegates to a separate process whose registration context is not locally available. + +Omitted configuration stays absent. This matters for schema-driven configuration: a materialized empty `allow` list means “allow nothing,” which is not equivalent to an omitted list, and an empty filter is not equivalent to no filter. The configuration schema preserves those distinctions before setup calls `restrict`. + +### Parent disposal owns the subtree + +After creating a child, the in-process driver registers the child's memoized disposer as an effect on the parent scope. Disposing a parent therefore reaches the whole descendant tree even if the delegating tool's `finally` block never runs. + +This is structured concurrency expressed through ownership rather than through scope inheritance. The child still has a flat capability view—global plus child-only registrations—while its lifetime is linked explicitly to the parent. + +### Structured output becomes per-child state + +A structured child registers a real-schema `structured_output` tool and its instruction section through its own context. Concurrent children can carry different schemas because each resolves only its own tool definition; no placeholder global schema, reference count, or strip-for-other-agents pass is needed. + +Four scoped listeners enforce the terminal protocol: + +1. An outer prompt-assembly listener reasserts the exact tool schema and instruction after downstream listeners have run. It replaces an existing tool in place, appends it when absent, removes duplicates, and restores the instruction to its order-190 section band. +2. A tool pre-execution listener denies calls after a value has been captured, preventing later side effects in the same model response. +3. A tool post-execution listener commits a staged value only if the final pipeline decision accepts that same execution. +4. A turn-continuation listener stops the child after capture instead of spending another model step merely because a tool ran. + +Staging is keyed by the `ToolExecution` object's identity in a `WeakMap`, not by the model or adapter's call ID. Only the pipeline trip whose tool body staged a value can commit it; a blocked trip cannot leave state that a later call with a reused ID accidentally promotes. The weak key also allows an abandoned stage to be reclaimed without global cleanup bookkeeping. + +## Correctness enforcement + +Scoping errors are dangerous because a missed carrier silently restores global delivery. The implementation therefore makes the safe path short and checks it at type, runtime, test, and documentation boundaries. + +### Compile-time and API shaping + +Scoped event declarations require the `Scoped` carrier marker. `agentEvents` couples carrier creation to the agent argument, `assembleContextFor` couples agent prompt facts to the scope selector, and session flush is a service method that owns carrier lookup. + +These TypeScript checks improve authoring but are not treated as a security boundary: JavaScript callers, casts, and hand-written dispatches can bypass them. + +### Development-time invariants + +The invariants plugin observes Cordis's internal dispatch seam. For every scope-filtered event it verifies that a carrier exists and, where the subject is present in the arguments, that the carrier key is the same object. Session and subagent lifecycle payloads do not expose the owner key, so their runtime check proves carrier presence only; the session store and subagent service centralize the dispatch spelling that selects the key. The plugin also rejects an assembly context whose `agent` and `scope` fields disagree and a setup callback that opens a turn before `agent/session-start`. + +The invariant checks run before listener delivery, so a violation points at the dispatching call site instead of appearing later as cross-agent behavior. + +### Drift gates and focused tests + +`verify-scoped-dispatch` compares the runtime invariant table with the event declarations marked as scope-filtered. The generated event matrix also rejects a declared event with no recognized dispatcher, preventing helper-shaped calls from disappearing silently from the architecture documentation. + +Focused tests cover scoped visibility, shadowing, restrictions, carrier transparency, setup rollback, teardown order, shared quiescence, session delivery, structured-output tamper recovery, stale-stage isolation, Code Mode bindings, and parent-child disposal. The [events catalog](../../../cordis-catalog/events.md) remains the exhaustive event contract rather than being duplicated here. ## Alternatives considered -- **Explicit scope parameters on every registration API** (`tools.register(def, {agent})`): forgettable — omitting the option is global, so leak-by-default survives; no lifecycle coupling; and it can express visible-to-X-disposed-with-Y, which is almost always a bug. -- **Per-agent `ctx.isolate()` service instances**: isolation is a bulkhead for co-hosting independent applications, not intra-app scoping. Resolution picks exactly one instance per name — "deployment tools plus my tools" needs a hand-built delegating merge registry per service — and single-subscription observers (persistence, the ACP bridge) would have to discover and subscribe per agent. -- **Event-filtering only** (scoped listeners, global registries): leaves the model-visible surfaces — tool schemas, personas — unscoped, which is the half that makes `toolFilter` and per-child personas impossible. -- **Vendored-cordis support** (a first-class scope concept in the framework): more invasive vendor drift for no additional capability; `extend` + `Context.filter` + a no-op plugin fiber already compose the same semantics from public primitives. +The alternatives below solve only part of the problem or separate visibility from ownership, which would make safe composition harder to reason about. + +### Pass an agent or scope option to every registration + +An API such as `tools.register(definition, { agent })` makes global registration the leak-by-omission default and requires every registry to invent parallel scope plumbing. It also lets visibility point at one agent while cleanup belongs to an unrelated context. + +The chosen design leaves existing registry signatures unchanged and uses the calling context as the single source of both facts. + +### Create an isolated service instance per agent + +Cordis isolation selects one service instance for a context. Agent composition needs a merged view—deployment-global tools plus one agent's additions—not a choice between two independent registries. + +Per-agent service instances would require delegating merge registries for every scoped service and would force single-subscription infrastructure such as persistence and ACP to discover and subscribe to each new instance. Isolation remains the right bulkhead for co-hosting independent applications, not for agents collaborating inside one application. + +### Filter events but keep registries global + +Listener filtering prevents a hook from intercepting the wrong agent, but it does not scope the model-visible tool schemas, prompt sections, variables, or executable tool lookup. Persona, tool filtering, and concurrent structured-output schemas would remain impossible or require ad hoc mutation. + +### Add first-class scope support to vendored Cordis + +Cordis already exposes the primitives needed for this design: derived contexts, effect-owning plugin fibers, and listener filtering through the dispatch receiver. Modifying the vendored framework would add synchronization and maintenance cost without providing an additional harness capability. + +### Give each subsystem a separate per-agent API + +Tool filters, prompt profiles, listener predicates, and session routing can each be implemented independently. That approach multiplies concepts, cleanup paths, and opportunities for the views to disagree. + +The shared scope primitive gives every subsystem the same answer to “which agent sees this?” and “when does it go away?” while letting each registry retain its own domain-specific resolution rules. ## Consequences -- Plugin authors get one new concept: register through `agent.ctx` for one agent, through your plugin context for everyone. The registration APIs are unchanged; scope-filtered events document themselves in the catalog. -- The loop's dispatch discipline is enforced three ways: `Scoped` `this`-types make a bare subject a compile error, the fused helpers make the correct spelling the shortest, and the dev invariants throw on a mis-keyed or missing carrier at the dispatching call site. -- `toolOrder` validates against the providers' pre-restriction `knownNames` universe, so a deployment order listing a global tool stays compatible with children that `restrict()` it away (a typo still fails every assembly loudly). -- A scoped listener's own disposer runs after the session leaves the store on teardown (detach precedes the scope unwind); it heard the final stop/drain flush while attached, so nothing durable is lost. -- Deliberately out of scope, buildable on the primitive with no core change: named profile registries (`agentCtx.plugin(...)` already works), per-agent `fs/*` policy, `llm/*` scoping, and background subagents (the parent-scope teardown effect is already shaped for them). +The design adds one central concept and some low-level implementation machinery. In return, it makes per-agent composition ordinary, leak-resistant, and lifecycle-safe. + +### Benefits + +- Plugin authors use the same registration APIs globally or per agent; only the context changes. +- An agent's prompt, executable tools, Code Mode bindings, policy listeners, and UI definitions resolve from the same scoped view. +- Agent disposal revokes its registrations automatically, including failure and hot-reload paths. +- Subagent persona, tool filtering, structured output, and parent-owned teardown compose without global mutation. +- Existing unscoped plugins remain global observers and contributors, preserving the deployment-wide extension model. + +### Costs and constraints + +- Every agent-subject event dispatcher must carry the correct scope; types, fused helpers, invariants, and gates exist because omission would otherwise fail open to global delivery. +- `agent.ctx` is a capability-bearing context. The loop plugin's injected service set determines what holders can reach. +- Scoped registry layers consume memory for the agent lifetime and add a two-layer resolution step, then disappear on scope disposal. +- The dispatch carrier is proxy-shaped and not identity-equal to its subject, even though property access and method calls are transparent. +- Restrictions are flat and apply only to the global tool layer; scoped registrations are deliberate grants, and parent scopes do not confer capabilities on children. +- The generic `Scope.dispose()` and `ScopeHost.dispose()` normalize Cordis's single-shot disposer but do not give racing callers a shared quiescence promise; the agent lifecycle adds that stronger boundary itself. +- Setup is a synchronous contract, but its current `(Context) => void` TypeScript shape accepts an `async` function and the runtime does not inspect the returned promise; asynchronous setup can escape rollback and race the first assembly. + +### Deliberate boundaries + +The primitive is general, but this decision scopes only the surfaces needed for coherent agent composition. Per-agent filesystem policy, LLM adapter selection, named profile registries, and background subagents can build on the same context without changing the core scope model. diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md index 18961e42fd..0063ab8b2f 100644 --- a/packages/core/scope/README.md +++ b/packages/core/scope/README.md @@ -15,6 +15,6 @@ Scoped-context registration primitive. `createScope(ctx, key)` mints a Cordis co ## Design contract -Ownership and visibility derive from ONE fact — which context a registration went through. An explicit `{ scope }` registration parameter could express "visible to X, disposed with Y", which is almost always a bug; the scoped context makes it unrepresentable. Rationale and alternatives: the agent-scoped-registration RFC (`docs/rfc/implemented/architecture/2026-07-08-agent-scoped-registration.md`, landing with this change set). +Ownership and visibility derive from ONE fact — which context a registration went through. An explicit `{ scope }` registration parameter could express "visible to X, disposed with Y", which is almost always a bug; the scoped context makes it unrepresentable. Rationale and alternatives: [the agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md). Handing out a scoped context hands out the minting plugin's service-resolution capability (resolution walks the minting fiber's dependency chain, not the holder's) — mint scopes from a plugin whose `inject` surface is what scope holders should reach. diff --git a/packages/subagent/subagent-fork/README.md b/packages/subagent/subagent-fork/README.md index 1abd7951fd..c626775945 100644 --- a/packages/subagent/subagent-fork/README.md +++ b/packages/subagent/subagent-fork/README.md @@ -12,7 +12,7 @@ The seam this rides on: `CreateAgentOptions.seed` (added on `dsh-agent`, threade ## Capabilities -`{ outputSchema: true, depthLimit: true, toolFilter: false }` — identical to spawn (the depth/model/structured-output behavior is the shared driver's). +`{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }` — identical to spawn because the shared driver owns depth, model, persona, tool-filter, and structured-output behavior. ## Config diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index a5c293c7da..8e88505366 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -23,10 +23,10 @@ Runs a child as a child [`Agent`](../../core/agent) on the same cordis context ( `attachStructuredRuntime(childCtx, schema)` registers the run's whole enforcement surface as SCOPED registrations on the child's `agent.ctx` — riding the child's fiber (a backend hot-reload mid-run cannot unregister anything; a disposed child leaves no residue) and visible to that child alone (two concurrent structured runs never interact; no placeholder schema, no strip-for-everyone-else, no refcounted global state): -- the `structured_output` capture tool with the run's REAL schema as its registered `parameters`, validating each call (`validateStructuredValue`) — violations become an `INVALID_ARGS` isError the model retries in-turn; a valid call STAGES the value keyed by its call id; +- the `structured_output` capture tool with the run's REAL schema as its registered `parameters`, validating each call (`validateStructuredValue`) — violations become an `INVALID_ARGS` isError the model retries in-turn; a valid call STAGES the value in a `WeakMap` keyed by that call's `ToolExecution` object; - the calling instruction as an ordinary order-190 scoped prompt section (the demand travels with the tool, as prompt state of exactly one agent); -- a scoped `system-prompt/assemble` re-assert (`prepend: true` = outermost): whatever downstream listeners mutate or replace, the child's assembly always carries its capture tool and instruction — the loop logs the rendered assembly as the step's `request/header`, so the demand is reconstructable log state; -- a scoped `tools/post-execute` COMMIT (`prepend: true`): the staged value becomes the run's result only when the final decision accepts THE SAME CALL that staged it — call-keyed, so a stale stage orphaned by an outer short-circuiting listener is dropped, never promoted on a later call's acceptance; +- a scoped `system-prompt/assemble` re-assert (`prepend: true`) that post-processes its downstream chain, replacing conflicting entries with the child's capture tool and instruction — the loop logs the rendered assembly as the step's `request/header`, so the demand is reconstructable log state; +- a scoped `tools/post-execute` COMMIT (`prepend: true`): the staged value becomes the run's result when that same execution's downstream post-execute decision accepts it. Execution-object identity prevents an orphaned stage from matching a later call even when an adapter reuses the call id; - a scoped `tools/pre-execute` deny for any call arriving after the capture — terminal means terminal WITHIN the step; - a scoped `agent/turn-continuation` veto (`prepend: true`) stopping the child's turn once its output is captured, so a successful capture doesn't buy a wasted extra model step. diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index def740ec25..1b1bef839b 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -11,22 +11,21 @@ * enforcement listeners fire only for this child (scope-filtered dispatch). * Registration lifetime rides the child's fiber, so a backend hot-reload * mid-run cannot unregister the capture tool out from under a live child, and - * a disposed child leaves no residue — no placeholder schema, no - * strip-for-everyone-else, no refcounted global runtime, no `WeakMap` state. + * a disposed child leaves no residue — no placeholder schema, + * strip-for-everyone-else pass, or refcounted global runtime. * * Four listeners enforce the contract: * - * - `system-prompt/assemble` (prepend, scoped): FINAL-ASSEMBLY re-assert — - * whatever downstream listeners mutated or replaced, the child's assembly - * always carries its capture tool and the trailing instruction section. The - * registry already contributes both; this outermost wrapper preserves the - * guarantee against a (global) listener that strips or replaces the - * assembly — placement-preserving: tools are replaced in place, the section - * re-inserted at its ascending-order position, so the untampered path keeps - * the registry's ordering (identical output, up to intra-band section order - * — which carries no contract). The loop logs the rendered assembly as the - * request header, so the demand is reconstructable log state, never a - * wire-only mutation. + * - `system-prompt/assemble` (prepend, scoped): assembly re-assert — the + * listener post-processes its downstream chain so a listener inside that + * chain cannot leave the child's capture tool or instruction stripped or + * replaced. Tools are replaced in place and the section is re-inserted at + * its ascending-order position, so the untampered path keeps the registry's + * ordering (up to intra-band section order, which carries no contract). A + * listener prepended later can still wrap and transform this result; this is + * an ordinary waterfall listener, not a service-level finalizer. The loop + * logs the rendered assembly as the request header, so the demand is + * reconstructable log state, never a wire-only mutation. * - `agent/turn-continuation` (prepend, scoped): stop the child's turn once * its output is captured — the loop's default "had tool calls ⇒ continue" * would buy a wasted extra model step per structured child. @@ -36,8 +35,9 @@ * effects after the final answer was accepted. * - `tools/post-execute` (prepend, scoped): the capture COMMIT. The tool body * only STAGES the validated value, KEYED BY THE EXECUTION OBJECT in a - * WeakMap; it becomes the run's captured result only when the final - * post-execute decision accepts THAT SAME pipeline trip. Execution-keyed + * WeakMap; it becomes the run's captured result when this listener's + * downstream post-execute decision accepts THAT SAME pipeline trip. A + * later-prepended wrapper remains outside that decision. Execution-keyed * staging makes the stale-stage class structurally impossible: a value * orphaned by an outer short-circuiting listener (a post-execute block, or * a pre-execute deny whose call never dispatched) can never match another @@ -125,7 +125,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut if (violations.length > 0) throw new ToolArgsError(violations) // Two-phase commit, KEYED BY THIS EXECUTION: the body only stages; the // post-execute listener promotes exactly this pipeline trip's entry - // when the final decision accepts it. + // when its downstream decision accepts it. staged.set(exec, { value: args }) return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }]) }, @@ -137,10 +137,12 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut text: STRUCTURED_OUTPUT_INSTRUCTION, }) - // FINAL-ASSEMBLY re-assert (prepend = outermost): scoped dispatch means this - // fires only for the child's assemblies; `await next()` returns whatever the - // downstream chain (and any replacement assembly) produced, and the capture - // tool + instruction are re-asserted onto it if anything stripped them. + // PREPENDED assembly re-assert: scoped dispatch means this fires only for the + // child's assemblies; `await next()` returns whatever this listener's + // downstream chain produced, and the capture tool + instruction are + // re-asserted onto it if anything stripped them. A listener prepended later + // can still wrap and transform the returned assembly; this is not a + // service-level finalizer. childCtx.on('system-prompt/assemble', async function ( this: unknown, _assembly: PromptAssembly, _context: AssembleContext, next: () => Promise, ): Promise { diff --git a/packages/subagent/subagent-spawn/README.md b/packages/subagent/subagent-spawn/README.md index b976ef5a63..132fd4d732 100644 --- a/packages/subagent/subagent-spawn/README.md +++ b/packages/subagent/subagent-spawn/README.md @@ -10,7 +10,7 @@ The run mechanics live in the shared [`@deepseek-ai/dsh-subagent-inprocess`](../ ## Capabilities -`{ outputSchema: true, depthLimit: true, toolFilter: false }`. It constructs the child, so it enforces a recursion cap, and it supports structured output via the driver's [structured runtime](../subagent-inprocess/README.md) (acquired per structured run inside the driver — this backend registers nothing at apply). Tool-scoping is deferred (the service rejects a request needing it before `start` runs). +`{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`. It constructs the child, so it enforces a recursion cap and composes the child's persona, global-tool restriction, and [structured runtime](../subagent-inprocess/README.md) inside the agent-creation setup window. This backend registers nothing at apply. ## Config diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts index 53fa5967fd..61da4eb560 100644 --- a/packages/subagent/subagent-spawn/src/index.ts +++ b/packages/subagent/subagent-spawn/src/index.ts @@ -9,10 +9,10 @@ * ({@link startInProcessRun}); this backend just passes NO seed (a fresh * child). The fork backend is an independent peer over the same driver. * - * Structured output (`outputSchema`) is supported via the driver's shared - * structured runtime: the backend acquires it for its plugin lifetime (so the - * capture tool and request-shaping listeners exist before any run), and each - * structured run holds its own acquisition until it settles. + * Structured output (`outputSchema`) is supported through the driver's + * per-child scoped runtime: the child registers its real-schema capture tool, + * prompt instruction, and enforcement listeners inside the creation setup + * window, and its scope owns their lifetime. * * Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default. * @@ -25,11 +25,10 @@ import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } fro import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' export const name = 'subagent-spawn' -// `tools` is deliberately NOT injected: the shared driver's structured runtime -// (acquired per structured RUN, not at apply) gates its own capture-tool -// registration on `tools` availability, so this backend's apply timing — and -// with it the provider-mirroring delegation tool's position in the -// model-visible tool list — stays what it was before structured output existed. +// `tools` is deliberately NOT injected: the shared driver registers structured +// output through the child's creation context, whose factory already requires +// the tool service. Keeping it out of this backend's inject list preserves the +// provider's independent apply timing. export const inject = ['subagents', 'agents'] /** Config: the registry name to register the provider under. */ diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 6fe26d3083..715fb0b9db 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -14,7 +14,10 @@ The tool description and the `prompt` parameter description are DERIVED from the |---|---| | `provider` (required) | The `ctx.subagents` provider name to start runs on (`spawn`, `fork`, `acp`, …). | | `toolName` | The model-facing tool name to register (default `subagent`). Set a distinct value per load when exposing multiple providers, e.g. `subagent` + `subagent_acp`. | -| `agentOptions` | Default per-child `{ model? }` applied to every spawned child. (No per-child persona: the deployment persona is a context-wide section every agent shares.) | +| `agentOptions` | Default per-child `{ model? }` applied to every spawned child. | +| `persona` | Per-child persona that shadows the deployment persona; requires the provider's `persona` capability. | +| `toolFilter` | Per-child `{ allow?, deny? }` restriction over global tools; requires the provider's `toolFilter` capability. | +| `maxDepth` | Maximum delegation depth; requires the provider's `depthLimit` capability. | ## Lifecycle (synchronous collect) From 850796bb35b30298dd2ab9dc298672de48b75ea9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:47:45 +0800 Subject: [PATCH 28/64] fix(tools): reserve the Code Mode transport --- packages/core/tools/README.md | 12 +-- packages/core/tools/src/code-mode.ts | 3 +- packages/core/tools/src/index.ts | 71 +++++++++---- packages/core/tools/tests/code-mode.spec.ts | 106 ++++++++++++++++++++ 4 files changed, 166 insertions(+), 26 deletions(-) diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index eb96366cab..2c423a61d4 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -11,15 +11,15 @@ tools: mode: native # native (default) | code | both ``` -`native` contributes every registered tool as a wire function definition — the default, byte-for-byte the pre-config behavior. `code` contributes exactly ONE wire tool, `run_code`, plus the generated `tools:sdk` prompt section (see [Code Mode](#code-mode)). `both` contributes every native definition AND `run_code` + the SDK section. Non-native modes require a loaded `ctx.codeRuntime` with `language: 'typescript'`; a missing or mismatched runtime rejects every prompt assembly with an actionable error, and a `systemPrompt.toolOrder` naming tools the mode no longer contributes rejects the assembly the same way. +`native` contributes every registered tool as a wire function definition — the default, byte-for-byte the pre-config behavior. `code` contributes exactly ONE wire tool, `run_code`, plus the generated `tools:sdk` prompt section (see [Code Mode](#code-mode)). `both` contributes every native definition AND `run_code` + the SDK section. In non-native modes `run_code` is reserved presentation infrastructure rather than a filterable capability: allow/deny restrictions cannot remove it, and registering, shadowing, or explicitly filtering that name fails loudly. Non-native modes require a loaded `ctx.codeRuntime` with `language: 'typescript'`; a missing or mismatched runtime rejects every prompt assembly with an actionable error, and a `systemPrompt.toolOrder` naming tools the mode no longer contributes rejects the assembly the same way. ### Public API -- `ctx.tools.register(definition: ToolDefinition): () => Promise | void` Register a tool. The layer is the CALLING context's scope (`dsh-scope`): a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, SHADOWING a same-named global tool there (per-agent tool variants). Duplicate names within one layer throw. Disposed with the calling fiber (= the agent, for scoped registrations). -- `ctx.tools.restrict(filter: ToolRestriction): () => Promise | void` Scoped-only (throws on a plain context): mask the GLOBAL tool surface for the calling agent — `allow` keeps only the listed tools, `deny` removes them; multiple restrictions intersect; scoped registrations bypass restriction as explicit grants. Snapshot-at-registration, loud unknown-name validation, `restrict({})` rejects (the materialized-empty-config trap). +- `ctx.tools.register(definition: ToolDefinition): () => Promise | void` Register a tool. The layer is the CALLING context's scope (`dsh-scope`): a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, SHADOWING a same-named global tool there (per-agent tool variants). Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Disposed with the calling fiber (= the agent, for scoped registrations). +- `ctx.tools.restrict(filter: ToolRestriction): () => Promise | void` Scoped-only (throws on a plain context): mask the GLOBAL end-capability surface for the calling agent — `allow` keeps only the listed tools, `deny` removes them; multiple restrictions intersect; scoped registrations bypass restriction as explicit grants. The reserved `run_code` transport remains available automatically and cannot be named explicitly. Snapshot-at-registration, loud unknown-name validation, `restrict({})` rejects (the materialized-empty-config trap). - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. -- `ctx.tools.visible(scope?: ScopeKey): ToolDefinition[]` THE visibility function — restricted global layer ∪ the scope's own layer — feeding prompt assembly, `get`, and `execute`, so what the model sees and what dispatches can never disagree. -- `ctx.tools.knownNames(scope?: ScopeKey): string[]` The PRE-restriction name universe configuration (`toolOrder`, `restrict`) validates against: a typo fails loud while a restricted-away tool stays a normal absence. +- `ctx.tools.visible(scope?: ScopeKey): ToolDefinition[]` THE visibility function — restricted global layer ∪ the scope's own layer, plus the reserved transport in non-native modes — feeding prompt assembly, `get`, and `execute`, so what the model sees and what dispatches can never disagree. +- `ctx.tools.knownNames(scope?: ScopeKey): string[]` The PRE-restriction end-capability name universe `restrict` validates against: a typo fails loud while a restricted-away tool stays a normal absence. Presentation providers add reserved transport names separately when validating `toolOrder`. - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)). - `ctx.tools.execute(exec: ToolExecution): Promise` Execute one tool call through the `tools/pre-execute` → `tools/execute` → `tools/post-execute` pipeline. @@ -133,7 +133,7 @@ const bash = defineTool({ ### Code Mode -Under `mode: code` (or `both`) the registry turns the tool surface into a programming API, per the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): the model writes a TypeScript program (the body of an async function) and passes it to the ONE wire tool `run_code`; the program runs in `ctx.codeRuntime` (the [code-execution seam](../../code-runtime/README.md) — the shipped backend is a worker thread) with one async binding per registered tool (`await tools.bash({...})`), and ONLY what it prints or returns re-enters the model's context. +Under `mode: code` (or `both`) the registry turns the tool surface into a programming API, per the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): the model writes a TypeScript program (the body of an async function) and passes it to the reserved wire transport `run_code`; the program runs in `ctx.codeRuntime` (the [code-execution seam](../../code-runtime/README.md) — the shipped backend is a worker thread) with one async binding per visible end-capability tool (`await tools.bash({...})`), and ONLY what it prints or returns re-enters the model's context. Scope restrictions change those SDK bindings but cannot remove or replace the transport itself. - **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of every registered tool except `run_code` (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is TOTAL: constructs outside the `defineTool` subset degrade to `unknown`, never throw. - **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized BEFORE dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and the logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes the underlying `ctx.tools.execute()` calls one at a time in submission order — the tool contract carries no concurrency-safety metadata yet), gated by `tools/pre-execute`/`tools/post-execute` like any native call (a deny reaches the program as a binding rejection), and logged as one `tool/code-dispatch` session event (log-only: `deriveMessages()` never surfaces it) with the deterministic sub-id `:code:`. A failed sub-call REJECTS the program-side promise with the tool's error text — real code error handling, no bespoke envelope. A sub-call's `additionalContext` is deliberately DROPPED (no safe outlet mid-run without breaking tool-call/result adjacency; deferred until a real hook needs it through Code Mode). diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 1eb63cea1a..c985eb852c 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -138,7 +138,8 @@ function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined { /** * Build the `run_code` {@link ToolDefinition}: one required `code` parameter, * executed through the dispatch bridge described in the module doc. The - * registry registers it under non-native modes. + * registry reserves it as presentation infrastructure under non-native modes, + * outside the filterable global/scoped capability layers. * @param registry - the owning registry (sub-calls go through its `execute`, * bindings cover its registered tools). * @param requireRuntime - resolves `ctx.codeRuntime` or throws the loud diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 5fe0d8c1b9..c3b708ecb8 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -359,8 +359,10 @@ export interface Config { * `deny` removes the listed ones; both present = allow first, then deny. * Restrictions never touch scoped registrations — a tool registered through * the same scope is an explicit grant that bypasses them (which is what keeps - * e.g. a structured-output capture tool alive under an allow-list). Multiple - * restrictions on one scope compose by intersection: every one must admit. + * e.g. a structured-output capture tool alive under an allow-list). The + * reserved `run_code` presentation transport is likewise outside capability + * filtering, and naming it explicitly is rejected. Multiple restrictions on + * one scope compose by intersection: every one must admit. */ export interface ToolRestriction { /** Global tool names that stay visible; everything else is removed. */ @@ -374,8 +376,8 @@ export interface ToolRestriction { * loop executes calls through the `tools/pre-execute` → `tools/execute` → * `tools/post-execute` pipeline. The registry contributes its schemas into the * system-prompt assembly — WHICH schemas is governed by its `mode` config - * (see {@link Config.mode}); under a non-native mode it also registers the - * `run_code` tool and the `tools:sdk` prompt section itself. + * (see {@link 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 @@ -401,15 +403,24 @@ export class ToolRegistry extends Service { /** Snapshot-at-registration restriction filters, per scope (see {@link restrict}). */ private restrictions = new Map() private readonly mode: ToolPresentationMode + /** Reserved presentation transport, kept outside the filterable registration layers. */ + private readonly codeTransport: ToolDefinition | undefined constructor(ctx: Context, config: Config = {}) { super(ctx, 'tools') // The schema already defaulted an omitted mode; the ?? narrows the // optional-input type for direct (non-Loader) construction in tests. this.mode = config.mode ?? 'native' + // `run_code` is presentation infrastructure, not an end capability. It + // therefore does not enter the global layer: per-agent restrictions must + // not remove it, and a scoped registration must not shadow it. The + // visibility resolver appends this reserved definition after resolving + // the filterable global/scoped capability layers. + this.codeTransport = this.mode === 'native' + ? undefined + : createRunCodeTool(this, () => this.requireCodeRuntime()) ctx.systemPrompt.tools(context => this.wireSchemas(context.scope)) if (this.mode !== 'native') { - this.register(createRunCodeTool(this, () => this.requireCodeRuntime())) ctx.systemPrompt.section({ name: 'tools:sdk', order: SDK_SECTION_ORDER, @@ -442,7 +453,9 @@ export class ToolRegistry extends Service { * pre-restriction and a restricted-away tool in `toolOrder` is a normal * absence — while the MODE collapse is deployment config, so under * `mode: 'code'` the universe is `[run_code]` and a `toolOrder` naming a - * native tool is dead configuration that fails every assembly loud. + * native tool is dead configuration that fails every assembly loud. Under + * `mode: 'both'`, the provider adds the reserved transport to the + * capability-only {@link knownNames} universe for `toolOrder` validation. */ private wireSchemas(scope?: ScopeKey): ToolProviderResult { if (this.mode === 'native') return { schemas: this.schemas(scope), knownNames: this.knownNames(scope) } @@ -451,7 +464,7 @@ export class ToolRegistry extends Service { if (this.mode === 'code') { return { schemas: all.filter(schema => schema.name === RUN_CODE_NAME), knownNames: [RUN_CODE_NAME] } } - return { schemas: all, knownNames: this.knownNames(scope) } + return { schemas: all, knownNames: [...this.knownNames(scope), RUN_CODE_NAME] } } /** @@ -480,9 +493,10 @@ export class ToolRegistry extends Service { * with the scope, and shadowing a same-named global tool for that agent. * Throws if the SAME layer already has the name (cross-layer name twins are * the shadowing feature, not an error; the global-duplicate message names - * `agent.ctx` as the per-agent alternative). The visible schema set flows - * into prompt assembly automatically. Disposed with the calling fiber. - * Emits `tools/change` on register/unregister. + * `agent.ctx` as the per-agent alternative), or if a non-native mode reserves + * the `run_code` name for its presentation transport. The visible schema set + * flows into prompt assembly automatically. Disposed with the calling + * fiber. Emits `tools/change` on register/unregister. * @param definition - the tool's schema plus its execute (and optional * presentation) functions. * @returns the disposer that unregisters the tool. The exact @@ -491,6 +505,9 @@ export class ToolRegistry extends Service { */ register(definition: ToolDefinition): () => Promise | void { const scope = scopeOf(this.ctx) + if (this.codeTransport !== undefined && definition.name === RUN_CODE_NAME) { + throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the Code Mode presentation transport and cannot be registered or shadowed`) + } const dispose = this.ctx.effect(function* (this: ToolRegistry) { const layer = scope === undefined ? this.global : this.layerFor(scope) if (layer.has(definition.name)) { @@ -531,10 +548,13 @@ export class ToolRegistry extends Service { * name universe ({@link knownNames}) and throws on an unknown one (fail loud * beats a typo silently filtering nothing) — register restrictions after the * global tools they mask exist (the agent-creation `setup` window satisfies - * this). The filter is SNAPSHOT at registration: later caller mutation of - * the arrays changes nothing. Multiple restrictions compose by intersection. - * Scoped registrations bypass restrictions (explicit grants win). Disposed - * with the calling fiber (revocable independently); emits `tools/change`. + * this). A non-native mode's reserved `run_code` presentation transport is + * not a filterable capability; naming it explicitly throws, while omitting + * it from an allow-list cannot remove it. The filter is SNAPSHOT at + * registration: later caller mutation of the arrays changes nothing. + * Multiple restrictions compose by intersection. Scoped registrations + * bypass restrictions (explicit grants win). Disposed with the calling + * fiber (revocable independently); emits `tools/change`. * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove). * @returns the disposer that lifts this restriction. The exact * Cordis effect disposer (single-shot): composite (generator) effects may @@ -553,6 +573,10 @@ export class ToolRegistry extends Service { ...filter.allow !== undefined ? { allow: [...filter.allow] } : {}, ...filter.deny !== undefined ? { deny: [...filter.deny] } : {}, } + if (this.codeTransport !== undefined + && [...snapshot.allow ?? [], ...snapshot.deny ?? []].includes(RUN_CODE_NAME)) { + throw new Error(`tools.restrict() cannot name reserved Code Mode presentation transport "${RUN_CODE_NAME}"; restrict end-capability tools instead`) + } const known = new Set(this.knownNames(scope)) const unknown = [...snapshot.allow ?? [], ...snapshot.deny ?? []].filter(name => !known.has(name)) if (unknown.length > 0) { @@ -604,7 +628,8 @@ export class ToolRegistry extends Service { * THE visibility function — one resolution feeding prompt assembly, * {@link get}, and {@link execute}: the global layer masked by the scope's * restrictions, unioned with the scope's own layer, scoped shadowing global - * on a name conflict. No scope = the unrestricted global view. + * on a name conflict, then the non-native mode's reserved `run_code` + * presentation transport. No scope = the unrestricted global view. * @param scope - the viewing scope (the agent), or undefined for the global view. * @returns the visible definitions (scoped shadows applied), in per-layer * registration order, global layer first. @@ -618,6 +643,10 @@ export class ToolRegistry extends Service { // Scoped layer second: same-name entries REPLACE (shadow) the global ones, // and grants bypass restrictions by construction (never filtered above). for (const [name, definition] of layer ?? []) result.set(name, definition) + // Presentation infrastructure is resolved last and outside capability + // filtering. Registration rejects this reserved name, so this set is an + // invariant assertion as well as protection against future layer changes. + if (this.codeTransport !== undefined) result.set(RUN_CODE_NAME, this.codeTransport) return [...result.values()] } @@ -631,6 +660,7 @@ export class ToolRegistry extends Service { * @returns the definition the scope resolves, or undefined when none is visible. */ get(name: string, scope?: ScopeKey): ToolDefinition | undefined { + if (name === RUN_CODE_NAME && this.codeTransport !== undefined) return this.codeTransport const shadowed = scope === undefined ? undefined : this.scoped.get(scope)?.get(name) if (shadowed) return shadowed if (!this.admits(scope, name)) return undefined @@ -658,10 +688,13 @@ export class ToolRegistry extends Service { } /** - * The PRE-restriction name universe for `scope`: every global name plus the - * scope's own layer, ignoring restrictions. This is the set configuration - * (`toolOrder`, `restrict()` filters) validates against, so a typo fails - * loud while a restricted-away tool remains a normal, non-erroneous absence. + * The PRE-restriction END-CAPABILITY name universe for `scope`: every global + * name plus the scope's own layer, ignoring restrictions. This is the set + * `restrict()` validates against, so a typo fails loud while a + * restricted-away tool remains a normal, non-erroneous absence. Reserved + * presentation transports are deliberately absent: `restrict()` rejects + * naming one, while {@link wireSchemas} adds it to the separate `toolOrder` + * validation universe when its presentation mode contributes it. * @param scope - the viewing scope (the agent); omitted = global names only. * @returns the known names, deduplicated. */ diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index f7f4b058d8..782447e7ec 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -1,11 +1,14 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' +import { createScope } from '@deepseek-ai/dsh-scope' +import type { Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools' import type { Config, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import { AgentId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEventMap } from '@deepseek-ai/dsh-session' @@ -54,6 +57,15 @@ async function setup(options: SetupOptions = {}) { return { ctx, tools: ctx.tools, systemPrompt: ctx.systemPrompt, runtime: runtime! } } +/** Mint one production-shaped agent scope that can register scoped tool policy. */ +async function mintAgentScope(ctx: Context, name = 'scoped'): Promise<{ scope: Scope; agent: Agent }> { + const agent = { id: AgentId(name) } as Agent + let scope!: Scope + await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, + { inject: ['tools', 'systemPrompt'] })) + return { scope, agent } +} + /** Register a trivial echo tool; returns the calls it received. */ function registerEcho(ctx: Context, name = 'echo'): unknown[] { const calls: unknown[] = [] @@ -119,6 +131,100 @@ describe('mode-aware wire contribution', () => { expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(true) }) + it.each(['code', 'both'] as const)('keeps the run_code transport outside scoped allow-list filtering in mode %s', async (mode) => { + const { ctx, systemPrompt, runtime } = await setup({ mode }) + registerEcho(ctx, 'echo') + registerEcho(ctx, 'hidden') + const { scope, agent } = await mintAgentScope(ctx) + const lift = scope.ctx.tools.restrict({ allow: ['echo'] }) + + const assembly = await systemPrompt.assemble({ scope: agent }) + expect(assembly.tools.map(tool => tool.name)).toEqual(mode === 'code' + ? [RUN_CODE_NAME] + : ['echo', RUN_CODE_NAME]) + const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text + expect(sdk).toContain('echo(args:') + expect(sdk).not.toContain('hidden(args:') + + runtime.behavior = request => Promise.resolve({ + logs: [], + value: Object.keys(request.bindings[0]!.functions).sort().join(','), + }) + const result = await runCode(ctx, 'return Object.keys(tools)', { agent }) + expect(result.isError).toBe(false) + expect(result.content).toEqual([{ type: 'text', text: 'echo' }]) + + await lift() + const unrestricted = await systemPrompt.assemble({ scope: agent }) + expect(unrestricted.tools.map(tool => tool.name)).toEqual(mode === 'code' + ? [RUN_CODE_NAME] + : ['echo', 'hidden', RUN_CODE_NAME]) + }) + + it.each(['code', 'both'] as const)('keeps the run_code transport outside scoped deny-list filtering in mode %s', async (mode) => { + const { ctx, systemPrompt, runtime } = await setup({ mode }) + registerEcho(ctx, 'denied') + registerEcho(ctx, 'kept') + const { scope, agent } = await mintAgentScope(ctx) + scope.ctx.tools.restrict({ deny: ['denied'] }) + + const assembly = await systemPrompt.assemble({ scope: agent }) + expect(assembly.tools.map(tool => tool.name)).toEqual(mode === 'code' + ? [RUN_CODE_NAME] + : ['kept', RUN_CODE_NAME]) + const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text + expect(sdk).not.toContain('denied(args:') + expect(sdk).toContain('kept(args:') + + runtime.behavior = request => Promise.resolve({ + logs: [], + value: Object.keys(request.bindings[0]!.functions).sort().join(','), + }) + const result = await runCode(ctx, 'return Object.keys(tools)', { agent }) + expect(result.isError).toBe(false) + expect(result.content).toEqual([{ type: 'text', text: 'kept' }]) + }) + + it.each(['code', 'both'] as const)('reserves run_code against scoped shadows and explicit restrictions in mode %s', async (mode) => { + const { ctx, systemPrompt } = await setup({ mode }) + const { scope, agent } = await mintAgentScope(ctx) + const impostor = defineTool({ + name: RUN_CODE_NAME, + description: 'Scoped impostor.', + parameters: {}, + execute: () => Promise.resolve([{ type: 'text' as const, text: 'impostor' }]), + }) + + expect(() => scope.ctx.tools.register(impostor)).toThrow(/reserved for the Code Mode presentation transport/) + expect(() => ctx.tools.register(impostor)).toThrow(/reserved for the Code Mode presentation transport/) + expect(() => scope.ctx.tools.restrict({ allow: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/) + expect(() => scope.ctx.tools.restrict({ deny: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/) + + const assembly = await systemPrompt.assemble({ scope: agent }) + const transports = assembly.tools.filter(tool => tool.name === RUN_CODE_NAME) + expect(transports).toHaveLength(1) + expect(transports[0]?.description).toContain('Execute a TypeScript program') + expect(ctx.tools.get(RUN_CODE_NAME, agent)).toBe(ctx.tools.get(RUN_CODE_NAME)) + expect(ctx.tools.knownNames(agent)).not.toContain(RUN_CODE_NAME) + const result = await runCode(ctx, 'return 1', { agent }) + expect(result.content).toEqual([{ type: 'text', text: '(run_code completed with no output)' }]) + }) + + it.each(['code', 'both'] as const)('keeps run_code in the toolOrder universe without exposing it as a restriction target in mode %s', async (mode) => { + const { ctx, systemPrompt } = await setup({ + mode, + toolOrder: [RUN_CODE_NAME, ''], + }) + registerEcho(ctx) + const { agent } = await mintAgentScope(ctx) + + expect(ctx.tools.knownNames(agent)).toEqual(['echo']) + const assembly = await systemPrompt.assemble({ scope: agent }) + expect(assembly.tools.map(tool => tool.name)).toEqual(mode === 'code' + ? [RUN_CODE_NAME] + : [RUN_CODE_NAME, 'echo']) + }) + it("never exposes run_code to programs, even under mode 'both' (no recursive dispatch path)", async () => { const { ctx, runtime } = await setup({ mode: 'both' }) registerEcho(ctx) From 3263dab822a3d7f6dfd4d34b397b0a92d3e55ba7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:55:26 +0800 Subject: [PATCH 29/64] fix(core): enforce agent-scoped ownership boundaries --- examples/coding-agent/tests/resume.e2e.ts | 4 +- .../bash/tool-bash/tests/integration.spec.ts | 23 +- packages/bash/tool-bash/tests/tools.spec.ts | 10 +- .../cordis/tool-cordis/src/api-catalog.ts | 60 +- packages/cordis/tool-cordis/src/guard.ts | 4 +- packages/core/agent-loop/package.json | 1 - packages/core/agent-loop/src/agent.ts | 77 ++- packages/core/agent-loop/src/index.ts | 379 +++++++---- packages/core/agent-loop/src/loop.ts | 89 ++- packages/core/agent-loop/tests/agent.spec.ts | 20 +- packages/core/agent-loop/tests/cancel.spec.ts | 4 +- .../tests/config-session-id.spec.ts | 2 +- packages/core/agent-loop/tests/inbox.spec.ts | 2 +- packages/core/agent-loop/tests/loop.spec.ts | 40 +- .../tests/request-reconstruction.spec.ts | 2 +- packages/core/agent-loop/tests/resume.spec.ts | 268 +++++++- .../agent-loop/tests/review-fixes.spec.ts | 7 +- .../agent-loop/tests/scope-lifecycle.spec.ts | 229 ++++++- .../core/agent-loop/tests/turn-stop.spec.ts | 199 ++++++ packages/core/agent/src/dispatch.ts | 28 +- packages/core/agent/src/index.ts | 182 ++++-- packages/core/agent/src/types.ts | 52 +- packages/core/agent/tests/agent.spec.ts | 40 +- packages/core/scope/src/index.ts | 75 ++- packages/core/scope/tests/scope.spec.ts | 78 ++- packages/core/session/src/index.ts | 26 +- packages/core/session/tests/scoped.spec.ts | 25 +- packages/core/session/tests/session.spec.ts | 1 + packages/core/system-prompt/src/index.ts | 191 +++++- .../core/system-prompt/tests/scoped.spec.ts | 42 ++ .../system-prompt/tests/system-prompt.spec.ts | 97 +++ packages/core/tools/src/code-mode.ts | 17 +- packages/core/tools/src/index.ts | 588 ++++++++++++++---- packages/core/tools/tests/code-mode.spec.ts | 84 ++- packages/core/tools/tests/scoped.spec.ts | 301 ++++++++- packages/core/tools/tests/tools.spec.ts | 284 ++++++++- packages/fs/tool-fs/tests/fs-tools.e2e.ts | 2 +- packages/fs/tool-fs/tests/tools.spec.ts | 10 +- .../hooks/hooks-claude/tests/coverage.spec.ts | 6 +- .../hooks/hooks-codex/tests/coverage.spec.ts | 2 +- packages/subagent/subagent-fork/src/index.ts | 3 +- .../subagent-fork/tests/subagent-fork.spec.ts | 4 +- .../subagent/subagent-inprocess/src/index.ts | 227 ++++--- .../subagent-inprocess/src/structured.ts | 235 +++---- .../tests/structured.spec.ts | 299 +++++++-- .../tests/subagent-inprocess.spec.ts | 183 +++++- packages/subagent/subagent-spawn/src/index.ts | 4 +- .../tests/subagent-spawn.spec.ts | 149 ++++- packages/subagent/subagent/src/index.ts | 33 +- packages/subagent/subagent/src/types.ts | 8 +- .../subagent/subagent/tests/service.spec.ts | 40 ++ packages/support/invariants/src/index.ts | 14 +- .../invariants/tests/invariants.spec.ts | 6 +- .../tests/timeout-policy.spec.ts | 4 +- packages/ui/acp/src/index.ts | 16 +- packages/ui/acp/tests/dispose.spec.ts | 8 +- packages/ui/acp/tests/edges.spec.ts | 2 +- .../tests/workflow-workerthread.e2e.ts | 2 +- scripts/gen-cordis-catalog.ts | 2 + scripts/gen-doc-graphs.ts | 43 +- scripts/gen-tool-catalog.ts | 2 +- scripts/type-equiv.manifest.json | 4 + 62 files changed, 3982 insertions(+), 857 deletions(-) create mode 100644 packages/core/agent-loop/tests/turn-stop.spec.ts diff --git a/examples/coding-agent/tests/resume.e2e.ts b/examples/coding-agent/tests/resume.e2e.ts index fc216a9848..70382b4beb 100644 --- a/examples/coding-agent/tests/resume.e2e.ts +++ b/examples/coding-agent/tests/resume.e2e.ts @@ -39,11 +39,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // dispose the whole context (simulating process exit) so only the JSONL // log on disk survives. ctx = await codingHarness(process.cwd(), { persona: SYSTEM_PROMPT, persistenceRoot: root }) - const first = ctx.agents.create({ + const first = (await ctx.agents.create({ agentId: AgentId('resume-1'), sessionId: SESSION_ID, agentOptions: { model: 'deepseek-v4-flash' }, - }).agent as ReactLoopAgent + })).agent as ReactLoopAgent first.send([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }]) await waitForIdle(ctx, first) await ctx.fiber.dispose() diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index b3d6bb3f77..a22e86bd99 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -119,37 +119,30 @@ describe('bash tool through the agent loop', () => { it('background: start → poll → completion notice lands as context/message', async () => { const adapter = new MockAdapter([ toolCallResponse('call-1', 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true }), - toolCallResponse('call-2', 'bash_output', {}, undefined), + // Each harness owns a fresh BashLocal service, whose first task id is + // deterministically bash-1. Keep the scripted call faithful to what the + // model sent; tool arguments are immutable once execution policy begins. + toolCallResponse('call-2', 'bash_output', { task_id: 'bash-1' }, undefined), textResponse('Background task finished.'), ]) - // The second tool call needs the REAL task id from the first result; - // a tools/pre-execute listener rewrites the scripted arguments. (This uses - // the low-level capability to mutate `exec` before dispatch — the - // unadvertised mechanism behind a future first-class input-rewrite decision; - // here it is a test shim to thread the generated id, not a product feature.) let taskId = '' const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('it-bg'), { model: 'mock' }) - // Intercept the first tool result to capture the generated task id, then - // rewrite the second scripted call's arguments to use it. + // Capture the generated id so the deterministic fixture is checked against + // the real executor instead of silently assuming it. ctx.on('session/event', (_session, event) => { if (event.type === 'tool/result' && taskId === '') { const match = /task (bash-\d+)/.exec(resultText(event)) if (match) taskId = match[1]! } }) - ctx.on('tools/pre-execute', async (exec, next) => { - if (exec.name === 'bash_output') { - exec.arguments = { task_id: taskId } - } - return next() - }) - agent.send([{ type: 'text', text: 'run echo bg-ok in the background' }]) await waitForIdle(ctx, agent) + expect(taskId).toBe('bash-1') + // Wait for the background task itself (completion may race turn end). const task = ctx.bash.get(BashTaskId(taskId)) if (!task) throw new Error(`task ${taskId} not registered`) diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 6299aea7d3..fb2c3bf7a8 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -242,7 +242,6 @@ describe('bash tool', () => { [{ command: ' ', description: 'd' }, /invalid command/], [{ command: 'x', description: ' ' }, /invalid description/], [{ command: 'x', description: 'd', timeoutMs: -1 }, /invalid timeoutMs/], - [{ command: 'x', description: 'd', timeoutMs: Number.NaN }, /invalid timeoutMs/], ])('rejects value-invalid args %j', async (args, pattern) => { const ctx = await setup() const result = await call(ctx, 'bash', args) @@ -250,6 +249,15 @@ describe('bash tool', () => { expect(text(result)).toMatch(pattern) }) + it('rejects a non-JSON numeric argument before tool-specific validation', async () => { + const ctx = await setup() + const result = await call(ctx, 'bash', { + command: 'x', description: 'd', timeoutMs: Number.NaN, + }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('tool execution arguments must be losslessly JSON-serializable') + }) + it('registers all three schemas in the system prompt assembly', async () => { const ctx = await setup() const names = ctx.tools.schemas().map(schema => schema.name) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 31ebdabc5d..ecb30c7a80 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -57,7 +57,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'The agent-loop plugin (`ctx.agentLoop`): creates ReactLoopAgents, runs their loops, and registers them in `ctx.agents`.', methods: [ 'create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent', - 'createAgent(options: CreateAgentOptions): AgentHandle', + 'async createAgent(options: CreateAgentOptions): Promise', 'async resume(options: ResumeAgentOptions): Promise', ], }, @@ -66,9 +66,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package.', methods: [ 'setFactory(factory: AgentFactory): () => Promise | void', - 'create(options: CreateAgentOptions): AgentHandle', + 'async create(options: CreateAgentOptions): Promise', 'async resume(options: ResumeAgentOptions): Promise', 'register(agent: Agent): () => Promise | void', + 'enter(agent: Agent): () => void', + 'announce(agent: Agent): void', 'get(id: AgentId): Agent | undefined', 'list(): Agent[]', ], @@ -161,25 +163,27 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'systemPrompt', - summary: 'Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, and named prompt variables; the agent loop calls `assemble(context)` once per step.', + summary: 'Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, named prompt variables, and authoritative contribution protections; the agent loop calls `assemble(context)` once per step.', methods: [ 'section(section: PromptSection): () => Promise | void', 'tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise | void', 'variable(name: string, provider: (context: AssembleContext) => string | undefined): () => Promise | void', + 'protect(protection: PromptProtection): () => Promise | void', 'async assemble(context: AssembleContext = {}): Promise', ], }, { key: 'tools', - summary: 'Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → `tools/execute` → `tools/post-execute` pipeline.', + summary: '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.', methods: [ 'register(definition: ToolDefinition): () => Promise | void', 'restrict(filter: ToolRestriction): () => Promise | void', + 'guard(guard: ToolGuard): () => Promise | void', 'visible(scope?: ScopeKey): ToolDefinition[]', 'get(name: string, scope?: ScopeKey): ToolDefinition | undefined', 'schemas(scope?: ScopeKey): ToolSchema[]', 'knownNames(scope?: ScopeKey): string[]', - 'async execute(exec: ToolExecution): Promise', + 'async execute(exec: ToolExecutionInput): Promise', ], }, { @@ -215,13 +219,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/created', mode: 'emit', signature: '\'agent/created\'(this: Scoped, agent: Agent): void', - summary: 'An agent was registered in the AgentRegistry and is ready to receive messages.', + summary: 'An agent\'s fully composed scoped world was published in the AgentRegistry.', }, { name: 'agent/disposed', mode: 'emit', signature: '\'agent/disposed\'(this: Scoped, agent: Agent): void', - summary: 'An agent was disposed and removed from the registry; its fiber and any in-flight turn have been torn down.', + summary: 'An agent was removed from the registry after its driver and any in-flight turn reached quiescence.', }, { name: 'agent/error', @@ -283,6 +287,12 @@ export const EVENT_API: readonly EventApiEntry[] = [ signature: '\'agent/turn-continuation\'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise', summary: 'Waterfall: override the turn-continuation decision via a typed ContinuationDecision.', }, + { + name: 'agent/turn-stop', + mode: 'serial', + signature: '\'agent/turn-stop\'(this: Scoped, agent: Agent, turn: number): Promise | ContinuationStop | undefined', + summary: 'Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` waterfall, any `continue.reason`, and the pending-steering continuation override have been folded.', + }, { name: 'fs/edit-intent', mode: 'waterfall', @@ -328,7 +338,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'subagent/end', mode: 'emit', - signature: '\'subagent/end\'(info: SubagentRunEndInfo): void', + signature: '\'subagent/end\'(this: Scoped, info: SubagentRunEndInfo): void', summary: 'A subagent run settled — emitted when SubagentRun.result resolves (any stop reason).', }, { @@ -346,7 +356,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'subagent/start', mode: 'emit', - signature: '\'subagent/start\'(info: SubagentRunInfo): void', + signature: '\'subagent/start\'(this: Scoped, info: SubagentRunInfo): void', summary: 'A subagent run started — emitted after the provider is resolved and its capabilities validated, as the child run begins.', }, { @@ -359,7 +369,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'system-prompt/change', mode: 'emit', signature: '\'system-prompt/change\'(): void', - summary: 'A section, tool provider, or variable provider was registered or unregistered (the assembly inputs changed — possibly for one scope only).', + summary: 'A section, tool provider, variable provider, or protection was registered or unregistered (the assembly inputs changed — possibly for one scope only).', }, { name: 'tools/change', @@ -385,6 +395,12 @@ export const EVENT_API: readonly EventApiEntry[] = [ signature: '\'tools/pre-execute\'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise', summary: 'Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code\'s `PreToolUse`).', }, + { + name: 'tools/result', + mode: 'parallel', + signature: '\'tools/result\'(this: Scoped, exec: Readonly, result: Readonly): Promise | void', + summary: 'Awaited notification of the authoritative FINAL tool outcome, after the complete pre/execute/post pipeline, final lossless-JSON validation, and outer error normalization.', + }, { name: 'workflow/agent-end', mode: 'emit', @@ -431,7 +447,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AgentFactory', - declaration: 'export interface AgentFactory {\n createAgent(options: CreateAgentOptions): AgentHandle;\n resume(options: ResumeAgentOptions): Promise;\n}', + declaration: 'export interface AgentFactory {\n createAgent(options: CreateAgentOptions): Promise;\n resume(options: ResumeAgentOptions): Promise;\n}', }, { name: 'AgentHandle', @@ -563,7 +579,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateAgentOptions', - declaration: 'export interface CreateAgentOptions {\n agentId: AgentId;\n sessionId: SessionId;\n meta?: {\n cwd?: string;\n parentSession?: SessionId;\n seedLength?: number;\n };\n seed?: SessionEvent[];\n agentOptions?: AgentOptions;\n setup?: (agentCtx: Context) => void;\n}', + declaration: 'export interface CreateAgentOptions {\n agentId: AgentId;\n sessionId: SessionId;\n meta?: {\n cwd?: string;\n parentSession?: SessionId;\n seedLength?: number;\n };\n seed?: SessionEvent[];\n agentOptions?: AgentOptions;\n setup?: (agentCtx: Context) => Promise | void;\n}', }, { name: 'CreateSessionOptions', @@ -665,6 +681,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PromptAssembly', declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n tools: ToolSchema[];\n variables: Record;\n}', }, + { + name: 'PromptProtection', + declaration: 'export interface PromptProtection {\n sections?: readonly string[];\n tools?: readonly string[];\n}', + }, { name: 'PromptSection', declaration: 'export interface PromptSection {\n name: string;\n order: number;\n text: string | ((context: AssembleContext) => string);\n}', @@ -675,7 +695,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ResumeAgentOptions', - declaration: 'export interface ResumeAgentOptions {\n agentId: AgentId;\n resumeSessionId: SessionId;\n agentOptions?: AgentOptions;\n}', + declaration: 'export interface ResumeAgentOptions {\n agentId: AgentId;\n resumeSessionId: SessionId;\n agentOptions?: AgentOptions;\n setup?: (agentCtx: Context) => Promise | void;\n}', }, { name: 'ScopeKey', @@ -807,12 +827,24 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolExecution', - declaration: 'export interface ToolExecution {\n callId: CallId;\n name: string;\n arguments: unknown;\n agent?: Agent;\n signal?: AbortSignal;\n}', + declaration: 'export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n}', + }, + { + name: 'ToolExecutionInput', + declaration: 'export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n}', }, { name: 'ToolExecutionResult', declaration: 'export interface ToolExecutionResult {\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContext?: HookContext;\n meta?: unknown;\n}', }, + { + name: 'ToolExecutionToken', + declaration: 'export interface ToolExecutionToken {\n readonly [toolExecutionTokenBrand]: true;\n}', + }, + { + name: 'ToolGuard', + declaration: 'export type ToolGuard = (execution: Readonly) => string | undefined;', + }, { name: 'ToolProviderResult', declaration: 'export interface ToolProviderResult {\n schemas: ToolSchema[];\n knownNames?: readonly string[];\n}', diff --git a/packages/cordis/tool-cordis/src/guard.ts b/packages/cordis/tool-cordis/src/guard.ts index 33d647db2b..10c2255141 100644 --- a/packages/cordis/tool-cordis/src/guard.ts +++ b/packages/cordis/tool-cordis/src/guard.ts @@ -253,8 +253,8 @@ const CTX_VERBS = new Set(['on', 'once', 'provide', 'timeout', 'interval', 'setT * metadata (`schemas`, and `get` returning a schema view, never the live * `ToolDefinition`). Exposing the raw definition would hand mount code the * tool's `execute` function, letting it call another tool directly and bypass - * `ToolRegistry.execute` — the pre/post-execute waterfall (permission gates, - * accounting) and result normalization. So `get` returns the same + * `ToolRegistry.execute` — identity protection, pre-policy, monotonic guards, + * around dispatch, post-policy, final observation, and result normalization. So `get` returns the same * name/description/parameters view as `schemas()`, and nothing invocable. */ function sandboxTools(ctx: Context): Record { diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index 03acc7e17f..5d180bd08c 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -11,7 +11,6 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 5f56cdc8a7..fb00f4ad76 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -16,6 +16,51 @@ import type { Session } from '@deepseek-ai/dsh-session' import { Inbox } from './inbox.ts' import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts' +/** Agents whose rollback-covered publication enabled driving. */ +const driveEnabledAgents = new WeakSet() + +/** Sessions already claimed by a concrete driver construction. */ +const claimedDriverSessions = new WeakSet() + +/** Module-private driver entry: its symbol is absent from the package surface. */ +const startDriver = Symbol('dsh.agent-loop.start-driver') + +/** Factory-owned controls that can operate only on the agent created with them. */ +export interface PreparedReactLoopAgent { + /** The unpublished concrete agent. */ + agent: ReactLoopAgent + /** Open its driving verbs at the rollback-covered publication boundary. */ + enableDrive(): void + /** Start its driver after publication and session-start notification. */ + startDriver(): () => void +} + +/** + * Construct one concrete agent together with unforgeable, instance-bound + * lifecycle controls. The package surface deliberately exposes neither source + * subpaths nor this helper: setup code may identify the concrete class, but it + * cannot enable or start the factory's unpublished instance. + * @param ctx - the agent-loop service context used for driving and events. + * @param id - the concrete agent identity. + * @param options - loop options for the agent. + * @param session - the prepared session the agent will own. + * @returns the agent and closures bound only to that exact instance. + */ +export function prepareReactLoopAgent( + ctx: Context, id: AgentId, options: AgentOptions, session: Session, +): PreparedReactLoopAgent { + if (claimedDriverSessions.has(session)) { + throw new Error(`session "${session.id}" already has a concrete agent driver`) + } + claimedDriverSessions.add(session) + const agent = new ReactLoopAgent(ctx, id, options, session) + return { + agent, + enableDrive: () => { driveEnabledAgents.add(agent) }, + startDriver: () => agent[startDriver](), + } +} + /** * The concrete {@link Agent} implementation owned by the agent-loop plugin. * @@ -24,11 +69,8 @@ import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts' * the agent/* event taxonomy — plugins never need this class. */ export class ReactLoopAgent implements Agent { - /** - * The queued + steering FIFOs behind {@link send}/{@link steer}. Public so - * the driver loop can drain it; {@link cancel} clears it wholesale. - */ - readonly inbox = new Inbox() + /** Queued + steering FIFOs; native-private so setup cannot bypass driving verbs. */ + readonly #inbox = new Inbox() /** * The agent's scope context ({@link Agent.ctx}), wired by the factory right @@ -119,7 +161,7 @@ export class ReactLoopAgent implements Agent { /** * Resolve and clear all pending {@link whenIdle} waiters. Called on a * running→idle transition (from {@link setStatus}) and on disposal (from the - * {@link start} disposer, which chains `done` for true loop-exit quiescence). + * internal driver disposer, which chains `done` for true loop-exit quiescence). */ private settleIdleWaiters(): void { const waiters = this.idleWaiters @@ -131,22 +173,31 @@ export class ReactLoopAgent implements Agent { return options?.source ?? { kind: 'user' } } + /** Reject every driving verb while creation setup still owns the agent. */ + private assertDriveEnabled(action: string): void { + if (driveEnabledAgents.has(this)) return + throw new Error(`agent "${this.id}" cannot ${action} before creation setup completes`) + } + send(content: ContentBlock[], options?: SendOptions): void { + this.assertDriveEnabled('send') if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`) const source = this.resolveSource(options) - this.inbox.enqueue({ content, source }) + this.#inbox.enqueue({ content, source }) this.loopCtx.emit(this.carrier, 'agent/queued', this, content, { source, steering: false }) } steer(content: ContentBlock[], options?: SendOptions): void { + this.assertDriveEnabled('steer') if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`) if (this._status !== 'running') { this.send(content, options); return } const source = this.resolveSource(options) - this.inbox.steer({ content, source }) + this.#inbox.steer({ content, source }) this.loopCtx.emit(this.carrier, 'agent/queued', this, content, { source, steering: true }) } inject(content: ContentBlock[], options?: SendOptions): void { + this.assertDriveEnabled('inject') if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`) const source = this.resolveSource(options) if (isTurnOpen(this.session)) { @@ -220,6 +271,7 @@ export class ReactLoopAgent implements Agent { } cancel(reason?: string): void { + this.assertDriveEnabled('cancel') // Arm-gate: only mark a cancellation when there is actually work to cancel — // a running turn, an in-flight step, or queued/steering work. An idle cancel // with nothing pending is a true no-op; arming the marker then would wrongly @@ -229,7 +281,7 @@ export class ReactLoopAgent implements Agent { // the pre-step window (a send() queued but the loop not yet flipped to // running) has status `idle` with `hasQueued` true, and the marker exists // precisely to cover it. - if (this._status === 'running' || this.currentAbort !== undefined || this.inbox.hasQueued || this.inbox.hasSteering) { + if (this._status === 'running' || this.currentAbort !== undefined || this.#inbox.hasQueued || this.#inbox.hasSteering) { this.cancelRequested = true // Capture the resolved reason for the marker-only windows (pre-step / // continuation). The mid-step path reads it from abort.signal.reason @@ -240,7 +292,7 @@ export class ReactLoopAgent implements Agent { // cancelled turn's steering is not re-enqueued). Cleared directly even when // the loop is parked in waitForQueued — there is no turn to stop and nothing // left for the parked loop to run, so no wake is needed. - this.inbox.clear() + this.#inbox.clear() // Interrupt an in-flight step immediately (the running turn observes the // abort and ends `aborted`). The marker covers the windows where no step is // running (pre-step, continuation). @@ -263,7 +315,7 @@ export class ReactLoopAgent implements Agent { */ whenIdle(): Promise { if (this._status === 'disposed') return this.done - if (this._status !== 'running' && !this.inbox.hasQueued) return Promise.resolve() + if (this._status !== 'running' && !this.#inbox.hasQueued) return Promise.resolve() // Register an internal waiter (resolved by settleIdleWaiters on the next // running→idle/disposed transition), NOT an effect-scoped `ctx.on` listener: // a concurrent fiber disposal runs this agent's listener disposers, which @@ -287,8 +339,9 @@ export class ReactLoopAgent implements Agent { * @returns the disposer — idempotent and infallible (it runs inside the * fiber's LIFO disposal chain, where a throw would skip later disposers). */ - start(): () => void { + [startDriver](): () => void { this.done = runLoop(this.loopCtx, this, { + inbox: this.#inbox, setStatus: (status) => { this.setStatus(status) }, setAbort: controller => void (this.currentAbort = controller), disposed: this.disposed, diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 47b2607881..aa7e68d00e 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -7,10 +7,11 @@ * @module @deepseek-ai/dsh-agent-loop */ -import { Context, Service } from 'cordis' +import { Context, FiberState, Service } from 'cordis' import { randomUUID } from 'node:crypto' import z from 'schemastery' import { createScope } from '@deepseek-ai/dsh-scope' +import type { Scope } from '@deepseek-ai/dsh-scope' import { agentEvents } from '@deepseek-ai/dsh-agent' import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions, SessionStartSource } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-llm' @@ -19,11 +20,9 @@ import type { Session } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' -import { ReactLoopAgent } from './agent.ts' +import { prepareReactLoopAgent, ReactLoopAgent } from './agent.ts' export { ReactLoopAgent } from './agent.ts' -export { Inbox, type InboxMessage } from './inbox.ts' -export { runLoop } from './loop.ts' declare module 'cordis' { interface Context { @@ -70,6 +69,10 @@ export interface Config { export class AgentLoop extends Service implements AgentFactory { static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt'] + /** IDs held by unpublished async creation transactions. */ + private pendingAgentIds = new Set() + private pendingSessionIds = new Set() + // The schema validates plain strings (cordis.yml config values are untyped at // runtime); the {@link Config} TYPE declares the branded `id`/`resumeSessionId` // because the config format is the boundary where an id enters. The brand is a @@ -165,18 +168,28 @@ export class AgentLoop extends Service implements AgentFactory { * and agent options. * @returns the handle whose dispose tears down exactly this agent. */ - createAgent(options: CreateAgentOptions): AgentHandle { - // Check the agent id BEFORE preparing the session: register() would reject a - // duplicate id only AFTER the session enters the store, leaving an orphaned - // live session (and lazy persistence state) that blocks reuse of that id. - this.assertAgentIdFree(options.agentId) - const session = this.ctx.sessions.prepare(options.sessionId, { - ...options.seed !== undefined ? { seed: options.seed } : {}, - meta: options.meta ?? {}, - }) - // A seeded (forked) create is still a fresh start, NOT a resume — `resume` - // is reserved for reloading a PERSISTED session via resume()/resumeWith(). - return this.startOwned(options.agentId, options.agentOptions ?? {}, session, 'startup', options.setup) + async createAgent(options: CreateAgentOptions): Promise { + // Snapshot every caller-owned field before the first async setup boundary. + // The callback itself is an identity capability; all data fields are + // detached so caller mutation cannot drift a reserved/published identity or + // the options the accepted agent observes. + const agentId = options.agentId + const sessionId = options.sessionId + const setup = options.setup + const agentOptions = structuredClone(options.agentOptions ?? {}) + const seed = options.seed === undefined ? undefined : structuredClone(options.seed) + const meta = structuredClone(options.meta ?? {}) + const release = this.reserve(agentId, sessionId) + try { + const session = this.ctx.sessions.prepare(sessionId, { + ...seed !== undefined ? { seed } : {}, + meta, + }) + // A seeded (forked) create is still a fresh start, NOT a resume. + return await this.startOwned(agentId, agentOptions, session, 'startup', setup) + } finally { + release() + } } /** @@ -226,31 +239,76 @@ export class AgentLoop extends Service implements AgentFactory { * AgentLoop's static inject, so they resolve fine). */ private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise { - this.assertAgentIdFree(options.agentId) - const { meta, events } = await persistence.load(options.resumeSessionId) - // Re-check the agent id AFTER the await: the pre-load check above can go - // stale while load() is pending (a concurrent resume/create may register the - // same id). Re-checking immediately before prepare()/start keeps the - // "no orphaned session on a duplicate id" guarantee under concurrency. - this.assertAgentIdFree(options.agentId) - // Reconstruct the live session with the FULL persisted header (createdAt, - // cwd, lineage) so resume preserves identity, not just the cwd. The seed - // events make lastTurnNumber/deriveMessages continue; the backend already - // has state (cursor) from the load above, so onCreated is a no-op and the - // seed is not re-persisted. prepare() (not create()) so the session - // lifecycle folds into the agent's composite effect (ordered teardown). - const session = this.ctx.sessions.prepare(options.resumeSessionId, { - seed: events, - meta: { - createdAt: meta.createdAt, - ...meta.cwd !== undefined ? { cwd: meta.cwd } : {}, - ...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {}, - // Reconstruct the seed boundary from the persisted header, NOT from - // `events.length` (the resume seeds the WHOLE stored log). - ...meta.seedLength !== undefined ? { seedLength: meta.seedLength } : {}, - }, - }) - return this.startOwned(options.agentId, options.agentOptions ?? {}, session, 'resume') + // Persistence is an async trust boundary. Reserve, load, reconstruct, and + // publish only the identities/options accepted at entry—never fields + // reread from a caller-owned object after the await. + const agentId = options.agentId + const sessionId = options.resumeSessionId + const agentOptions = structuredClone(options.agentOptions ?? {}) + const setup = options.setup + const { promise: ownerDisposed, resolve: markOwnerDisposed } = Promise.withResolvers() + const { promise: transactionSettled, resolve: markTransactionSettled } = Promise.withResolvers() + let observingOwner = true + // Resume must observe its caller from BEFORE persistence I/O begins. The + // full agent lifecycle does not exist until load returns, so without this + // sentinel a never-settling backend outlives owner disposal and holds both + // public identities forever. `this.ctx.effect` retains the traceable caller + // ownership used by startOwned's lifecycle effect. Install it before even + // reserving the ids: an inactive owner cannot leak a reservation if effect + // registration fails. + const disposeLoadSentinel = this.ctx.effect(() => () => { + if (!observingOwner) return + markOwnerDisposed() + // Owner-triggered teardown does not reach quiescence until the resume + // transaction has observed disposal and released both reservations. + return transactionSettled + }, `agentLoop.resumeLoad(${agentId})`) + try { + const release = this.reserve(agentId, sessionId) + try { + const loadTask = persistence.load(sessionId) + const { meta, events } = await Promise.race([ + loadTask, + ownerDisposed.then(() => { + throw new Error(`agent "${agentId}" resume aborted: owner disposed during persistence load`) + }), + ]) + // An out-of-band direct registry/session insertion can still race this + // service's reservation, so the public enter primitives re-check exact + // liveness at publication. + const session = this.ctx.sessions.prepare(sessionId, { + seed: events, + meta: { + createdAt: meta.createdAt, + ...meta.cwd !== undefined ? { cwd: meta.cwd } : {}, + ...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {}, + ...meta.seedLength !== undefined ? { seedLength: meta.seedLength } : {}, + }, + }) + // Calling startOwned synchronously installs the complete lifecycle + // effect before it reaches its first setup await. Only then disarm the + // load sentinel: ownership passes directly from one effect to the other + // with no disposal gap. + const starting = this.startOwned(agentId, agentOptions, session, 'resume', setup) + observingOwner = false + await disposeLoadSentinel() + return await starting + } finally { + release() + } + } finally { + try { + // Manual handoff/removal must not return transactionSettled: awaiting + // that promise from inside this transaction would deadlock it. If the + // owner already triggered cleanup, this idempotent second disposal is a + // no-op and the owner's first cleanup remains parked on the shared + // settlement promise. + observingOwner = false + await disposeLoadSentinel() + } finally { + markTransactionSettled() + } + } } /** @@ -260,109 +318,136 @@ export class AgentLoop extends Service implements AgentFactory { * only after the session has already entered the store. */ private assertAgentIdFree(id: AgentId): void { - if (this.ctx.agents.get(id) !== undefined) { + if (this.ctx.agents.get(id) !== undefined || this.pendingAgentIds.has(id)) { throw new Error(`agent "${id}" is already registered`) } } + /** Reserve both public identities for one unpublished async transaction. */ + private reserve(agentId: AgentId, sessionId: SessionId): () => void { + this.assertAgentIdFree(agentId) + if (this.ctx.sessions.get(sessionId) !== undefined || this.pendingSessionIds.has(sessionId)) { + throw new Error(`session "${sessionId}" already exists`) + } + this.pendingAgentIds.add(agentId) + this.pendingSessionIds.add(sessionId) + return () => { + this.pendingAgentIds.delete(agentId) + this.pendingSessionIds.delete(sessionId) + } + } + /** - * Shared: construct a ReactLoopAgent over a PREPARED (not-yet-entered) - * session, then build the ONE composite effect that owns the whole agent - * lifecycle — session entry, registry registration, and the loop. Keeping all - * three in a SINGLE effect (not sibling effects) is load-bearing: a fiber - * unload disposes sibling effects CONCURRENTLY (`Promise.all`), which would - * race the session detach against the loop's closing flush and drop the - * closing `turn/end`. Inside one effect the disposers run as an ORDERED LIFO - * chain — the runtime awaits each disposer's returned promise before the next: - * - * yield session-detach (disposed LAST — detach onAppend + remove entry) - * yield register (disposed 2nd — unregister) - * yield stop-and-drain (disposed FIRST — request loop stop, await agent.done) - * - * So on teardown: the loop is stopped and AWAITED to exit (its final - * `session/flush` + `turn/end` fire through the still-attached `onAppend`), - * THEN the agent is unregistered, THEN the session is detached — capturing the - * closing events before detach, whether the trigger is the handle's `dispose()` - * OR a fiber unload. Rollback safety: each yield runs before the next mutation, - * so a throwing `session/created`/`agent/created` listener unwinds the - * already-yielded disposers instead of leaking. - * - * `source` says why the session began ({@link SessionStartSource}); it is - * emitted as `agent/session-start` once, AFTER the agent is registered (so a - * listener can resolve the agent via `ctx.agents.get(id)` and `inject()` into - * it) and BEFORE the loop starts its first turn. The emit is contained: a - * throwing session-start listener must not abort agent construction — it is - * logged, and the agent still starts. (Unlike a turn-boundary throw, there is - * no open turn here to balance; the durable evidence of a session-start hook - * is whatever it `inject()`ed.) - * - * Returns the agent plus the composite effect's disposer (`disposeAgent`). + * Construct an unpublished agent and synchronously install its complete + * teardown skeleton before any setup await. The closures are assigned their + * session/registry/loop disposers only at publication, while the exact scope + * disposer is nested immediately. Therefore owner unload during setup flips + * `active`, unwinds the scope, and wins the race without any late Cordis + * effect collection. */ - private start( - id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource, - setup?: (agentCtx: Context) => void, - ): { agent: ReactLoopAgent; disposeAgent: () => Promise } { - const agent = new ReactLoopAgent(this.ctx, id, options, session) - // The ONE quiescence boundary every disposal path observes. Cordis effect - // disposers are single-shot but not await-idempotent: when the OWNING - // fiber's unload invokes the raw wrapper first, a concurrent - // `handle.dispose()` calling the same wrapper gets an immediate undefined - // (epoch already cleared) — so the handle path must await THIS promise, - // resolved by the teardown chain's final disposer, not the wrapper's - // return. Every disposer in the chain is deliberately infallible (stop() - // is infallible by contract, unregister/detach contain their listeners, - // the scope unwind is cordis-contained), so the final disposer always - // runs — a throwing link would skip the rest of a cordis dispose chain. + private prepareLifecycle(id: AgentId, options: AgentOptions, session: Session): { + agent: ReactLoopAgent + active: () => boolean + deactivated: Promise + publish: (source: SessionStartSource) => void + disposeAgent: () => Promise + } { + // When creation is invoked through an agent scope (subagents), the owner + // agent's disposed status flips synchronously at handle teardown—earlier + // than Cordis reaches nested scope effects. Include that signal in the + // pre-publication liveness check so a same-turn parent dispose cannot race + // an already-fulfilled setup promise into briefly publishing a child. + const ownerAgent = this.ctx.agent + const ownerFiber = this.ctx.fiber + const driver = prepareReactLoopAgent(this.ctx, id, options, session) + const { agent } = driver + const scope: Scope = createScope(this.ctx, agent) + agent.ctx = scope.ctx.extend({ agent }) + + let active = true + let detachSession: (() => void) | undefined + let detachAgent: (() => void) | undefined + let stop: (() => void) | undefined + const { promise: deactivated, resolve: markDeactivated } = Promise.withResolvers() const { promise: torndown, resolve: markTorndown } = Promise.withResolvers() - const dispose = this.ctx.effect(function* (this: AgentLoop) { - // First-yielded ⇒ disposed LAST: marks true teardown completion. + + const dispose = this.ctx.effect(function* () { + // First yielded, disposed last: every preceding teardown stage settled. yield () => { markTorndown() } - // Mint the agent's scope (key = the agent) and wire the two-phase - // reference: the scope context tags registrations + filters dispatch; - // the extend adds the `ctx.agent` DX own-property on top. The raw - // disposer is yielded IMMEDIATELY (exact function identity nests the - // scope fiber out of the loop fiber's concurrent sibling list), so - // there is no window in which a throw leaves the scope un-nested. - // - // Yield order is the REVERSE of teardown (LIFO). Teardown runs: - // stop/drain → unregister → detach session → unwind scope - // Detach BEFORE the scope unwind is deliberate: the scope fiber's - // unload is asynchronous (fiber inertia), and every disposer chained - // after an async one waits for it — detaching first keeps the - // store/registry rollback SYNCHRONOUS on every failure path (a caller - // that catches a throwing create() observes no half-created agent or - // session, and the ids are immediately reusable), at the cost that a - // scoped listener's own disposer runs after the session left the store - // (it heard the final stop/drain flush while still attached, so - // nothing durable is lost). - const scope = createScope(this.ctx, agent) - agent.ctx = scope.ctx.extend({ agent }) + // Exact identity moves the scope fiber out of the owner's concurrent + // sibling list and into this ordered transaction. yield scope.rawDispose - // Enter the session THROUGH agent.ctx so the store captures the agent's - // scope as the session's dispatch carrier. - yield agent.ctx.sessions.enter(session) + yield () => { + detachSession?.() + detachSession = undefined + } + yield () => { + detachAgent?.() + detachAgent = undefined + } + // Last yielded, disposed first. Keep the pre-publication path + // synchronous: returning a Promise only after the loop actually began + // lets a failed announcement roll back registry/store before create's + // rejection is observed. + yield () => { + active = false + markDeactivated() + if (stop === undefined) return + stop() + return agent.done + } + }, 'agentLoop.lifecycle()') + + let disposing: Promise | undefined + const disposeAgent = (): Promise => (disposing ??= (async () => { + await dispose() + await torndown + })()) + + const publish = (source: SessionStartSource): void => { + // Publication is one synchronous, rollback-covered sequence. Setup has + // already completed, so its scoped listeners observe both announcements. + detachSession = agent.ctx.sessions.enter(session) + detachAgent = this.ctx.agents.enter(agent) this.ctx.sessions.announce(session) - yield this.ctx.agents.register(agent) - // The creator's scoped composition, inside the rollback boundary: a - // throwing setup unwinds LIFO through register → scope → detach, so a - // half-created agent never leaks. Setup REGISTERS (through agent.ctx), - // it never drives — see CreateAgentOptions.setup. - setup?.(agent.ctx) - // Fire AFTER register (a listener can ctx.agents.get(id) + inject()) and - // BEFORE the loop's first turn. Contained: a throwing listener is logged, - // never aborts construction (no open turn to balance here). + this.ctx.agents.announce(agent) + // Setup is over and both entries are live. Open the driving surface just + // before session-start so its listeners retain their supported ability to + // inject/queue, while setup itself can never drive an unpublished agent. + driver.enableDrive() try { agentEvents(this.ctx, agent).emit('agent/session-start', source) } catch (error: unknown) { this.ctx.logger.warn(`agent "${id}": agent/session-start listener threw: ${String(error)}`) } - const stop = agent.start() - // Disposed FIRST (LIFO): request loop stop (sync), then AWAIT the loop's - // actual exit so its closing flush lands while onAppend (yielded above, - // disposed later) is still attached. - yield async () => { stop(); await agent.done } - }.bind(this), 'agentLoop.start()') - return { agent, disposeAgent: async () => { await dispose(); await torndown } } + stop = driver.startDriver() + } + + return { + agent, + active: () => active + && ownerFiber.state !== FiberState.UNLOADING + && ownerFiber.state !== FiberState.DISPOSED + && ownerFiber.state !== FiberState.FAILED + && ownerAgent?.status !== 'disposed', + deactivated, + publish, + disposeAgent, + } + } + + /** Publish a no-setup config agent synchronously. */ + private start( + id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource, + ): { agent: ReactLoopAgent; disposeAgent: () => Promise } { + const lifecycle = this.prepareLifecycle(id, options, session) + try { + lifecycle.publish(source) + return { agent: lifecycle.agent, disposeAgent: lifecycle.disposeAgent } + } catch (error: unknown) { + void lifecycle.disposeAgent() + throw error + } } /** @@ -382,13 +467,37 @@ export class AgentLoop extends Service implements AgentFactory { * `AgentHandle.dispose(): Promise` contract (mirrors the ACP `quiesce()` * helper). */ - private startOwned( + private async startOwned( id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource, - setup?: (agentCtx: Context) => void, - ): AgentHandle { - const { agent, disposeAgent } = this.start(id, options, session, source, setup) - let disposing: Promise | undefined - return { agent, dispose: () => (disposing ??= disposeAgent()) } + setup?: (agentCtx: Context) => Promise | void, + ): Promise { + const lifecycle = this.prepareLifecycle(id, options, session) + try { + // The owner-disposal branch makes a never-settling setup unable to hold + // the transaction or its ID reservations forever. Promise.race installs + // rejection observation on setup even if owner disposal wins first. + const setupTask = Promise.resolve(setup?.(lifecycle.agent.ctx)) + await Promise.race([ + setupTask, + lifecycle.deactivated.then(() => { + throw new Error(`agent "${id}" setup aborted: owner disposed during setup`) + }), + ]) + // Cordis begins a fiber unload synchronously but invokes nested effect + // disposers from its next microtask. Give that already-started unload one + // checkpoint to deactivate this lifecycle before publication; otherwise + // an immediately fulfilled setup continuation can outrun its owner's + // same-turn dispose and briefly publish an already-doomed child. + await Promise.resolve() + if (!lifecycle.active()) { + throw new Error(`agent "${id}" setup aborted: owner disposed during setup`) + } + lifecycle.publish(source) + return { agent: lifecycle.agent, dispose: lifecycle.disposeAgent } + } catch (error: unknown) { + await lifecycle.disposeAgent() + throw error + } } } diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index efceb101fa..b49c38f5da 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -11,7 +11,7 @@ import type { Context } from 'cordis' import type { FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm' import { BlockAssembler, HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm' import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' -import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' +import type { AgentEventDispatch, ContinuationDecision, ContinuationStop, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' import { canonicalHeader } from '@deepseek-ai/dsh-session' import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' import { createTransmissionLog, recordRequestHeader } from './request-log.ts' @@ -20,6 +20,7 @@ import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import type { ReactLoopAgent } from './agent.ts' +import type { Inbox } from './inbox.ts' /** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */ type CodedError = Error & { code?: string } @@ -35,6 +36,20 @@ function toError(error: unknown): CodedError { return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error }) } +/** + * Validate the runtime result of the terminal-stop serial event. Event types + * protect TypeScript listeners, but JavaScript and casts can still return an + * arbitrary bail value; accepting one as an implicit stop would hide a broken + * policy plugin. + */ +function assertContinuationStop(value: unknown): asserts value is ContinuationStop | undefined { + if (value === undefined) return + const candidate = Object(value) as { action?: unknown } + if (candidate.action !== 'stop') { + throw new Error('agent/turn-stop returned an invalid result; expected { action: \'stop\' } or undefined') + } +} + /** * Map a model-call {@link FinishReason} to the step error it should raise, or * `undefined` when the step completed normally. @@ -108,6 +123,8 @@ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined { * loop testable without a real agent. */ export interface LoopHandle { + /** Native-private agent inbox handed to the driver only at internal startup. */ + readonly inbox: Inbox setStatus(status: 'idle' | 'running'): void setAbort(controller: AbortController | undefined): void /** Resolves when the agent is disposed — unblocks the idle wait. */ @@ -185,6 +202,9 @@ export interface LoopHandle { * {action: hadToolCalls||steered ? 'continue':'stop'}; a continue.reason is * recorded as next-step steering * if action==stop && steering arrived (step/end/continuation listeners): continue anyway + * terminal = serial agent/turn-stop ⟵ stop or abstain; after all ordinary + * continuation and steering folding + * if terminal: discard pending steering and break * if action==stop: break * session('turn/end') ⟵ durable turn boundary (no agent/* mirror) * await ctx.sessions.flush(session) ⟵ durability checkpoint (store-owned carrier) @@ -210,7 +230,7 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH const events = agentEvents(ctx, agent) while (!handle.isDisposed()) { - await agent.inbox.waitForQueued(handle.disposed) + await handle.inbox.waitForQueued(handle.disposed) if (handle.isDisposed()) break // Pre-step cancel (window 1): a `cancel()` landed after a `send()` woke the @@ -228,7 +248,7 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH // resolve before it runs (the quiescence contract). if (handle.isCancelled()) { handle.clearCancel() - if (!agent.inbox.hasQueued) { + if (!handle.inbox.hasQueued) { handle.settleIdle() continue } @@ -250,7 +270,7 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH // is still queued and unrun (the same early-resolve race window 1 fixes). if (handle.isCancelled()) { handle.clearCancel() - if (!agent.inbox.hasQueued) { + if (!handle.inbox.hasQueued) { handle.setStatus('idle') continue } @@ -261,8 +281,9 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH // the loop waits above, so the next real turn must continue from whatever // turn number is actually last in the log — a stale counter would collide. const turn = lastTurnNumber(session) + 1 + let terminalStopped = false try { - await runTurn(ctx, events, agent, handle, turn, transmission) + terminalStopped = await runTurn(ctx, events, agent, handle, turn, transmission) } catch (error: unknown) { // Backstop: runTurn rethrows only a PRE-turn throw (the invariant guard // before turn/start) — no turn/start was appended, so no turn is open and @@ -286,27 +307,30 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH // cancelled. handle.clearCancel() - // Steering that arrived too late to join this turn (turn-end listeners, - // flush) becomes a queued message — it must never be stranded. (A cancelled - // turn already cleared its steering, so there is nothing to re-enqueue.) - for (const message of agent.inbox.drainSteering()) { - agent.inbox.enqueue(message) + // Steering that arrived too late to join an ordinary turn (turn-end + // listeners, flush) becomes queued input so it is never stranded. A + // terminal-stop owner is the deliberate exception: discard the steering + // again after the close + flush window so terminal policy cannot be undone + // after its in-turn drain. Ordinary queued sends live in a separate FIFO and + // remain untouched. + for (const message of handle.inbox.drainSteering()) { + if (!terminalStopped) handle.inbox.enqueue(message) } - if (!agent.inbox.hasQueued) handle.setStatus('idle') + if (!handle.inbox.hasQueued) handle.setStatus('idle') } } async function runTurn( ctx: Context, events: AgentEventDispatch, agent: ReactLoopAgent, handle: LoopHandle, turn: number, transmission: TransmissionLog, -): Promise { +): Promise { const { session } = agent // --- Pre-turn. A throw here (the invariant guard) is owed NO turn/end — // turn/start has not been appended — so it propagates to runLoop's backstop // untouched. The queued messages are drained here but appended AFTER // turn/start (below), so every event in the log lives inside a turn. - const queued = agent.inbox.drainQueued() + const queued = handle.inbox.drainQueued() const first = queued[0] /* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */ if (!first) throw new Error('runTurn invariant violated: no queued message at turn start') @@ -316,6 +340,7 @@ async function runTurn( let step = 0 let stepOpen = false let errorReported = false + let terminalStopped = false // Close the open step exactly once (idempotent via stepOpen). Step boundaries // are durable session events only — there is no agent/* step emit to mirror @@ -449,7 +474,7 @@ async function runTurn( // Steering from the previous round's continuation listeners joins before // the request. - drainSteering(agent, turn) + drainSteering(agent, handle.inbox, turn) // The step's AbortController exists BEFORE any async pre-step work so a // dispose() or cancel() — in a synchronous turn-start listener or an @@ -616,7 +641,7 @@ async function runTurn( if (stepReason) reason = stepReason // Steering that arrived during streaming/tool execution. - const steered = drainSteering(agent, turn) + const steered = drainSteering(agent, handle.inbox, turn) if (closeStep()) break @@ -638,14 +663,39 @@ async function runTurn( // iteration drains it before its request — the typed twin of the /goal // step/end-steer pattern. if (decision.action === 'continue' && decision.reason) { - agent.inbox.steer({ content: decision.reason.content, source: decision.reason.source }) + handle.inbox.steer({ content: decision.reason.content, source: decision.reason.source }) } let shouldContinue = decision.action === 'continue' // Steering from step/end session-event or continuation listeners (the // /goal pattern) demands the model see it — it overrides a stop decision; // the next iteration's drain records it. - if (!shouldContinue && agent.inbox.hasSteering) shouldContinue = true + if (!shouldContinue && handle.inbox.hasSteering) shouldContinue = true + + // Terminal policy runs only AFTER the extensible continuation waterfall, + // its optional reason, and late steering have all been folded. Unlike the + // waterfall, this serial seam is monotonic: the first stop bail wins, and + // no later listener or steering override can resurrect the turn. + let terminalStop = false + try { + const stop = await events.strictSerial('agent/turn-stop', turn) + assertContinuationStop(stop) + terminalStop = stop !== undefined + } catch (error: unknown) { + // A broken terminal policy is an ordinary continuation failure: fail + // this turn closed while leaving the driver alive for later turns. + failTurn(toError(error)) + break + } + if (terminalStop) { + terminalStopped = true + // A continuation reason or listener may have queued steering before the + // terminal checkpoint. Discard only steering (never ordinary queued + // prompts) so it cannot become a next step or be re-enqueued as a fresh + // turn by runLoop's late-steering fallback. + handle.inbox.drainSteering() + shouldContinue = false + } // A cancel that landed during the continuation window — after the step's // AbortController was cleared (setAbort(undefined)) but before the next @@ -720,11 +770,12 @@ async function runTurn( // contained: a throwing agent/error listener must not escape the loop. } } + return terminalStopped } /** Drain the steering queue into the session. Returns whether any arrived. */ -function drainSteering(agent: ReactLoopAgent, turn: number): boolean { - const messages = agent.inbox.drainSteering() +function drainSteering(agent: ReactLoopAgent, inbox: Inbox, turn: number): boolean { + const messages = inbox.drainSteering() for (const message of messages) { agent.session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' }) } diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index ed163bf4c2..d4f9d1e3d4 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -7,6 +7,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { prepareReactLoopAgent } from '../src/agent.ts' import { MockAdapter, textResponse } from './mock-adapter.ts' async function harness(adapter: MockAdapter) { @@ -226,16 +227,19 @@ describe('ReactLoopAgent', () => { }) it('disposer is idempotent (double-stop)', async () => { - // Create a bare ReactLoopAgent and call start() directly to get the disposer. - // Then call it twice — the second call hits the early-return branch. + // Create a bare ReactLoopAgent and start it through the package-internal + // test seam. Then call its disposer twice — the second call hits the + // early-return branch. const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('test')) - const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session) + const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session) + const { agent } = prepared // Start the loop to get the disposer; the agent waits for messages // (idle, never-resolving cancel), so it will stay idle. - const dispose = agent.start() + prepared.enableDrive() + const dispose = prepared.startDriver() // First dispose dispose() @@ -324,7 +328,7 @@ describe('ReactLoopAgent', () => { // Covers the waiter's disposed arm: whenIdle() queues an internal waiter // while running (not the fast path), then the disposer settles it and chains // `done` (loop exit), not an eager resolve. A bare ReactLoopAgent + direct - // start() disposer keeps the emit synchronous. + // internal driver disposer keeps the emit synchronous. const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) @@ -334,8 +338,10 @@ describe('ReactLoopAgent', () => { const adapter = new MockAdapter(['hang']) ctx.llm.registerAdapter(['mock'], adapter) const session = ctx.sessions.create(SessionId('bare')) - const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session) - const dispose = agent.start() + const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session) + const { agent } = prepared + prepared.enableDrive() + const dispose = prepared.startDriver() agent.send([{ type: 'text', text: 'go' }]) await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 63a75447ca..56376b69a7 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -202,7 +202,7 @@ describe('Agent.cancel()', () => { await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) - const handle = ctx.agents.create({ + const handle = await ctx.agents.create({ agentId: AgentId('a-dispose-prefix'), sessionId: SessionId('dispose-prefix-session'), agentOptions: { model: 'mock' }, @@ -333,7 +333,7 @@ describe('Agent.cancel()', () => { await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) - const handle = ctx.agents.create({ + const handle = await ctx.agents.create({ agentId: AgentId('a-dispose-step-start'), sessionId: SessionId('dispose-step-start-session'), agentOptions: { model: 'mock' }, diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index a645fb3553..97f04cbbca 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -78,7 +78,7 @@ describe('config-driven session id', () => { await ctx1.plugin(AgentLoop, { agents: [] }) await ctx1.plugin(SessionPersistenceJsonl, { root }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')])) - const a1 = ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sticky-1') }).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sticky-1') })).agent as ReactLoopAgent a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts index 5406197b75..79a518ba2b 100644 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ b/packages/core/agent-loop/tests/inbox.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Inbox } from '@deepseek-ai/dsh-agent-loop' +import { Inbox } from '../src/inbox.ts' function resolverPair() { let r!: () => void diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index eebf56ee82..734d4a73a9 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -168,7 +168,7 @@ describe('agent loop', () => { it('resolves {{cwd}} from the agent session workspace (factory create with meta.cwd)', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter, 'Working in {{cwd}}.') - const handle = ctx.agents.create({ + const handle = await ctx.agents.create({ agentId: AgentId('a-cwd'), sessionId: SessionId('s-cwd'), meta: { cwd: '/work/space' }, @@ -243,6 +243,44 @@ describe('agent loop', () => { expect(adapter.requests[0]!.system).toBe('You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou run on mock.') }) + it.each([ + ['BigInt', { n: 1n }], + ['Map', new Map([['key', 'value']])], + ['class instance', new (class ResultMeta { x = 1 })()], + ])('normalizes non-JSON tool meta (%s) before the durable result commit', async (_kind, meta) => { + const adapter = new MockAdapter([ + toolCallResponse('bad-meta-call', 'bad-meta', {}, 'calling'), + textResponse('recovered'), + ]) + const ctx = await harness(adapter) + ctx.tools.register(defineTool({ + name: 'bad-meta', + description: 'returns invalid durable metadata', + parameters: {}, + execute: () => Promise.resolve({ content: [{ type: 'text' as const, text: 'apparent success' }], meta }), + })) + const agent = ctx.agentLoop.create(AgentId('bad-meta-agent'), { model: 'mock' }) + + send(agent, 'use the tool') + await waitForIdle(ctx, agent) + + const result = agent.session.events.find(event => event.type === 'tool/result') + expect(result?.type).toBe('tool/result') + if (result?.type === 'tool/result') { + expect(result.data.callId).toBe('bad-meta-call') + expect(result.data.isError).toBe(true) + expect(result.data.meta).toBeUndefined() + expect(result.data.content).toEqual([{ + type: 'text', + text: 'Error: tools/execute must return a losslessly JSON-serializable ToolExecutionResult', + }]) + } + // The normalized failure was durably logged and fed back to the model; the + // turn continued normally instead of failing after an apparent success. + expect(adapter.requests).toHaveLength(2) + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('losslessly JSON-serializable') + }) + it('omits the system field when a system-prompt/assemble veto empties the assembly', async () => { // The documented escape valve: a deployment that must drop the harness // openers short-circuits the assemble waterfall; the request then carries diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index f1d6b02ac4..bb6d613954 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -222,7 +222,7 @@ describe('request stability across the loop', () => { // one's full log (the resume/fork path). const adapter2 = new MockAdapter([textResponse('two')]) const ctx2 = await harness(adapter2) - const handle = ctx2.agents.create({ + const handle = await ctx2.agents.create({ agentId: AgentId('gen2'), sessionId: SessionId('gen2-session'), seed: [...agent.session.events], diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 805f61d392..2a4ba7e904 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -19,6 +19,10 @@ afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: async function persistentHarness(adapter: MockAdapter): Promise<{ ctx: Context; root: string }> { const root = await mkdtemp(join(tmpdir(), 'dsh-resume-')) dirs.push(root) + return { ctx: await mountPersistentHarness(root, adapter), root } +} + +async function mountPersistentHarness(root: string, adapter: MockAdapter): Promise { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) @@ -28,7 +32,22 @@ async function persistentHarness(adapter: MockAdapter): Promise<{ ctx: Context; await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SessionPersistenceJsonl, { root }) ctx.llm.registerAdapter(['mock'], adapter) - return { ctx, root } + return ctx +} + +async function persistSession(sessionId: SessionId): Promise { + const { ctx, root } = await persistentHarness(new MockAdapter([textResponse('seed')])) + // Persistence deliberately has no artifact for a truly empty session. A + // balanced completed turn is the smallest resumable log and avoids running + // the model merely to construct this lifecycle fixture. + const seed: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + const session = ctx.sessions.create(sessionId, { seed }) + await ctx.sessions.flush(session) + await ctx.fiber.dispose() + return root } function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { @@ -39,11 +58,22 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } +/** Fail a lifecycle regression promptly instead of waiting for Vitest's suite timeout. */ +async function promptly(task: Promise): Promise { + const timeout = Promise.withResolvers() + const timer = setTimeout(() => { timeout.reject(new Error('lifecycle task did not settle promptly')) }, 1000) + try { + return await Promise.race([task, timeout.promise]) + } finally { + clearTimeout(timer) + } +} + describe('the session-persistence RFC: AgentLoop factory create/resume', () => { it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => { const adapter = new MockAdapter([textResponse('hi')]) const { ctx } = await persistentHarness(adapter) - const { agent } = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('custom-session'), meta: { cwd: '/w' } }) + const { agent } = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('custom-session'), meta: { cwd: '/w' } }) expect(agent.session.id).toBe('custom-session') expect(agent.session.header.cwd).toBe('/w') await ctx.fiber.dispose() @@ -52,10 +82,10 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { it('createAgent rejects a duplicate agent id BEFORE creating the session (no orphan)', async () => { const adapter = new MockAdapter([textResponse('hi')]) const { ctx } = await persistentHarness(adapter) - ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-a') }) + await ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-a') }) // A second create with the SAME agent id but a fresh session id must reject // up front — and must NOT leave an orphaned 'sess-b' session behind. - expect(() => ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-b') })).toThrow(/already registered/) + await expect(ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-b') })).rejects.toThrow(/already registered/) expect(ctx.sessions.get(SessionId('sess-b'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -63,7 +93,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { it('createAgent works without meta (no cwd)', async () => { const adapter = new MockAdapter([textResponse('hi')]) const { ctx } = await persistentHarness(adapter) - const { agent } = ctx.agents.create({ agentId: AgentId('a-nometa'), sessionId: SessionId('nometa-session') }) + const { agent } = await ctx.agents.create({ agentId: AgentId('a-nometa'), sessionId: SessionId('nometa-session') }) expect(agent.session.id).toBe('nometa-session') expect(agent.session.header.cwd).toBeUndefined() await ctx.fiber.dispose() @@ -73,7 +103,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // Lifecycle 1: create a no-cwd session and run a turn. const adapter1 = new MockAdapter([textResponse('a')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('nocwd-sess') }).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -100,7 +130,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { const { ctx: ctx1, root } = await persistentHarness(adapter1) const sources1: string[] = [] ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source)) - const a1 = ctx1.agents.create({ agentId: AgentId('s'), sessionId: SessionId('start-sess') }).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ agentId: AgentId('s'), sessionId: SessionId('start-sess') })).agent as ReactLoopAgent expect(sources1).toEqual(['startup']) a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) @@ -124,6 +154,224 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.fiber.dispose() }) + it('resume awaits setup while unpublished, then publishes a fully composed world in order', async () => { + const sessionId = SessionId('resume-setup-success') + const root = await persistSession(sessionId) + const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) + const gate = Promise.withResolvers() + const setupStarted = Promise.withResolvers() + const order: string[] = [] + + ctx.on('session/created', (session) => { + expect(ctx.sessions.get(session.id)).toBe(session) + expect(ctx.agents.get(AgentId('resumed-atomic'))?.session).toBe(session) + order.push('session/created') + }) + ctx.on('agent/created', (agent) => { + expect(() => { agent.cancel('too early') }).toThrow(/cannot cancel before creation setup completes/) + order.push('agent/created') + }) + ctx.on('agent/session-start', (agent) => { + expect(() => { agent.cancel('now live') }).not.toThrow() + order.push('agent/session-start') + }) + + const resuming = ctx.agents.resume({ + agentId: AgentId('resumed-atomic'), + resumeSessionId: sessionId, + agentOptions: { model: 'mock' }, + setup: async (agentCtx) => { + expect(agentCtx.agent?.id).toBe(AgentId('resumed-atomic')) + expect(agentCtx.agent?.session.events).toHaveLength(2) + agentCtx.on('session/created', () => void order.push('setup-listener:session/created')) + agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created')) + order.push('setup:start') + setupStarted.resolve(undefined) + await gate.promise + order.push('setup:end') + }, + }) + + await setupStarted.promise + expect(ctx.agents.get(AgentId('resumed-atomic'))).toBeUndefined() + expect(ctx.sessions.get(sessionId)).toBeUndefined() + expect(order).toEqual(['setup:start']) + + gate.resolve(undefined) + const handle = await resuming + expect(order).toEqual([ + 'setup:start', + 'setup:end', + 'session/created', + 'setup-listener:session/created', + 'agent/created', + 'setup-listener:agent/created', + 'agent/session-start', + ]) + await handle.dispose() + await ctx.fiber.dispose() + }) + + it('resume setup rejection publishes nothing, unwinds, and releases both identities', async () => { + const sessionId = SessionId('resume-setup-reject') + const root = await persistSession(sessionId) + const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) + const published: string[] = [] + ctx.on('session/created', () => void published.push('session/created')) + ctx.on('agent/created', () => void published.push('agent/created')) + ctx.on('agent/session-start', () => void published.push('agent/session-start')) + + await expect(ctx.agents.resume({ + agentId: AgentId('resume-reject'), + resumeSessionId: sessionId, + agentOptions: { model: 'mock' }, + setup: async () => { + await Promise.resolve() + throw new Error('resume setup failed') + }, + })).rejects.toThrow('resume setup failed') + + expect(published).toEqual([]) + expect(ctx.agents.get(AgentId('resume-reject'))).toBeUndefined() + expect(ctx.sessions.get(sessionId)).toBeUndefined() + const retry = await ctx.agents.resume({ + agentId: AgentId('resume-reject'), + resumeSessionId: sessionId, + agentOptions: { model: 'mock' }, + }) + await retry.dispose() + await ctx.fiber.dispose() + }) + + it('owner unload aborts resume setup and cannot publish after the callback settles', async () => { + const sessionId = SessionId('resume-setup-owner-unload') + const root = await persistSession(sessionId) + const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) + const gate = Promise.withResolvers() + const setupStarted = Promise.withResolvers() + const published: string[] = [] + ctx.on('session/created', () => void published.push('session/created')) + ctx.on('agent/created', () => void published.push('agent/created')) + + let resuming!: ReturnType + const owner = await ctx.plugin(Object.assign((inner: Context) => { + resuming = inner.agents.resume({ + agentId: AgentId('resume-owner-race'), + resumeSessionId: sessionId, + agentOptions: { model: 'mock' }, + setup: async () => { + setupStarted.resolve(undefined) + await gate.promise + }, + }) + }, { inject: ['agents'] })) + await setupStarted.promise + + await owner.dispose() + await expect(resuming).rejects.toThrow(/owner disposed during setup/) + expect(published).toEqual([]) + expect(ctx.agents.get(AgentId('resume-owner-race'))).toBeUndefined() + expect(ctx.sessions.get(sessionId)).toBeUndefined() + + gate.resolve(undefined) + await Promise.resolve() + expect(published).toEqual([]) + await ctx.fiber.dispose() + }) + + it('owner unload aborts a never-settling persistence load, releases identities, and blocks late publication', async () => { + const sessionId = SessionId('resume-load-owner-unload') + const agentId = AgentId('resume-load-race') + const root = await persistSession(sessionId) + const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) + const snapshot = await ctx.sessionPersistence.load(sessionId) + const lateLoad = Promise.withResolvers() + const loadStarted = Promise.withResolvers() + let loads = 0 + ctx.sessionPersistence.load = (id) => { + expect(id).toBe(sessionId) + loads += 1 + if (loads === 1) { + loadStarted.resolve(undefined) + return lateLoad.promise + } + return Promise.resolve(structuredClone(snapshot)) + } + + const published: string[] = [] + ctx.on('session/created', () => void published.push('session/created')) + ctx.on('agent/created', () => void published.push('agent/created')) + ctx.on('agent/session-start', () => void published.push('agent/session-start')) + + let resuming!: ReturnType + const owner = await ctx.plugin(Object.assign((inner: Context) => { + resuming = inner.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } }) + }, { inject: ['agents'] })) + await loadStarted.promise + + const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during persistence load/) + await promptly(owner.dispose()) + expect(published).toEqual([]) + expect(ctx.agents.get(agentId)).toBeUndefined() + expect(ctx.sessions.get(sessionId)).toBeUndefined() + + // owner.dispose() itself awaited transaction settlement and reservation + // release: reuse the same identities BEFORE awaiting the resume rejection. + const retry = await promptly(ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } })) + await rejection + expect(loads).toBe(2) + expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start']) + + // Settlement of the abandoned backend promise cannot resume the old + // transaction or emit a second publication after the retry owns the ids. + lateLoad.resolve(structuredClone(snapshot)) + await Promise.resolve() + await Promise.resolve() + expect(ctx.agents.get(agentId)).toBe(retry.agent) + expect(ctx.sessions.get(sessionId)).toBe(retry.agent.session) + expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start']) + + await retry.dispose() + await ctx.fiber.dispose() + }) + + it('snapshots resume identities and agent options before persistence load', async () => { + const sessionId = SessionId('resume-snapshot-source') + const root = await persistSession(sessionId) + const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) + const loaded = await ctx.sessionPersistence.load(sessionId) + const loadGate = Promise.withResolvers() + ctx.sessionPersistence.load = () => loadGate.promise + + const occupied = await ctx.agents.create({ + agentId: AgentId('occupied-agent'), + sessionId: SessionId('occupied-session'), + agentOptions: { model: 'mock' }, + }) + const options = { + agentId: AgentId('accepted-agent'), + resumeSessionId: sessionId, + agentOptions: { model: 'mock' }, + } + const resuming = ctx.agents.resume(options) + + options.agentId = AgentId('occupied-agent') + options.resumeSessionId = SessionId('occupied-session') + options.agentOptions.model = 'mutated-model' + loadGate.resolve(structuredClone(loaded)) + + const resumed = await resuming + expect(resumed.agent.id).toBe(AgentId('accepted-agent')) + expect(resumed.agent.session.id).toBe(sessionId) + expect(resumed.agent.options.model).toBe('mock') + expect(ctx.agents.get(AgentId('occupied-agent'))).toBe(occupied.agent) + expect(ctx.sessions.get(SessionId('occupied-session'))).toBe(occupied.agent.session) + + await resumed.dispose() + await occupied.dispose() + await ctx.fiber.dispose() + }) + it('resume of a forked session preserves the parentSession lineage and seed boundary in the header', async () => { // Lifecycle 1: persist a FORKED session (carries parentSession + seedLength // in its header) by creating it with a complete-turn seed — the write path @@ -170,7 +418,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // disk, since a crash before the next turn would otherwise lose it. const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } }).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) @@ -195,7 +443,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // drop it on reload (the bug this guards). const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } }).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) @@ -223,7 +471,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // Lifecycle 1: run one full turn, persisting it. const adapter1 = new MockAdapter([textResponse('first answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } }).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent as ReactLoopAgent a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) const events1 = [...a1.session.events] diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index e487a2c428..e527099a88 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -6,6 +6,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId, type ContinuationDecision } from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { prepareReactLoopAgent } from '../src/agent.ts' import * as Invariants from '@deepseek-ai/dsh-invariants' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -458,8 +459,10 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () ctx2.llm.registerAdapter(['mock'], second) const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] }) - const forked = new ReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded) - ctx2.effect(() => forked.start()) + const prepared = prepareReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded) + const forked = prepared.agent + prepared.enableDrive() + ctx2.effect(() => prepared.startDriver()) const turns: number[] = [] ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) }) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index b89f06727d..085629db64 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -8,6 +8,7 @@ import AgentRegistry, { AgentId, agentEvents, assembleContextFor } from '@deepse import type { Agent } from '@deepseek-ai/dsh-agent' import { scopeOf } from '@deepseek-ai/dsh-scope' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import * as concreteAgentModule from '../src/agent.ts' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -49,7 +50,7 @@ describe('agent scope lifecycle', () => { it('scoped registrations live in the agent world and die with the agent', async () => { const ctx = await harness() - const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } }) + const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } }) const { agent } = handle agent.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' }) agent.ctx.tools.register({ @@ -104,12 +105,13 @@ describe('agent scope lifecycle', () => { }) }) - const handle = ctx.agents.create({ + const handle = await ctx.agents.create({ agentId: AgentId('child'), sessionId: SessionId('child-s'), agentOptions: { model: 'mock' }, - setup: (agentCtx) => { + setup: async (agentCtx) => { order.push('setup') + await Promise.resolve() agentCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You are the child.' }) }, }) @@ -118,42 +120,233 @@ describe('agent scope lifecycle', () => { await handle.dispose() }) - it('a throwing setup unwinds the half-created agent completely', async () => { + it('keeps both identities unpublished until async setup completes, then announces in order', async () => { const ctx = await harness() - expect(() => ctx.agents.create({ + const gate = Promise.withResolvers() + const setupStarted = Promise.withResolvers() + const order: string[] = [] + ctx.on('session/created', (session) => { + expect(ctx.sessions.get(session.id)).toBe(session) + expect(ctx.agents.get(AgentId('atomic'))?.session).toBe(session) + order.push('session/created') + }) + ctx.on('agent/created', () => void order.push('agent/created')) + ctx.on('agent/session-start', () => void order.push('agent/session-start')) + const acceptedOptions = { model: 'mock' } + + const creating = ctx.agents.create({ + agentId: AgentId('atomic'), + sessionId: SessionId('atomic-s'), + agentOptions: acceptedOptions, + setup: async (agentCtx) => { + expect(agentCtx.agent?.id).toBe(AgentId('atomic')) + agentCtx.on('session/created', () => void order.push('setup-listener:session/created')) + agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created')) + order.push('setup:start') + setupStarted.resolve(undefined) + await gate.promise + order.push('setup:end') + }, + }) + await setupStarted.promise + expect(ctx.agents.get(AgentId('atomic'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('atomic-s'))).toBeUndefined() + expect(order).toEqual(['setup:start']) + acceptedOptions.model = 'mutated while setup was pending' + + gate.resolve(undefined) + const handle = await creating + expect(handle.agent.options.model).toBe('mock') + expect(order).toEqual([ + 'setup:start', + 'setup:end', + 'session/created', + 'setup-listener:session/created', + 'agent/created', + 'setup-listener:agent/created', + 'agent/session-start', + ]) + await handle.dispose() + }) + + it('reserves agent and session ids across concurrent async setup', async () => { + const ctx = await harness() + const gate = Promise.withResolvers() + const first = ctx.agents.create({ + agentId: AgentId('reserved'), + sessionId: SessionId('reserved-s'), + agentOptions: { model: 'mock' }, + setup: () => gate.promise, + }) + + await expect(ctx.agents.create({ + agentId: AgentId('reserved'), + sessionId: SessionId('other-s'), + agentOptions: { model: 'mock' }, + })).rejects.toThrow(/already registered/) + await expect(ctx.agents.create({ + agentId: AgentId('other'), + sessionId: SessionId('reserved-s'), + agentOptions: { model: 'mock' }, + })).rejects.toThrow(/already exists/) + expect(ctx.agents.list()).toEqual([]) + expect(ctx.sessions.list()).toEqual([]) + + gate.resolve(undefined) + const handle = await first + await handle.dispose() + }) + + it('structurally rejects every driving verb during setup', async () => { + const ctx = await harness() + const handle = await ctx.agents.create({ + agentId: AgentId('no-drive'), + sessionId: SessionId('no-drive-s'), + agentOptions: { model: 'mock' }, + setup: (agentCtx) => { + const agent = agentCtx.agent! + // Even JavaScript or a cast to the exported concrete class cannot name + // a public start method. Driver startup is behind a module-private + // symbol used only by AgentLoop after rollback-covered publication. + expect(Reflect.get(agent as ReactLoopAgent, 'start')).toBeUndefined() + expect(Reflect.get(concreteAgentModule, 'enableAgentDrive')).toBeUndefined() + expect(Reflect.get(concreteAgentModule, 'startAgentDriver')).toBeUndefined() + expect(() => concreteAgentModule.prepareReactLoopAgent( + agentCtx, agent.id, agent.options, agent.session, + )).toThrow(/already has a concrete agent driver/) + expect(Reflect.get(agent as ReactLoopAgent, 'inbox')).toBeUndefined() + expect(() => { agent.send(text('queued too soon')) }).toThrow(/cannot send before creation setup completes/) + expect(() => { agent.steer(text('steered too soon')) }).toThrow(/cannot steer before creation setup completes/) + expect(() => { agent.inject(text('injected too soon')) }).toThrow(/cannot inject before creation setup completes/) + expect(() => { agent.cancel('cancel too soon') }).toThrow(/cannot cancel before creation setup completes/) + expect(agent.session.events).toEqual([]) + }, + }) + expect(handle.agent.session.events).toEqual([]) + await handle.dispose() + }) + + it('owner unload aborts a pending setup and publishes nothing', async () => { + const ctx = await harness() + const gate = Promise.withResolvers() + const setupStarted = Promise.withResolvers() + const published: string[] = [] + ctx.on('session/created', () => void published.push('session/created')) + ctx.on('agent/created', () => void published.push('agent/created')) + + let creating!: ReturnType + const owner = await ctx.plugin(Object.assign((inner: Context) => { + creating = inner.agents.create({ + agentId: AgentId('owner-race'), + sessionId: SessionId('owner-race-s'), + agentOptions: { model: 'mock' }, + setup: async () => { + setupStarted.resolve(undefined) + await gate.promise + }, + }) + }, { inject: ['agents'] })) + await setupStarted.promise + + await owner.dispose() + await expect(creating).rejects.toThrow(/owner disposed during setup/) + expect(published).toEqual([]) + expect(ctx.agents.get(AgentId('owner-race'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('owner-race-s'))).toBeUndefined() + // Let the losing callback settle; Promise.race already observes it. + gate.resolve(undefined) + await Promise.resolve() + + // The other ordering in the same race: setup resolves first (its reaction + // is queued), then owner disposal flips active before that continuation can + // publish. The post-race active check must still reject. + const gate2 = Promise.withResolvers() + const setupStarted2 = Promise.withResolvers() + let creating2!: ReturnType + const owner2 = await ctx.plugin(Object.assign((inner: Context) => { + creating2 = inner.agents.create({ + agentId: AgentId('owner-race-2'), + sessionId: SessionId('owner-race-s-2'), + agentOptions: { model: 'mock' }, + setup: async () => { + setupStarted2.resolve(undefined) + await gate2.promise + }, + }) + }, { inject: ['agents'] })) + await setupStarted2.promise + gate2.resolve(undefined) + const unload2 = owner2.dispose() + await expect(creating2).rejects.toThrow(/owner disposed during setup/) + await unload2 + expect(ctx.agents.get(AgentId('owner-race-2'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('owner-race-s-2'))).toBeUndefined() + }) + + it('a rejecting setup publishes nothing and unwinds the unpublished scope', async () => { + const ctx = await harness() + const published: string[] = [] + ctx.on('session/created', () => void published.push('session/created')) + ctx.on('agent/created', () => void published.push('agent/created')) + ctx.on('agent/session-start', () => void published.push('agent/session-start')) + await expect(ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' }, - setup: () => { throw new Error('boom setup') }, - })).toThrow('boom setup') + setup: async () => { + await Promise.resolve() + throw new Error('boom setup') + }, + })).rejects.toThrow('boom setup') // Nothing leaked: no agent, no session, and the ids are reusable. + expect(published).toEqual([]) expect(ctx.agents.get(AgentId('bad'))).toBeUndefined() expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined() - const retry = ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } }) + const retry = await ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } }) await retry.dispose() }) it('a throwing session/created listener disposes the scope (pre-nesting rollback window)', async () => { const ctx = await harness() let boom = true + const disposed: string[] = [] + ctx.on('agent/disposed', agent => void disposed.push(agent.id)) ctx.on('session/created', () => { if (boom) { boom = false; throw new Error('boom created') } }) - expect(() => ctx.agents.create({ + await expect(ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' }, - })).toThrow('boom created') + })).rejects.toThrow('boom created') expect(ctx.agents.get(AgentId('bad'))).toBeUndefined() expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined() + expect(disposed).toEqual([]) // inserted but never announced: no impossible disposed edge // The rollback also disposed the scope fiber: re-creating works cleanly. - const retry = ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } }) + const retry = await ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } }) expect(scopeOf(retry.agent.ctx)).toBe(retry.agent) await retry.dispose() }) + it('the synchronous config helper rolls back when publication throws', async () => { + const ctx = await harness() + const sessionsBefore = ctx.sessions.list().length + let boom = true + ctx.on('session/created', () => { + if (boom) { + boom = false + throw new Error('config publish failed') + } + }) + + expect(() => ctx.agentLoop.create(AgentId('config-bad'), { model: 'mock' })) + .toThrow('config publish failed') + expect(ctx.agents.get(AgentId('config-bad'))).toBeUndefined() + expect(ctx.sessions.list()).toHaveLength(sessionsBefore) + }) + it('registrations through a disposed agent ctx throw INACTIVE_EFFECT', async () => { const ctx = await harness() - const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } }) + const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } }) await handle.dispose() expect(() => handle.agent.ctx.on('agent/status', () => {})).toThrow(/inactive context/) }) @@ -195,9 +388,9 @@ describe('agent scope lifecycle', () => { it('owner unload honors the documented teardown order: unregistration AFTER the drain, before detach', async () => { const ctx = await harness() - let handle!: ReturnType - const owner = await ctx.plugin(Object.assign((inner: Context) => { - handle = inner.agents.create({ agentId: AgentId('o1'), sessionId: SessionId('o1-s'), agentOptions: { model: 'mock' } }) + let handle!: Awaited> + const owner = await ctx.plugin(Object.assign(async (inner: Context) => { + handle = await inner.agents.create({ agentId: AgentId('o1'), sessionId: SessionId('o1-s'), agentOptions: { model: 'mock' } }) }, { inject: ['agents'] })) const { agent } = handle @@ -229,9 +422,9 @@ describe('agent scope lifecycle', () => { it('handle.dispose() during owner unload still awaits true quiescence (shared boundary)', async () => { const ctx = await harness() - let handle!: ReturnType - const owner = await ctx.plugin(Object.assign((inner: Context) => { - handle = inner.agents.create({ agentId: AgentId('h1'), sessionId: SessionId('h1-s'), agentOptions: { model: 'mock' } }) + let handle!: Awaited> + const owner = await ctx.plugin(Object.assign(async (inner: Context) => { + handle = await inner.agents.create({ agentId: AgentId('h1'), sessionId: SessionId('h1-s'), agentOptions: { model: 'mock' } }) }, { inject: ['agents'] })) const teardownDone: string[] = [] diff --git a/packages/core/agent-loop/tests/turn-stop.spec.ts b/packages/core/agent-loop/tests/turn-stop.spec.ts new file mode 100644 index 0000000000..c4ead42040 --- /dev/null +++ b/packages/core/agent-loop/tests/turn-stop.spec.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore, { type TurnEndReason } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId, type ContinuationStop } from '@deepseek-ai/dsh-agent' +import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import * as Invariants from '@deepseek-ai/dsh-invariants' +import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' + +async function harness(adapter: MockAdapter): Promise { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(Invariants) + await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], adapter) + return ctx +} + +function send(agent: ReactLoopAgent, text = 'go'): Promise { + agent.send([{ type: 'text', text }]) + return agent.whenIdle() +} + +function registerEcho(ctx: Context): void { + ctx.tools.register(defineTool({ + name: 'echo', + description: 'echo', + parameters: { text: { type: 'string' } }, + async execute(args) { + return [{ type: 'text', text: String(args.text) }] + }, + })) +} + +describe('agent/turn-stop', () => { + it('runs after steering folding and discards terminal steering instead of creating another step or turn', async () => { + const adapter = new MockAdapter([ + textResponse('the ordinary decision is stop'), + textResponse('must not be requested'), + ]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('terminal-steering'), { model: 'mock' }) + agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' })) + + let steered = false + ctx.on('agent/turn-continuation', async (subject, _turn, _default, next) => { + const downstream = await next() + if (subject === agent && !steered) { + steered = true + subject.steer([{ type: 'text', text: 'late continuation steering' }]) + } + return downstream + }, { prepend: true }) + + await send(agent) + + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0) + }) + + it('discards steering that arrives from session/flush after the terminal checkpoint', async () => { + const adapter = new MockAdapter([ + textResponse('terminal answer'), + textResponse('must not become a late-steering turn'), + ]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('terminal-flush-steering'), { model: 'mock' }) + agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' })) + + let injected = false + ctx.on('session/flush', (session) => { + if (session !== agent.session || injected) return + injected = true + agent.steer([{ type: 'text', text: 'steering from flush' }]) + }) + + await send(agent) + + expect(injected).toBe(true) + expect(agent.status).toBe('idle') + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0) + }) + + it('preserves an ordinary queued send that arrives during terminal flush', async () => { + const adapter = new MockAdapter([ + textResponse('first terminal answer'), + textResponse('queued follow-up answer'), + ]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('terminal-flush-send'), { model: 'mock' }) + agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' })) + + let queued = false + ctx.on('session/flush', (session) => { + if (session !== agent.session || queued) return + queued = true + agent.send([{ type: 'text', text: 'ordinary queued follow-up' }]) + }) + + await send(agent) + + expect(agent.status).toBe('idle') + expect(adapter.requests).toHaveLength(2) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) + expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(2) + }) + + it('filters a scoped terminal listener to its own agent', async () => { + const adapter = new MockAdapter([ + toolCallResponse('a1', 'echo', { text: 'a' }), + toolCallResponse('b1', 'echo', { text: 'b' }), + textResponse('b continues normally'), + ]) + const ctx = await harness(adapter) + registerEcho(ctx) + const stopped = ctx.agentLoop.create(AgentId('stopped'), { model: 'mock' }) + const ordinary = ctx.agentLoop.create(AgentId('ordinary'), { model: 'mock' }) + stopped.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' })) + + await send(stopped) + expect(adapter.requests).toHaveLength(1) + await send(ordinary) + + expect(adapter.requests).toHaveLength(3) + expect(stopped.session.events.filter(event => event.type === 'step/start')).toHaveLength(1) + expect(ordinary.session.events.filter(event => event.type === 'step/start')).toHaveLength(2) + }) + + it('unregisters with its scoped owner disposer', async () => { + const adapter = new MockAdapter([ + toolCallResponse('first', 'echo', { text: 'first' }), + toolCallResponse('second', 'echo', { text: 'second' }), + textResponse('continued after listener disposal'), + ]) + const ctx = await harness(adapter) + registerEcho(ctx) + const agent = ctx.agentLoop.create(AgentId('owned-listener'), { model: 'mock' }) + const disposeStop = agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' })) + + await send(agent, 'first turn') + expect(adapter.requests).toHaveLength(1) + + disposeStop() + await send(agent, 'second turn') + expect(adapter.requests).toHaveLength(3) + }) + + it('fails throwing and malformed terminal policies closed while the driver survives', async () => { + const adapter = new MockAdapter([ + textResponse('throwing policy'), + textResponse('malformed continue policy'), + textResponse('malformed false policy'), + textResponse('malformed null policy'), + textResponse('healthy later turn'), + ]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('bad-policy'), { model: 'mock' }) + const reasons: TurnEndReason[] = [] + const errors: string[] = [] + ctx.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason) + }) + agent.ctx.on('agent/error', (_subject, _turn, _step, error) => { errors.push(error.message) }) + + const disposeThrowing = agent.ctx.on('agent/turn-stop', () => { + throw new Error('terminal policy exploded') + }) + await send(agent, 'first') + disposeThrowing() + + for (const [index, malformed] of [ + { action: 'continue' }, + false, + null, + ].entries()) { + const disposeMalformed = agent.ctx.on('agent/turn-stop', () => malformed as unknown as ContinuationStop) + await send(agent, `malformed ${index}`) + disposeMalformed() + } + + await send(agent, 'healthy') + + expect(reasons.map(reason => reason.kind)).toEqual(['error', 'error', 'error', 'error', 'completed']) + expect(errors).toContain('terminal policy exploded') + expect(errors).toContain("agent/turn-stop returned an invalid result; expected { action: 'stop' } or undefined") + expect(adapter.requests).toHaveLength(5) + }) +}) diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts index bed4981135..b02dfeab57 100644 --- a/packages/core/agent/src/dispatch.ts +++ b/packages/core/agent/src/dispatch.ts @@ -57,6 +57,16 @@ export interface AgentEventDispatch { * @returns the serial chain's result (the first bail value, if any). */ serial(name: K, ...rest: Tail): Promise>> + /** + * Await listeners in order and return the first value other than `undefined`. + * Unlike Cordis `serial`, this does not silently treat `null` or `false` as + * abstentions. Use it for a runtime-validated public boundary whose declared + * abstention is exactly `undefined` (currently `agent/turn-stop`). + * @param name - the agent-subject event to dispatch. + * @param rest - the event's arguments after the injected agent. + * @returns the first non-undefined listener result, or undefined. + */ + strictSerial(name: K, ...rest: Tail): Promise>> /** * Around-middleware dispatch (Cordis `waterfall`) in the agent's scope. The * declared event parameters already end with the `next` callback, so `rest` @@ -79,7 +89,7 @@ export interface AgentEventDispatch { */ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { const carrier: Scoped = scopeTarget(agent, agent) - // The three dispatch methods forward through cordis' variadic mixins. The + // The ordinary dispatch methods forward through Cordis' variadic mixins. The // fused (carrier, name, agent, ...rest) tuple is provably a valid argument // list for the matching thisArg overload, but TypeScript cannot relate the // generic Tail spread back to that overload's conditional parameter @@ -95,6 +105,22 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { const serial = ctx.serial as (thisArg: Scoped, name: string, ...args: unknown[]) => Promise return await serial(carrier, name, agent, ...rest) }, + strictSerial(name, ...rest) { + return (async (): Promise => { + // EventsService.dispatch applies the carrier filter and emits the same + // internal/dispatch instrumentation as ctx.serial, then mutates `args` + // down to the actual listener parameters. Invoke those callbacks in order + // ourselves so every non-undefined value reaches the caller's validator; + // Cordis serial would discard null/false before validation could see them. + const args: unknown[] = [carrier, name, agent, ...rest] + const callbacks = ctx.events.dispatch('serial', args) + for (const callback of callbacks) { + const result: unknown = await callback(...args) + if (result !== undefined) return result + } + return undefined + })() as Promise>> + }, waterfall(name, ...rest) { // eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function const waterfall = ctx.waterfall as (thisArg: Scoped, name: string, ...args: unknown[]) => never diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 588a3c8ee1..5bd3dd80d7 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -18,14 +18,13 @@ declare module 'cordis' { interface Context { agents: AgentRegistry /** - * The agent whose scope this context belongs to, or `undefined` on any - * context not derived from an agent scope. Pure DX sugar over the - * `dsh-scope` tag: the agent loop sets it as an own property on each - * `Agent.ctx`, and {@link AgentRegistry} registers a root accessor - * defaulting to `undefined` so the read is safe on every context (a plain - * plugin context answers `undefined` instead of throwing the Cordis - * unknown-property error). Core packages below the agent layer read the - * `dsh-scope` tag (`scopeOf`) instead, never this field. + * The agent association installed as an own property on `Agent.ctx`, or + * `undefined` on a plain context. Contexts derived from `Agent.ctx` inherit + * the association; a deliberately nested scope may carry a nearer + * `dsh-scope` tag while retaining it, so this field is DX context rather + * than the scope resolver. {@link AgentRegistry} registers a root accessor + * defaulting to `undefined`, and core packages below the agent layer use + * `scopeOf()` for layer selection instead of reading this field. */ agent?: Agent } @@ -66,19 +65,20 @@ export interface CreateAgentOptions { /** Per-agent options (model, …). */ agentOptions?: AgentOptions /** - * Creation-time composition of the agent's scoped world. The factory runs it - * inside the agent's composite lifecycle effect — after the scope is minted - * and the agent registered, before `agent/session-start` fires and the loop - * starts — so everything it registers through `agentCtx` (scoped tools, - * prompt sections/variables, `restrict()`, listeners, `agentCtx.plugin(…)` - * profiles) exists before the first prompt assembly, and a THROWING setup - * unwinds inside the rollback boundary instead of leaking a half-created - * agent. **Setup registers, it never drives**: calling - * `send`/`steer`/`inject` here would open a turn before `agent/session-start` - * (the dev invariants flag a `turn/start` logged before session-start as a - * teaching error) — drive the agent after creation returns. + * Creation-time composition of the agent's scoped world. The factory awaits + * setup after minting `agentCtx` but BEFORE inserting or announcing either + * the session or agent, so observers can never see a partially configured + * world. Everything registered through `agentCtx` (scoped tools, prompt + * sections/variables, `restrict()`, listeners, awaited child plugins) exists + * before `session/created`, `agent/created`, `agent/session-start`, and the + * first prompt assembly. A throw/rejection or owner disposal rolls the scope + * back without publishing either id. + * + * **Setup composes, it never drives**: calling `send`/`steer`/`inject` here + * would run an unpublished agent and violate the session-start boundary. + * Drive the agent only after the creation promise resolves. */ - setup?: (agentCtx: Context) => void + setup?: (agentCtx: Context) => Promise | void } /** @@ -92,15 +92,26 @@ export interface ResumeAgentOptions { resumeSessionId: SessionId /** Per-agent options (model, …). */ agentOptions?: AgentOptions + /** + * Resume-time composition of the agent's fresh scoped world. Persistence is + * loaded first; the factory then mints `agentCtx` and awaits setup while the + * reconstructed session and agent remain unpublished. The callback has the + * same composition-only contract as {@link CreateAgentOptions.setup}: all + * registrations exist before either creation announcement, driving verbs are + * unavailable until the session-start boundary, and rejection or owner + * disposal rolls the transaction back without publishing either id. + */ + setup?: (agentCtx: Context) => Promise | void } /** * An owned agent plus its disposer, returned by {@link AgentRegistry.create} / * {@link AgentRegistry.resume}. The disposer is a CAPABILITY: only the holder - * can tear this agent down. `dispose()` unregisters the agent, stops its loop, - * awaits the loop's exit (quiescence — NOT just the `disposed` status flip), and - * removes the agent's session from the store, in an order that captures the - * loop's final `session/flush` before the session is detached. + * can tear this agent down. `dispose()` stops the loop, awaits its exit + * (quiescence — NOT just the `disposed` status flip), unregisters the agent, + * removes its session from the store, and finally unwinds its scoped world. + * This order captures the loop's final `session/flush` before the session is + * detached and keeps scoped listeners alive through that flush. * * `ctx.agents.get(id)` still returns a bare {@link Agent} — the handle is only * for the OWNER that created it. Config-created agents (the loop's own startup) @@ -119,15 +130,27 @@ export interface AgentHandle { */ export interface AgentFactory { /** - * Create, start, and register a new agent on a caller-supplied session id. - * Returns an {@link AgentHandle} — the owner disposes it to tear down exactly - * this agent (unregister + stop loop + await quiescence + remove session). + * Create a new agent on a caller-supplied session id. Async because creation + * awaits unpublished setup, inserts both session and agent, emits their + * creation notifications in order, unlocks driving at + * `agent/session-start`, and only then starts the loop. The sequence is + * rollback-covered, but notifications delivered before a later listener + * failure remain observable; if agent announcement began, rollback emits + * `agent/disposed`, while the session entry is removed without a separate + * disposal event. The owner disposes the resolved handle to stop/drain, + * unregister, remove the session, and unwind the scope. + * @param options - agent/session identity, configuration, and optional setup. + * @returns the owned handle after setup, both announcements, and loop start complete. */ - createAgent(options: CreateAgentOptions): AgentHandle + createAgent(options: CreateAgentOptions): Promise /** * Load a persisted session and resume an agent on it. Async because it awaits - * `ctx.sessionPersistence.load`; must be called after that service exists - * (consumers inject `sessionPersistence`). Returns an {@link AgentHandle}. + * both `ctx.sessionPersistence.load` and the optional unpublished setup + * transaction; must be called after that service exists (consumers inject + * `sessionPersistence`). Publication and drive unlocking follow the same + * ordered boundary as {@link createAgent}. + * @param options - persisted identity, configuration, and optional setup. + * @returns the owned handle after setup, both announcements, and loop start complete. */ resume(options: ResumeAgentOptions): Promise } @@ -139,11 +162,13 @@ const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plug * Agent registry (`ctx.agents`): tracks live agents so UI, hook, and * orchestrator plugins can find them without depending on the concrete loop * package. Agent *creation* is provided by whichever plugin implements the - * {@link AgentFactory} (phase 1: `@deepseek-ai/dsh-agent-loop`), registered via + * {@link AgentFactory} (`@deepseek-ai/dsh-agent-loop`), registered via * {@link setFactory}. */ export class AgentRegistry extends Service { private store = new Map() + /** Entries whose `agent/created` announcement phase began. */ + private announced = new WeakSet() private factory: AgentFactory | undefined constructor(ctx: Context) { @@ -180,15 +205,15 @@ export class AgentRegistry extends Service { } /** - * Create, start, and register a new agent through the registered factory. + * Create and publish a new agent through the registered factory. * Distinct from {@link register} (which records an already-constructed - * agent): this constructs the agent and its session. Throws if no factory is - * registered. Returns an {@link AgentHandle} — the owner disposes it to tear - * down exactly this agent. + * agent): this constructs the agent and its session. Rejects if no factory is + * registered or creation/setup fails. The resolved {@link AgentHandle} lets + * the owner tear down exactly this agent. * @param options - agent id, session id/seed/metadata, and agent options. - * @returns the handle whose dispose tears down exactly this agent. + * @returns the handle after setup, rollback-covered publication, and loop start complete. */ - create(options: CreateAgentOptions): AgentHandle { + async create(options: CreateAgentOptions): Promise { if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE) return this.factory.createAgent(options) } @@ -196,9 +221,9 @@ export class AgentRegistry extends Service { /** * Load a persisted session and resume an agent on it through the registered * factory. Rejects if no factory is registered; the factory rejects if - * session persistence is not configured. Returns an {@link AgentHandle}. - * @param options - the persisted session id plus agent id and options. - * @returns the handle for the resumed agent. + * session persistence is not configured or persistence/setup fails. + * @param options - persisted identity, configuration, and optional setup. + * @returns the handle after setup, rollback-covered publication, and loop start complete. */ async resume(options: ResumeAgentOptions): Promise { if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE) @@ -225,39 +250,58 @@ export class AgentRegistry extends Service { */ register(agent: Agent): () => Promise | void { const dispose = this.ctx.effect(function* (this: AgentRegistry) { - if (this.store.has(agent.id)) { - throw new Error(`agent "${agent.id}" is already registered`) - } - this.store.set(agent.id, agent) - // Yield the rollback BEFORE emitting `agent/created`: a generator effect - // collects each yielded disposer before the next step runs, so a - // throwing `agent/created` listener rolls the entry back instead of - // leaking it (a leak would wedge the duplicate-id check until restart). - // The duplicate throw above fires before any mutation — it leaks nothing. - yield () => { - this.store.delete(agent.id) - // CONTAIN a throwing `agent/disposed` listener: this disposer runs as - // one link in the owning fiber/effect's disposal chain, and Cordis - // chains later disposers with `task.then(next)` — so an UNCAUGHT throw - // here rejects the chain and SKIPS every later disposer. When this - // registration shares a composite effect with a session (the agent - // factory's `AgentLoop.start`, where the session-detach disposer runs - // AFTER this one), a swallowed-less throw would strand the session in - // the store with `onAppend` attached — a leak AND a durability hole. - // The store entry is already removed above (the useful state), so - // logging the listener bug and continuing is correct (mirrors the - // guarded `agent/status` emit in dsh-agent-loop's ReactLoopAgent). - try { - this.ctx.emit(scopeTarget(agent, agent), 'agent/disposed', agent) - } catch (error: unknown) { - this.ctx.logger.warn(`agent "${agent.id}": agent/disposed listener threw: ${String(error)}`) - } - } - this.ctx.emit(scopeTarget(agent, agent), 'agent/created', agent) + yield this.enter(agent) + this.announce(agent) }.bind(this), 'agents.register()') return dispose } + /** + * Insert an already-constructed agent without announcing it. This is the + * advanced ordered-lifecycle primitive used by the async agent factory: it + * first completes setup while the agent is unpublished, then assigns the + * returned detach closure into its pre-installed composite teardown before + * calling {@link announce}. Ordinary callers use {@link register}. + * @param agent - the prepared, unpublished agent. + * @returns an idempotent closure that removes this exact entry and emits + * `agent/disposed` with listener failures contained. + */ + enter(agent: Agent): () => void { + if (this.store.has(agent.id)) { + throw new Error(`agent "${agent.id}" is already registered`) + } + this.store.set(agent.id, agent) + let entered = true + return () => { + if (!entered) return + entered = false + this.store.delete(agent.id) + // An insertion rolled back before announce was never externally created, + // so emitting disposed would invent an impossible lifecycle edge. Marking + // happens before the created emit: if a later created listener throws, + // earlier listeners may already have observed it and must see disposal. + if (!this.announced.delete(agent)) return + try { + this.ctx.emit(scopeTarget(agent, agent), 'agent/disposed', agent) + } catch (error: unknown) { + this.ctx.logger.warn(`agent "${agent.id}": agent/disposed listener threw: ${String(error)}`) + } + } + } + + /** + * Announce an agent previously inserted with {@link enter}. + * @param agent - the live inserted agent to announce. + * @throws if `agent` is not the exact live registry entry for its id. + */ + announce(agent: Agent): void { + if (this.store.get(agent.id) !== agent) { + throw new Error(`agent "${agent.id}" is not live in this registry`) + } + this.announced.add(agent) + this.ctx.emit(scopeTarget(agent, agent), 'agent/created', agent) + } + /** * Look up a live agent. * @param id - the agent id to look up. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index a0ef52e735..1017a2df46 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -18,8 +18,8 @@ * - **`agent/*`** (this module) — the LIVE runtime surface. Always carries the * live `Agent`. Two shapes: INTERCEPTION seams (the `agent/prompt-submit`/ * `agent/request`/`agent/session-prefix`/`agent/step-result`/ - * `agent/turn-continuation` waterfalls and - * the serial `agent/pre-step`) that mutate/veto, and TRANSIENT emits + * `agent/turn-continuation` waterfalls and the serial `agent/pre-step` / + * `agent/turn-stop` checkpoints) that mutate/veto, and TRANSIENT emits * (`agent/status`, `agent/error`, `agent/created`/ * `agent/disposed`, `agent/queued`, `agent/session-start`) * that notify with the `Agent` in hand. Turn/step boundaries are NOT here — @@ -37,8 +37,9 @@ * and `docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md`. * * The interception waterfalls here (`agent/prompt-submit`, `agent/request`, - * `agent/step-result`, `agent/turn-continuation`) each return a typed Decision — - * the convention pinned by + * `agent/step-result`, `agent/turn-continuation`) each return a typed Decision; + * the terminal serial `agent/turn-stop` returns the stop-only subset. The + * convention is pinned by * `docs/rfc/implemented/feature/2026-06-30-interception-seams.md`. * * @module @deepseek-ai/dsh-agent/types @@ -161,6 +162,13 @@ export type ContinuationDecision = | { action: 'stop' } | { action: 'continue'; reason?: HookContext } +/** + * The terminal subset of {@link ContinuationDecision}. A listener on + * `agent/turn-stop` returns this to make the already-composed continuation + * outcome terminal; `undefined` abstains. + */ +export type ContinuationStop = Extract + /** * Why an agent's session lifecycle began, carried by `agent/session-start`. A * bridge keys its SessionStart hook's matcher on this (Claude Code's @@ -274,9 +282,12 @@ declare module 'cordis' { interface Events { // ---- lifecycle (emit) ---- /** - * An agent was registered in the {@link AgentRegistry} and is ready to - * receive messages. - * @param agent - the newly registered agent, already resolvable in the registry. + * An agent's fully composed scoped world was published in the + * {@link AgentRegistry}. Its session is already live in the session store, + * but concrete factories may keep driving verbs locked until the subsequent + * `agent/session-start` boundary; that event is the first supported place + * to inject or queue work during startup. + * @param agent - the newly registered agent with its live session and completed setup. * 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 @@ -286,9 +297,11 @@ declare module 'cordis' { */ 'agent/created'(this: Scoped, agent: Agent): void /** - * An agent was disposed and removed from the registry; its fiber and any - * in-flight turn have been torn down. - * @param agent - the agent that was torn down; its handle is now inert. + * An agent was removed from the registry after its driver and any in-flight + * turn reached quiescence. Ordered teardown may still be detaching the + * session and unwinding the agent's scoped registrations when this + * notification runs. + * @param agent - the deregistered agent; its driving handle is now inert. * 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 @@ -534,6 +547,25 @@ declare module 'cordis' { * @mode waterfall */ 'agent/turn-continuation'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise + /** + * 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. + * @param agent - the agent whose composed continuation outcome may be stopped. + * @param turn - the turn at its terminal-stop checkpoint. + * 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`), built by the emitting side via + * `scopeTarget`/`agentEvents`. + * @mode serial + */ + 'agent/turn-stop'(this: Scoped, agent: Agent, turn: number): Promise | ContinuationStop | undefined // ---- error notifications (emit) ---- /** diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 08623549ec..4e89dc0e84 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -77,6 +77,36 @@ describe('AgentRegistry', () => { await dispose() expect(ctx.agents.get(AgentId('main'))).toBeUndefined() }) + + it('splits insertion from announcement and makes the detach exact/idempotent', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const created: Agent[] = [] + const disposed: Agent[] = [] + ctx.on('agent/created', agent => void created.push(agent)) + ctx.on('agent/disposed', agent => void disposed.push(agent)) + + const first = stubAgent('split') + const detachFirst = ctx.agents.enter(first) + expect(ctx.agents.get(first.id)).toBe(first) + expect(created).toEqual([]) + ctx.agents.announce(first) + expect(created).toEqual([first]) + detachFirst() + detachFirst() + expect(disposed).toEqual([first]) + + const replacement = stubAgent('split') + const detachReplacement = ctx.agents.enter(replacement) + // A stale repeated detach cannot remove the replacement. + detachFirst() + expect(ctx.agents.get(replacement.id)).toBe(replacement) + expect(() => { ctx.agents.announce(first) }).toThrow(/not live/) + detachReplacement() + // The replacement was inserted but never announced, so rollback produces + // no disposed-without-created notification. + expect(disposed).toEqual([first]) + }) }) describe('AgentRegistry factory seam', () => { @@ -84,7 +114,7 @@ describe('AgentRegistry factory seam', () => { function stubFactory() { const calls: { create: unknown[]; resume: unknown[] } = { create: [], resume: [] } const factory: import('@deepseek-ai/dsh-agent').AgentFactory = { - createAgent(options) { + async createAgent(options) { calls.create.push(options) return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() } }, @@ -99,7 +129,7 @@ describe('AgentRegistry factory seam', () => { it('create()/resume() throw when no factory is registered', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) - expect(() => ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).toThrow(/no agent factory/) + await expect(ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).rejects.toThrow(/no agent factory/) await expect(ctx.agents.resume({ agentId: AgentId('a'), resumeSessionId: SessionId('s') })).rejects.toThrow(/no agent factory/) }) @@ -109,7 +139,7 @@ describe('AgentRegistry factory seam', () => { const { factory, calls } = stubFactory() ctx.agents.setFactory(factory) - const created = ctx.agents.create({ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } }) + const created = await ctx.agents.create({ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } }) expect(created.agent.id).toBe('c1') expect(calls.create).toEqual([{ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } }]) @@ -132,10 +162,10 @@ describe('AgentRegistry factory seam', () => { const fiber = await ctx.plugin(Object.assign((inner: Context) => { dispose = inner.agents.setFactory(stubFactory().factory) }, { inject: ['agents'] })) - expect(() => ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).not.toThrow() + await expect(ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).resolves.toBeDefined() void dispose await fiber.dispose() // factory slot cleared → create throws again - expect(() => ctx.agents.create({ agentId: AgentId('a2'), sessionId: SessionId('s2') })).toThrow(/no agent factory/) + await expect(ctx.agents.create({ agentId: AgentId('a2'), sessionId: SessionId('s2') })).rejects.toThrow(/no agent factory/) }) }) diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index 61b46978bd..7a7032b0b6 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -22,7 +22,7 @@ * @module @deepseek-ai/dsh-scope */ -import type { Context } from 'cordis' +import type { Context, Fiber } from 'cordis' import { Context as CordisContext } from 'cordis' /** @@ -78,21 +78,30 @@ export interface Scope { rawDispose: () => Promise | void /** * Unwind the scope: dispose the backing fiber, running every collected - * registration disposer. Idempotent and always awaitable — a repeat call - * resolves immediately (the underlying Cordis disposer is single-shot and - * returns undefined the second time; this wrapper Promise-normalizes it). + * registration disposer. Idempotent and always awaitable: repeat and racing + * calls share one completion even though the underlying Cordis disposer is + * single-shot and returns undefined after its first invocation. * After disposal the scoped context is inert — a further registration * through it throws Cordis's INACTIVE_EFFECT. * @returns for the call that initiates teardown: resolves when every - * registration's disposer has settled. A repeat/racing call resolves - * immediately WITHOUT awaiting the in-flight teardown (the underlying - * Cordis disposer is single-shot) — a caller needing a shared quiescence - * boundary across racing disposers keeps its own completion promise (the - * agent factory's pattern). + * registration's disposer has settled. Every repeat/racing call awaits + * that same quiescence boundary, including when {@link rawDispose} claimed + * the underlying single-shot Cordis disposer first. */ dispose(): Promise } +/** + * Dispose a Cordis fiber and await its lifecycle inertia even when some other + * caller claimed the single-shot raw disposer first. `Fiber.dispose()` returns + * `undefined` on a repeat call, but the fiber's `inertia` remains the + * authoritative promise while its async unload is running. + */ +async function quiesceFiber(fiber: Fiber): Promise { + await Promise.resolve(fiber.dispose()) + while (fiber.inertia !== undefined) await fiber.inertia +} + /** * The shared no-op plugin every scope fiber mounts: named so diagnostics read * `scope` and shared so all scopes join ONE plugin runtime (Cordis deletes the @@ -127,21 +136,22 @@ export function createScope(ctx: Context, key: ScopeKey): Scope { // Runtime guard behind the ScopeKey type: callers outside the typechecker // (yml-configured plugins, JS consumers) can still pass a primitive. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (typeof key !== 'object' || key === null) { - throw new TypeError('createScope: key must be an object (scope keys are identity-compared)') + if ((typeof key !== 'object' && typeof key !== 'function') || key === null) { + throw new TypeError('createScope: key must be a non-null object or function (scope keys are identity-compared)') } const fiber = ctx.plugin(scope) const scoped: Context = fiber.ctx.extend({ [kScope]: key }) + let disposing: Promise | undefined return { ctx: scoped, // fiber.dispose IS the disposer Cordis pushed onto the minting fiber's // disposable list — the identity a composite effect must yield (see // Scope.rawDispose). rawDispose: fiber.dispose, - // Promise.resolve-normalized: a cordis fiber's dispose returns undefined - // on a repeat call (the epoch is already cleared), and Scope.dispose - // promises an awaitable on every call. - dispose: () => Promise.resolve(fiber.dispose()), + // Memoize the public boundary and explicitly follow fiber inertia: the raw + // disposer must remain the exact Cordis function for ordered composition, + // so it cannot itself be wrapped to record a raw-first invocation. + dispose: () => (disposing ??= quiesceFiber(fiber)), } } @@ -293,8 +303,10 @@ export interface ScopeHost { mint(key: ScopeKey): Scope /** * Dispose the host fiber and with it every scope minted through it. - * @returns resolves when all collected disposers have settled (first call; - * a repeat call resolves immediately — single-shot, like Scope.dispose). + * Every racing/repeat caller observes the same completion, including when a + * child's raw disposer started before host disposal. + * @returns resolves when the host and every minted scope have reached + * quiescence. */ dispose(): Promise } @@ -334,8 +346,33 @@ export async function scopeHost(ctx: Context, services: string[]): Promise() + let disposing: Promise | undefined + const dispose = async (): Promise => { + // Start every boundary before awaiting any one of them. A child whose raw + // disposer already ran is still followed through Scope.dispose(); a child + // the host unload claims first is followed through the same fiber inertia. + const tasks = [quiesceFiber(fiber), ...[...scopes].map(scope => scope.dispose())] + const results = await Promise.allSettled(tasks) + scopes.clear() + const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason as unknown] : []) + if (errors.length === 1) throw errors[0] + if (errors.length > 1) throw new AggregateError(errors, 'scopeHost: disposal failed') + } return { - mint: (key: ScopeKey) => createScope(host, key), - dispose: () => Promise.resolve(fiber.dispose()), + mint: (key: ScopeKey) => { + const minted = createScope(host, key) + let disposing: Promise | undefined + const tracked: Scope = { + ctx: minted.ctx, + // Preserve the exact Cordis identity: only the public shared boundary + // is wrapped to retire this child from the host's tracking set. + rawDispose: minted.rawDispose, + dispose: () => (disposing ??= minted.dispose().finally(() => { scopes.delete(tracked) })), + } + scopes.add(tracked) + return tracked + }, + dispose: () => (disposing ??= dispose()), } } diff --git a/packages/core/scope/tests/scope.spec.ts b/packages/core/scope/tests/scope.spec.ts index 664ae00d7e..44b9cf4442 100644 --- a/packages/core/scope/tests/scope.spec.ts +++ b/packages/core/scope/tests/scope.spec.ts @@ -30,14 +30,19 @@ async function mintScope(ctx: Context, key: object): Promise { } describe('createScope', () => { - it('rejects a primitive key at runtime (identity-compared keys must be objects)', () => { + it('rejects primitive keys but accepts callable objects (matching ScopeKey)', async () => { const ctx = new Context() // Typed through `unknown` so the ScopeKey type cannot argue the assertion // away: this test exercises exactly the callers the typechecker misses. const badKeys: unknown[] = ['k', null] for (const bad of badKeys) { - expect(() => createScope(ctx, bad as ScopeKey)).toThrow(/must be an object/) + expect(() => createScope(ctx, bad as ScopeKey)).toThrow(/must be a non-null object or function/) } + + const callable = Object.assign(() => {}, { nameForTest: 'callable-key' }) + const scope = await mintScope(ctx, callable) + expect(scopeOf(scope.ctx)).toBe(callable) + await scope.dispose() }) it('tags the scoped context, readable through derivations (nearest tag wins)', async () => { @@ -91,6 +96,29 @@ describe('createScope', () => { expect(() => scope.ctx.effect(() => () => {})).toThrow(/inactive context/) }) + it('dispose() follows a rawDispose-first race through async quiescence', async () => { + const ctx = new Context() + const scope = await mintScope(ctx, { name: 'raw-first' }) + const gate = Promise.withResolvers() + let cleanupFinished = false + scope.ctx.effect(() => async () => { + await gate.promise + cleanupFinished = true + }) + + const raw = Promise.resolve(scope.rawDispose()) + let publicSettled = false + const publicDispose = scope.dispose().then(() => { publicSettled = true }) + await Promise.resolve() + expect(publicSettled).toBe(false) + expect(cleanupFinished).toBe(false) + + gate.resolve(undefined) + await Promise.all([raw, publicDispose]) + expect(cleanupFinished).toBe(true) + await expect(scope.dispose()).resolves.toBeUndefined() + }) + it('rawDispose is the exact cordis disposer: yielding it nests the scope at its position', async () => { const ctx = new Context() const order: string[] = [] @@ -287,6 +315,52 @@ describe('scopeHost', () => { expect(() => scope.ctx.effect(() => () => {})).toThrow(/inactive context/) }) + it('dispose waits for a child whose raw disposer won the race', async () => { + const ctx = new Context() + ctx.provide('answers', { value: 42 }) + const host = await scopeHost(ctx, ['answers']) + const scope = host.mint({ name: 'raw-first-child' }) + const gate = Promise.withResolvers() + let cleanupFinished = false + scope.ctx.effect(() => async () => { + await gate.promise + cleanupFinished = true + }) + + const raw = Promise.resolve(scope.rawDispose()) + let hostSettled = false + const hostDispose = host.dispose().then(() => { hostSettled = true }) + await Promise.resolve() + expect(hostSettled).toBe(false) + + gate.resolve(undefined) + await Promise.all([raw, hostDispose]) + expect(cleanupFinished).toBe(true) + await expect(host.dispose()).resolves.toBeUndefined() + }) + + it('reaches every child before surfacing one or multiple disposal failures', async () => { + const oneCtx = new Context() + oneCtx.provide('answers', { value: 42 }) + const oneHost = await scopeHost(oneCtx, ['answers']) + const one = oneHost.mint({ name: 'one' }) + one.dispose = () => Promise.reject(new Error('one failed')) + await expect(oneHost.dispose()).rejects.toThrow('one failed') + + const manyCtx = new Context() + manyCtx.provide('answers', { value: 42 }) + const manyHost = await scopeHost(manyCtx, ['answers']) + const a = manyHost.mint({ name: 'a' }) + const b = manyHost.mint({ name: 'b' }) + a.dispose = () => Promise.reject(new Error('a failed')) + b.dispose = () => Promise.reject(new Error('b failed')) + await expect(manyHost.dispose()).rejects.toMatchObject({ + name: 'AggregateError', + message: 'scopeHost: disposal failed', + errors: [expect.objectContaining({ message: 'a failed' }), expect.objectContaining({ message: 'b failed' })], + }) + }) + it('fails LOUD naming absent services instead of resolving as a silent no-op host', async () => { const ctx = new Context() await expect(scopeHost(ctx, ['tools', 'systemPrompt'])) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 0bbd957a79..d3f4ca4890 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -538,8 +538,12 @@ export class SessionStore extends Service { const emitCtx = this.ctx session.onAppend = (event) => { emitCtx.emit(carrier, 'session/event', session, event) } this.store.set(session.id, session) + let entered = true return () => { + if (!entered) return + entered = false session.onAppend = undefined + this.carriers.delete(session) this.store.delete(session.id) } } @@ -549,7 +553,7 @@ export class SessionStore extends Service { * yield the detach disposer first (rollback safety — see {@link enter}). * @param session - the entered session to announce to listeners. */ announce(session: Session): void { - this.ctx.emit(this.carrierFor(session), 'session/created', session) + this.ctx.emit(this.liveCarrierFor(session), 'session/created', session) } /** @@ -563,13 +567,23 @@ export class SessionStore extends Service { * @returns resolves when every flush listener has settled; rejects if one rejects. */ async flush(session: Session): Promise { - await this.ctx.parallel(this.carrierFor(session), 'session/flush', session) + await this.ctx.parallel(this.liveCarrierFor(session), 'session/flush', session) } - /** The carrier {@link enter} captured, or a subject-less one for a session - * never entered (defensive: dispatch stays filtered either way). */ - private carrierFor(session: Session): Scoped { - return this.carriers.get(session) ?? scopeTarget(session, undefined) + /** Return the exact live session's carrier; detached/prepared objects reject. */ + private liveCarrierFor(session: Session): Scoped { + if (this.store.get(session.id) !== session) { + throw new Error(`session "${session.id}" is not live in this store`) + } + const carrier = this.carriers.get(session) + // enter() installs store + carrier in one synchronous sequence; a live + // session without one is an internal invariant violation, never fallback + // to subject-less dispatch (that would silently cross scope boundaries). + /* v8 ignore next -- enter installs store and carrier in one synchronous sequence */ + if (carrier === undefined) { + throw new Error(`session "${session.id}" has no dispatch carrier`) + } + return carrier } /** diff --git a/packages/core/session/tests/scoped.spec.ts b/packages/core/session/tests/scoped.spec.ts index 743b72def3..ee1e710397 100644 --- a/packages/core/session/tests/scoped.spec.ts +++ b/packages/core/session/tests/scoped.spec.ts @@ -91,16 +91,33 @@ describe('sessions.flush()', () => { await expect(ctx.sessions.flush(session)).rejects.toThrow('disk full') }) - it('flushes a never-entered session with a subject-less carrier (defensive path)', async () => { + it('rejects a never-entered session instead of inventing a carrier', async () => { const ctx = await mount() const scope = await mintScope(ctx, 'owner') const flushed: string[] = [] ctx.on('session/flush', (session: Session) => void flushed.push(`global:${session.id}`)) scope.ctx.on('session/flush', (session: Session) => void flushed.push(`owner:${session.id}`)) - const detached = ctx.sessions.prepare() - await ctx.sessions.flush(detached) - expect(flushed).toEqual([`global:${detached.id}`]) + const prepared = ctx.sessions.prepare() + await expect(ctx.sessions.flush(prepared)).rejects.toThrow(/not live/) + expect(flushed).toEqual([]) + }) + + it('clears a detached carrier and rejects stale flushes', async () => { + const ctx = await mount() + const scope = await mintScope(ctx, 'owner') + const flushed: string[] = [] + ctx.on('session/flush', (session: Session) => void flushed.push(`global:${session.id}`)) + scope.ctx.on('session/flush', (session: Session) => void flushed.push(`owner:${session.id}`)) + + const session = scope.ctx.sessions.prepare() + const detach = scope.ctx.sessions.enter(session) + await ctx.sessions.flush(session) + expect(flushed.sort()).toEqual([`global:${session.id}`, `owner:${session.id}`]) + + detach() + await expect(ctx.sessions.flush(session)).rejects.toThrow(/not live/) + expect(flushed).toHaveLength(2) }) it('keyOf sanity: distinct scopes carry distinct keys', async () => { diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index f338b635f3..f63353af9b 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -289,6 +289,7 @@ describe('SessionStore', () => { expect(created).toEqual([session]) // The detach disposer removes the entry + stops notification. detach() + detach() // idempotent: cannot disturb a later same-id lifecycle expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined() }) diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index f89370447f..6d3ed3af58 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -1,8 +1,9 @@ /** * System prompt assembly registry. Plugins contribute ordered text sections, - * tool schema providers, and named prompt variables; `assemble(context)` - * collates them through a waterfall that runs once per step, and - * `renderPrompt` interpolates `{{variable}}` references into the final text. + * tool schema providers, named prompt variables, and authoritative named + * protections; `assemble(context)` collates them through a waterfall that + * runs once per step, restores protected contributions, and `renderPrompt` + * interpolates `{{variable}}` references into the final text. * * The harness-owned prompt openers live here too: this plugin registers the * static `harness:identity` section (order −100) and the deployment's @@ -43,8 +44,8 @@ declare module 'cordis' { */ 'system-prompt/assemble'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise /** - * A section, tool provider, or variable provider was registered or - * unregistered (the assembly inputs changed — possibly for one scope + * 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 @@ -121,6 +122,27 @@ export interface ToolProviderResult { knownNames?: readonly string[] } +/** + * Canonical prompt contributions that survive the assembly waterfall. + * + * Protection is declarative by contribution name rather than an ordered + * callback: after every `system-prompt/assemble` listener has finished, the + * service restores each protected name to the exact presence and definition + * produced by its registries before the waterfall. Restored entries keep + * canonical order with one another and anchor before their first surviving + * later unprotected canonical neighbor (or at the end); the service does not + * undo a listener's reordering of unprotected entries. A name absent from that + * canonical assembly is removed from the result. This makes mode-dependent + * absence protectable too (for example, a native tool that intentionally stays + * off the wire in Code Mode). + */ +export interface PromptProtection { + /** Section names whose canonical registry output is authoritative. */ + sections?: readonly string[] + /** Tool names whose canonical provider output is authoritative. */ + tools?: readonly string[] +} + /** * The assembled prompt. * @@ -211,6 +233,28 @@ function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined, knownN name === TOOL_ORDER_REST ? rest : tools.filter(tool => tool.name === name)) } +/** Restore protected named entries from `canonical`, anchored before their next unprotected canonical neighbor. */ +function restoreProtected( + canonical: readonly T[], result: readonly T[], protectedNames: ReadonlySet, +): T[] { + const restored = result.filter(entry => !protectedNames.has(entry.name)) + for (const [index, entry] of canonical.entries()) { + if (!protectedNames.has(entry.name)) continue + // Protected entries are inserted in canonical order. Anchor each one + // before the first later UNPROTECTED canonical neighbor that survived the + // waterfall; if none survived, it belongs at the end. Looking only at + // unprotected neighbors avoids reversing adjacent protected entries. + const following = new Set( + canonical.slice(index + 1) + .filter(candidate => !protectedNames.has(candidate.name)) + .map(candidate => candidate.name), + ) + const next = restored.findIndex(candidate => following.has(candidate.name)) + restored.splice(next < 0 ? restored.length : next, 0, structuredClone(entry)) + } + return restored +} + /** Lexicographic (code-unit) name comparison — locale-independent, so the order is identical on every machine. */ function compareToolNames(a: ToolSchema, b: ToolSchema): number { return a.name < b.name ? -1 : a.name > b.name ? 1 : 0 @@ -327,10 +371,10 @@ function interpolate(section: AssembledSection, variables: Record = z.object({ @@ -347,10 +391,12 @@ export class SystemPrompt extends Service { private sections: PromptSection[] = [] private toolProviders: ((context: AssembleContext) => ToolProviderResult)[] = [] private variableProviders = new Map string | undefined>() + private protections: PromptProtection[] = [] /** Per-scope layers (`@deepseek-ai/dsh-scope`); entries drop when a layer empties, so a disposed scope leaves no residue. */ private scopedSections = new Map() private scopedToolProviders = new Map ToolProviderResult)[]>() private scopedVariableProviders = new Map string | undefined>>() + private scopedProtections = new Map() private readonly toolOrder: string[] | undefined constructor(ctx: Context, public config: Config) { @@ -383,7 +429,12 @@ export class SystemPrompt extends Service { * scoped context (`agent.ctx`) contributes to that scope alone — and a * scoped section SHADOWS a same-named global section for that scope's * assemblies (most-specific-wins; this is how a per-agent persona overrides - * `deployment:persona`). Throws if the SAME layer already has the name (a + * `deployment:persona`) unless that global name is protected: global + * protection reserves its section name against scoped shadows so the + * registration owner—not a later scope—defines the canonical value. The + * registry snapshots `name`, `order`, and `text` before checking/storing, so + * later caller-object mutation cannot rename a contribution. Throws + * if the SAME layer already has the name (a * duplicate would silently double prompt text — e.g. a double-loaded tool * plugin; the global-duplicate message names `agent.ctx` as the per-agent * alternative). Removed when the calling fiber is disposed. Emits @@ -395,6 +446,14 @@ export class SystemPrompt extends Service { */ section(section: PromptSection): () => Promise | void { const scope = scopeOf(this.ctx) + const snapshot: PromptSection = { + name: section.name, + order: section.order, + text: section.text, + } + if (scope !== undefined && this.protections.some(record => record.sections?.includes(snapshot.name))) { + throw new Error(`prompt section "${snapshot.name}" is globally protected and cannot be shadowed in an agent scope`) + } const dispose = this.ctx.effect(function* (this: SystemPrompt) { const layer = scope === undefined ? this.sections @@ -403,18 +462,18 @@ export class SystemPrompt extends Service { this.scopedSections.set(scope, created) return created })() - if (layer.some(existing => existing.name === section.name)) { + if (layer.some(existing => existing.name === snapshot.name)) { throw new Error(scope === undefined - ? `prompt section "${section.name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)` - : `prompt section "${section.name}" is already registered in this scope`) + ? `prompt section "${snapshot.name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)` + : `prompt section "${snapshot.name}" is already registered in this scope`) } - layer.push(section) + layer.push(snapshot) // Yield the rollback BEFORE emitting `system-prompt/change`: a generator // effect collects each yielded disposer before the next step runs, so a // throwing change listener removes the section instead of leaking it into // every future assembly. yield () => { - const index = layer.indexOf(section) + const index = layer.indexOf(snapshot) /* v8 ignore next 3 -- defensive: section was registered, so indexOf is guaranteed >= 0 */ if (index >= 0) layer.splice(index, 1) if (scope !== undefined && layer.length === 0) this.scopedSections.delete(scope) @@ -531,6 +590,73 @@ export class SystemPrompt extends Service { return dispose } + /** + * Protect named section/tool contributions from the assembly waterfall. + * The layer is decided by the calling context: a global protection applies + * to every assembly, while one registered through `agent.ctx` applies only + * to that agent's scope. The name's canonical registry/provider output is + * restored AFTER the whole waterfall, so listener registration order cannot + * strip, replace, duplicate, or fabricate it. Canonical absence is restored + * too: if the protected name is intentionally absent for an assembly, a + * listener-injected entry with that name is removed. The input arrays are + * snapshotted; an empty protection throws because it cannot affect output. + * Removed with the calling fiber and emits `system-prompt/change` on + * registration/unregistration. A global section protection also reserves the + * name against scoped section shadows; registering protection when such a + * shadow already exists fails loudly instead of protecting the wrong owner. + * @param protection - section and/or tool names whose canonical presence and definitions are authoritative. + * @returns the exact Cordis effect disposer that removes the protection. + */ + protect(protection: PromptProtection): () => Promise | void { + const scope = scopeOf(this.ctx) + const snapshot: PromptProtection = { + ...protection.sections !== undefined ? { sections: [...new Set(protection.sections)] } : {}, + ...protection.tools !== undefined ? { tools: [...new Set(protection.tools)] } : {}, + } + if ((snapshot.sections?.length ?? 0) === 0 && (snapshot.tools?.length ?? 0) === 0) { + throw new Error('systemPrompt.protect() requires at least one section or tool name') + } + if (scope === undefined && snapshot.sections !== undefined) { + const protectedSections = new Set(snapshot.sections) + const conflicts = [...this.scopedSections.values()] + .flatMap(layer => layer.filter(section => protectedSections.has(section.name)).map(section => section.name)) + if (conflicts.length > 0) { + throw new Error(`systemPrompt.protect() cannot globally protect section${conflicts.length > 1 ? 's' : ''} ${[...new Set(conflicts)].map(name => `"${name}"`).join(', ')} while scoped shadows are registered`) + } + } + const dispose = this.ctx.effect(function* (this: SystemPrompt) { + const layer = scope === undefined + ? this.protections + : this.scopedProtections.get(scope) ?? (() => { + const created: PromptProtection[] = [] + this.scopedProtections.set(scope, created) + return created + })() + layer.push(snapshot) + yield () => { + const index = layer.indexOf(snapshot) + /* v8 ignore next 3 -- defensive: protection was registered, so indexOf is guaranteed >= 0 */ + if (index >= 0) layer.splice(index, 1) + if (scope !== undefined && layer.length === 0) this.scopedProtections.delete(scope) + this.ctx.emit('system-prompt/change') + } + this.ctx.emit('system-prompt/change') + }.bind(this), 'systemPrompt.protect()') + return dispose + } + + /** Resolve the authoritative names registered for one assembly scope. */ + private protectedNames(scope: ScopeKey | undefined): { sections: Set; tools: Set } { + const records = [ + ...this.protections, + ...(scope === undefined ? [] : this.scopedProtections.get(scope)) ?? [], + ] + return { + sections: new Set(records.flatMap(record => record.sections ?? [])), + tools: new Set(records.flatMap(record => record.tools ?? [])), + } + } + /** * Assemble the current prompt for one caller: the global layer merged with * {@link AssembleContext.scope}'s layer (scoped sections/variables SHADOW @@ -546,10 +672,11 @@ export class SystemPrompt extends Service { * Tool schemas are deep-cloned because adapters and request waterfalls may * mutate schema objects. Runs through the `system-prompt/assemble` * waterfall, giving listeners the opportunity to mutate or replace the - * assembly before it reaches the model — like the sections' `order` sort, - * tool canonicalization happens on the initial assembly, and a listener - * owns the determinism of whatever it emits. Await the result before - * reading the assembly values — waterfall listeners may be async. + * assembly, then restores every visible {@link PromptProtection} from the + * pre-waterfall canonical assembly. Like the sections' `order` sort, tool + * canonicalization happens on the initial assembly; unprotected listener + * output owns its own determinism. Await the result before reading the + * assembly values — waterfall listeners may be async. * Interpolation happens later, in {@link renderPrompt}. * @param context - what this assembly is for (defaults to an empty context; * see {@link AssembleContext}). @@ -560,6 +687,10 @@ export class SystemPrompt extends Service { // (`assemble().catch(...)` would miss it). async assemble(context: AssembleContext = {}): Promise { const scope = context.scope + // Protection is a registry input too: snapshot which names are protected + // at assembly start. Registrations that land while an async waterfall is + // in flight affect the NEXT assembly, matching the other registries. + const protectedNames = this.protectedNames(scope) // Variables: global layer first, then the scope's layer OVERWRITES // same-named entries (shadowing — a per-agent value wins for that agent). const variables: Record = {} @@ -611,7 +742,27 @@ export class SystemPrompt extends Service { tools: orderTools(collected, this.toolOrder, knownNames), variables, } - return this.ctx.waterfall(scopeTarget(this, scope), 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly)) + // Snapshot only the fields protection can restore. The waterfall receives + // `assembly` by reference and may mutate it or return a replacement; these + // independent snapshots remain the authoritative registry product. + const canonicalSections = protectedNames.sections.size > 0 ? structuredClone(assembly.sections) : undefined + const canonicalTools = protectedNames.tools.size > 0 ? structuredClone(assembly.tools) : undefined + const result = await this.ctx.waterfall( + scopeTarget(this, scope), 'system-prompt/assemble', assembly, context, + () => Promise.resolve(assembly), + ) + // Build a replacement instead of mutating the waterfall result: a + // listener may legitimately return a frozen assembly. Merge-extensible + // fields ride through the spread untouched. + return { + ...result, + ...canonicalSections !== undefined + ? { sections: restoreProtected(canonicalSections, result.sections, protectedNames.sections) } + : {}, + ...canonicalTools !== undefined + ? { tools: restoreProtected(canonicalTools, result.tools, protectedNames.tools) } + : {}, + } } } diff --git a/packages/core/system-prompt/tests/scoped.spec.ts b/packages/core/system-prompt/tests/scoped.spec.ts index 729ec52b1c..d59e3a234a 100644 --- a/packages/core/system-prompt/tests/scoped.spec.ts +++ b/packages/core/system-prompt/tests/scoped.spec.ts @@ -62,6 +62,21 @@ describe('scoped sections', () => { scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'a' }) expect(() => scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'b' })).toThrow(/already registered in this scope/) }) + + it.each([ + [['reserved'], 'section "reserved"'], + [['first', 'second'], 'sections "first", "second"'], + ])('rejects global protection added after scoped shadows (%j)', async (names, message) => { + const ctx = await mount() + const scope = await mintScope(ctx, 'child') + for (const name of names) { + scope.ctx.systemPrompt.section({ name, order: 1, text: `scoped ${name}` }) + } + + expect(() => ctx.systemPrompt.protect({ sections: names })).toThrow(message) + expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }))) + .toContain(`scoped ${names[0]}`) + }) }) describe('scoped variables', () => { @@ -148,4 +163,31 @@ describe('scoped assemble dispatch', () => { expect(global.sections.some(s => s.name === 'listener:extra')).toBe(false) expect(shaped).toHaveLength(1) }) + + it('a scoped protection finalizes only its own assemblies and disappears with the scope', async () => { + const ctx = await mount() + const scope = await mintScope(ctx, 'child') + const key = scopeKeyOf(scope) + ctx.systemPrompt.section({ name: 'required', order: 10, text: 'required' }) + ctx.systemPrompt.tools(() => ({ schemas: [schema('required')] })) + scope.ctx.systemPrompt.protect({ sections: ['required'], tools: ['required'] }) + ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { + const result = await next() + result.sections = result.sections.filter(section => section.name !== 'required') + result.tools = result.tools.filter(tool => tool.name !== 'required') + return result + }, { prepend: true }) + + const scoped = await ctx.systemPrompt.assemble({ scope: key }) + const global = await ctx.systemPrompt.assemble() + expect(scoped.sections.some(section => section.name === 'required')).toBe(true) + expect(scoped.tools.some(tool => tool.name === 'required')).toBe(true) + expect(global.sections.some(section => section.name === 'required')).toBe(false) + expect(global.tools.some(tool => tool.name === 'required')).toBe(false) + + await scope.dispose() + const disposed = await ctx.systemPrompt.assemble({ scope: key }) + expect(disposed.sections.some(section => section.name === 'required')).toBe(false) + expect(disposed.tools.some(tool => tool.name === 'required')).toBe(false) + }) }) diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index 3d4cb9d02c..95d46b705e 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -205,6 +205,103 @@ describe('SystemPrompt', () => { expect(assembly.sections).toHaveLength(0) }) + describe('canonical contribution protection', () => { + it('restores exact protected definitions after every listener, in canonical relative order', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + ctx.systemPrompt.section({ name: 'before', order: 10, text: 'before' }) + ctx.systemPrompt.section({ name: 'protected', order: 20, text: 'canonical section' }) + ctx.systemPrompt.section({ name: 'after', order: 30, text: 'after' }) + ctx.systemPrompt.tools(() => ({ schemas: [ + { name: 'alpha', description: 'alpha', parameters: {} }, + { name: 'protected', description: 'canonical tool', parameters: { type: 'object', properties: { answer: { type: 'number' } } } }, + { name: 'zulu', description: 'zulu', parameters: {} }, + ] })) + const protection = { sections: ['protected'], tools: ['protected'] } + ctx.systemPrompt.protect(protection) + // Registration snapshots its arrays; caller mutation cannot change what + // the service makes authoritative. + protection.sections[0] = 'after' + protection.tools[0] = 'zulu' + + // Registered AFTER the protection and prepended: it is outside every + // ordinary listener that existed when protect() ran, but service-level + // finalization still restores the canonical entries after it returns. + ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { + const result = await next() + return Object.freeze({ + sections: [ + ...result.sections.filter(section => section.name !== 'protected'), + { name: 'protected', order: -999, text: 'wrong section' }, + { name: 'protected', order: 999, text: 'duplicate section' }, + ], + tools: [ + ...result.tools.filter(tool => tool.name !== 'protected'), + { name: 'protected', description: 'wrong tool', parameters: {} }, + { name: 'protected', description: 'duplicate tool', parameters: {} }, + ], + variables: result.variables, + }) + }, { prepend: true }) + + const assembly = await ctx.systemPrompt.assemble() + const protectedSections = assembly.sections.filter(section => section.name === 'protected') + const protectedTools = assembly.tools.filter(tool => tool.name === 'protected') + expect(protectedSections).toEqual([{ name: 'protected', order: 20, text: 'canonical section' }]) + expect(protectedTools).toEqual([{ + name: 'protected', + description: 'canonical tool', + parameters: { type: 'object', properties: { answer: { type: 'number' } } }, + }]) + expect(assembly.sections.map(section => section.name).indexOf('protected')) + .toBeLessThan(assembly.sections.map(section => section.name).indexOf('after')) + expect(assembly.tools.map(tool => tool.name)).toEqual(['alpha', 'protected', 'zulu']) + }) + + it('protects canonical absence and rejects an empty protection', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + // Separate registrations exercise the set-union contract: protections + // may name only sections or only tools and still compose. + ctx.systemPrompt.protect({ sections: ['mode-hidden'] }) + ctx.systemPrompt.protect({ tools: ['mode-hidden'] }) + ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { + const result = await next() + result.sections.push({ name: 'mode-hidden', order: 100, text: 'fabricated' }) + result.tools.push({ name: 'mode-hidden', description: 'fabricated', parameters: {} }) + return result + }) + + const assembly = await ctx.systemPrompt.assemble() + expect(assembly.sections.some(section => section.name === 'mode-hidden')).toBe(false) + expect(assembly.tools.some(tool => tool.name === 'mode-hidden')).toBe(false) + expect(() => ctx.systemPrompt.protect({})).toThrow(/at least one section or tool name/) + expect(() => ctx.systemPrompt.protect({ sections: [], tools: [] })).toThrow(/at least one section or tool name/) + }) + + it('removes a protection with its contributing fiber (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + ctx.systemPrompt.section({ name: 'protected', order: 10, text: 'canonical' }) + ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { + const result = await next() + result.sections = result.sections.filter(section => section.name !== 'protected') + return result + }) + let changes = 0 + ctx.on('system-prompt/change', () => { changes++ }) + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.systemPrompt.protect({ sections: ['protected'] }) + }, { inject: ['systemPrompt'] })) + + expect((await ctx.systemPrompt.assemble()).sections.some(section => section.name === 'protected')).toBe(true) + expect(changes).toBe(1) + await fiber.dispose() + expect((await ctx.systemPrompt.assemble()).sections.some(section => section.name === 'protected')).toBe(false) + expect(changes).toBe(2) + }) + }) + it('assembles snapshots so one-step mutations do not leak into future assemblies', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index c985eb852c..323fbeed2b 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -1,12 +1,14 @@ /** * Code Mode: the `run_code` tool and its dispatch bridge. The model writes a - * TypeScript program; the bridge hands it to `ctx.codeRuntime` with one - * async binding per registered tool, serializes every binding call through a - * per-run queue onto `ToolRegistry.execute()` (so `tools/pre-execute` / - * `tools/post-execute` gate sub-calls exactly like native ones), logs each - * sub-dispatch as a `tool/code-dispatch` session event, and returns only the - * program's curated output. The registry itself decides WHEN this tool - * exists (its `mode` config); this module owns only the tool and the bridge. + * TypeScript program; the bridge hands it to `ctx.codeRuntime` with one async + * binding per end capability visible to the calling agent, then serializes + * every binding call through a per-run queue onto `ToolRegistry.execute()`. + * Sub-calls therefore traverse the complete pre/guard/around/post/final-result + * pipeline exactly like native calls and carry the outer execution's opaque + * token for correlation. The bridge logs each sub-dispatch as a + * `tool/code-dispatch` session event and returns only the program's curated + * output. The registry itself decides WHEN this tool exists (its `mode` + * config); this module owns only the tool and the bridge. * * @module @deepseek-ai/dsh-tools/src/code-mode */ @@ -205,6 +207,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => name, arguments: normalized.dispatched, ...exec.agent ? { agent: exec.agent } : {}, + parent: exec.token, signal: runController.signal, }) const text = textOf(result.content) diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index c3b708ecb8..0428eaef9c 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -1,15 +1,15 @@ /** * Tool registry and execution pipeline. Plugins register tools; the registry * feeds schemas into the system prompt, and `execute()` dispatches each call - * through `tools/pre-execute` (the allow/deny gate) → `tools/execute` (an - * around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute` - * (inspect/replace the result, attach context) for sandbox, permission, and hook - * plugins to gate or transform a call. + * through `tools/pre-execute` (the extensible allow/deny gate) → monotonic + * registered guards → `tools/execute` (an around-dispatch wrapper for + * timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the + * result, attach context) → the observe-only `tools/result` notification. * * The registry also owns HOW its tools are presented to the model — its * `mode` config: `'native'` (every tool as a wire function definition, - * today's behavior and the default), `'code'` (the wire carries exactly one - * tool, `run_code`, plus a generated TypeScript SDK prompt section), or + * today's behavior and the default), `'code'` (the registry's canonical wire + * contribution is one tool, `run_code`, plus a generated TypeScript SDK prompt section), or * `'both'`. See `code-mode.ts` (the tool + dispatch bridge) and * `ts-types.ts` (the SDK codegen); design in the Code Mode RFC. * @@ -21,8 +21,9 @@ import z from 'schemastery' import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope' import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm' import type { Agent, HookContext } from '@deepseek-ai/dsh-agent' +import { isJsonValue } from '@deepseek-ai/dsh-session' import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt' import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { ToolCallView, ToolResultView } from './presentation.ts' @@ -105,10 +106,13 @@ declare module 'cordis' { * 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 - * mutate `exec` (e.g. replace `exec.signal` with a per-call deadline) BEFORE - * `next()` and inspect the result AFTER. (Cordis `next()` ignores any passed - * arguments and re-invokes downstream with the shared payload, so a wrapper - * mutates `exec` in place rather than passing a new object to `next()`.) + * set or replace the one mutable field, `exec.signal` (e.g. with a per-call + * deadline), BEFORE `next()`, restore/delete it afterward, and inspect the result AFTER. Call identity + * (`token`, `callId`, `name`, `arguments`, `agent`, and `parent`) is immutable throughout the + * pipeline so a wrapper cannot change which capability or scope was + * authorized. (Cordis `next()` ignores passed arguments and re-invokes + * downstream with the shared payload, so a wrapper changes `exec.signal` in + * place rather than passing a new object to `next()`.) * Multiple listeners compose by registration order — an outer one wraps the * inner ones plus dispatch. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by @@ -139,6 +143,21 @@ declare module 'cordis' { * @mode waterfall */ 'tools/post-execute'(this: Scoped, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise): Promise + /** + * 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 + * {@link ToolRegistry.execute} still returns the outcome. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by + * `exec.agent`, using the same carrier as the pipeline. + * @param exec - the execution object that traversed the pipeline. + * @param result - a deep-frozen snapshot of the final returned result. + * @mode parallel + */ + 'tools/result'(this: Scoped, exec: Readonly, result: Readonly): Promise | void /** * A tool was registered or unregistered, or a scoped restriction changed * (the available tool set changed — possibly for one scope only). An @@ -152,10 +171,8 @@ declare module 'cordis' { } } -// TODO(review): revisit these shapes when the first real tools and -// sandbox/permission plugins land (e.g. a concurrency-safety hint for -// parallel execution — Claude Code partitions read-only tools; phase 1 -// executes sequentially). +// TODO(review): revisit these shapes when concurrency metadata becomes useful +// (for example, a read-only hint that would permit safe parallel execution). /** * What a tool's `execute` returns. The bare {@link ContentBlock}`[]` form is the @@ -214,17 +231,54 @@ export interface ToolResult { meta?: unknown } -/** One pending tool call, as it flows through the execution pipeline (`tools/pre-execute` → dispatch → `tools/post-execute`). */ -export interface ToolExecution { - callId: CallId - name: string - /** Parsed JSON arguments (unknown — tools validate their own input). */ - arguments: unknown +declare const toolExecutionTokenBrand: unique symbol + +/** Tokens minted in this module; a cast or JavaScript object cannot forge membership. */ +const executionTokens = new WeakSet() + +/** + * Opaque, immutable identity for one trip through the tool pipeline. Nested + * transports carry the enclosing execution's token instead of its live object, + * so observe-only result listeners can correlate calls without gaining a + * mutation path into an outer around-dispatch wrapper. + */ +export interface ToolExecutionToken { + readonly [toolExecutionTokenBrand]: true +} + +/** + * Caller-supplied description of one tool call. {@link ToolRegistry.execute} + * snapshots this input into a pipeline-owned {@link ToolExecution}; callers do + * not choose the execution token. + */ +export interface ToolExecutionInput { + readonly callId: CallId + readonly name: string + /** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */ + readonly arguments: unknown /** The agent on whose behalf the call runs (set by the agent loop). */ - agent?: Agent + readonly agent?: Agent + /** + * Opaque token of the enclosing transport execution, when one exists. Code + * Mode sets this on SDK sub-dispatches so commit-style observers can wait for + * the outer `run_code` outcome without receiving its live mutable execution. + */ + readonly parent?: ToolExecutionToken signal?: AbortSignal } +/** + * One pending tool call inside the registry pipeline. Call identity, the + * registry-assigned {@link token}, and a lossless-JSON-validated, deep-frozen + * clone of the parsed arguments are immutable from the first policy listener onward, while an + * around-dispatch wrapper may set, replace, or remove only `signal`. The + * registry freezes the complete object before `tools/result` observers run. + */ +export interface ToolExecution extends ToolExecutionInput { + /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ + readonly token: ToolExecutionToken +} + /** Structured error metadata for a failed tool call (alongside the model-facing text). */ export interface ToolErrorInfo { name: string @@ -255,7 +309,6 @@ export interface ToolExecutionResult { * text in `content` is always present; this is extra structure for code. */ error?: ToolErrorInfo - /** /** * Extra model-facing context a `tools/post-execute` listener attached for the * NEXT request (Claude Code's PostToolUse `additionalContext`). It is NOT part @@ -318,17 +371,28 @@ export type PostToolDecision = * is stringified. */ function errorMessage(error: unknown): string { - if (error instanceof Error) return error.message - if (typeof error === 'object' && error !== null - && 'message' in error && typeof error.message === 'string') { - return error.message + try { + if (error instanceof Error) return error.message + if (typeof error === 'object' && error !== null + && 'message' in error && typeof error.message === 'string') { + return error.message + } + return String(error) + } catch { + // A hostile thrown value can trap `instanceof`, property access, or string + // coercion. Error normalization is the outermost safety boundary, so its + // fallback must itself be total. + return '' } - return String(error) } /** Structured `{ name, code }` for a thrown HarnessError, else undefined. */ function errorInfo(error: unknown): ToolErrorInfo | undefined { - return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined + try { + return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined + } catch { + return undefined + } } /** How the registry presents its tools to the model (see {@link Config.mode}). */ @@ -338,9 +402,9 @@ export type ToolPresentationMode = 'native' | 'code' | 'both' export interface Config { /** * The presentation mode. `'native'` (the default) contributes every - * registered tool as a wire function definition — byte-for-byte today's - * behavior. `'code'` contributes exactly ONE wire tool, `run_code`, plus - * the generated `tools:sdk` prompt section declaring every other tool as a + * 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 @@ -371,11 +435,27 @@ export interface ToolRestriction { deny?: string[] } +/** + * A monotonic execution guard evaluated after every `tools/pre-execute` + * listener and before the tool body. Returning a reason denies the call; + * returning `undefined` leaves it unchanged. Because guards have no allow + * result, listener ordering cannot turn a denial back into permission. + * @param execution - the identity-protected call after extensible pre-execute policy completed. + * @returns a final denial reason, or `undefined` to leave the call allowed. + */ +export type ToolGuard = (execution: Readonly) => string | undefined + +/** One guard registration; the wrapper preserves independent duplicate registrations. */ +interface ToolGuardRegistration { + guard: ToolGuard +} + /** * Tool registry (`ctx.tools`): tool plugins register definitions; the agent - * loop executes calls through the `tools/pre-execute` → `tools/execute` → - * `tools/post-execute` pipeline. The registry contributes its schemas into the - * system-prompt assembly — WHICH schemas is governed by its `mode` config + * 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 {@link Config.mode}); under a non-native mode it also owns the reserved * `run_code` presentation transport and the `tools:sdk` prompt section. * @@ -402,6 +482,9 @@ export class ToolRegistry extends Service { private scoped = new Map>() /** Snapshot-at-registration restriction filters, per scope (see {@link restrict}). */ private restrictions = new Map() + /** Monotonic post-policy guards, split into global and per-agent layers. */ + private globalGuards = new Set() + private scopedGuards = new Map>() private readonly mode: ToolPresentationMode /** Reserved presentation transport, kept outside the filterable registration layers. */ private readonly codeTransport: ToolDefinition | undefined @@ -418,7 +501,7 @@ export class ToolRegistry extends Service { // the filterable global/scoped capability layers. this.codeTransport = this.mode === 'native' ? undefined - : createRunCodeTool(this, () => this.requireCodeRuntime()) + : deepFreeze(createRunCodeTool(this, () => this.requireCodeRuntime())) ctx.systemPrompt.tools(context => this.wireSchemas(context.scope)) if (this.mode !== 'native') { ctx.systemPrompt.section({ @@ -436,6 +519,11 @@ export class ToolRegistry extends Service { return renderToolsSdk(this.schemas(context.scope).filter(schema => schema.name !== RUN_CODE_NAME)) }, }) + // These are presentation infrastructure, not optional end capabilities. + // Protect them at their owner: assembly listeners may still transform + // ordinary tools and prose, but cannot silently leave Code Mode without + // its only wire transport or the SDK that tells the model how to use it. + ctx.systemPrompt.protect({ sections: ['tools:sdk'], tools: [RUN_CODE_NAME] }) } } @@ -495,8 +583,12 @@ export class ToolRegistry extends Service { * the shadowing feature, not an error; the global-duplicate message names * `agent.ctx` as the per-agent alternative), or if a non-native mode reserves * the `run_code` name for its presentation transport. The visible schema set - * flows into prompt assembly automatically. Disposed with the calling - * fiber. Emits `tools/change` on register/unregister. + * flows into prompt assembly automatically. Registration validates and + * clones the JSON parameters, copies scalar fields, binds each callback once + * to the caller's definition as its method receiver, and freezes the stored + * snapshot; later mutation or callback replacement on the input object does + * not rewrite the registry. Disposed with the calling fiber. Emits + * `tools/change` on register/unregister. * @param definition - the tool's schema plus its execute (and optional * presentation) functions. * @returns the disposer that unregisters the tool. The exact @@ -505,24 +597,52 @@ export class ToolRegistry extends Service { */ register(definition: ToolDefinition): () => Promise | void { const scope = scopeOf(this.ctx) - if (this.codeTransport !== undefined && definition.name === RUN_CODE_NAME) { + // A schema crosses the same model/log boundary as execution arguments. + // Validate BEFORE cloning because structuredClone silently turns some + // forbidden values (for example class instances) into plain records, then + // validate the detached value again to contain hostile getters that change + // between inspection and snapshotting. A frozen Map is still mutable, so + // deepFreeze alone is not a sufficient registration boundary. + if (!isJsonValue(definition.parameters)) { + throw new TypeError('tool parameters must be losslessly JSON-serializable') + } + const parameters = structuredClone(definition.parameters) + if (!isJsonValue(parameters)) { + throw new TypeError('tool parameters must be stable losslessly JSON-serializable data') + } + // Bind once so replacing a callback on the caller-owned definition after + // registration cannot change dispatch, while preserving the historical + // method receiver (`this === definition`) for callbacks that use it. + const execute = definition.execute.bind(definition) + const presentCall = definition.presentCall?.bind(definition) + const presentResult = definition.presentResult?.bind(definition) + const snapshot: ToolDefinition = deepFreeze({ + name: definition.name, + description: definition.description, + parameters, + execute, + ...definition.timeoutMs !== undefined ? { timeoutMs: definition.timeoutMs } : {}, + ...presentCall !== undefined ? { presentCall } : {}, + ...presentResult !== undefined ? { presentResult } : {}, + }) + if (this.codeTransport !== undefined && snapshot.name === RUN_CODE_NAME) { throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the Code Mode presentation transport and cannot be registered or shadowed`) } const dispose = this.ctx.effect(function* (this: ToolRegistry) { const layer = scope === undefined ? this.global : this.layerFor(scope) - if (layer.has(definition.name)) { + if (layer.has(snapshot.name)) { throw new Error(scope === undefined - ? `tool "${definition.name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)` - : `tool "${definition.name}" is already registered in this scope`) + ? `tool "${snapshot.name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)` + : `tool "${snapshot.name}" is already registered in this scope`) } - layer.set(definition.name, definition) + layer.set(snapshot.name, snapshot) // Yield the rollback BEFORE emitting `tools/change`: a generator effect // collects each yielded disposer before the next step runs, so a throwing // `tools/change` listener removes the tool instead of leaking it (a leak // would wedge the duplicate-name check until restart). The duplicate // throw above fires before any mutation — it leaks nothing. yield () => { - layer.delete(definition.name) + layer.delete(snapshot.name) // An emptied scope layer is dropped so a disposed scope leaves no // residue keyed by its (dead) key. if (scope !== undefined && layer.size === 0) this.scoped.delete(scope) @@ -604,6 +724,30 @@ export class ToolRegistry extends Service { return dispose } + /** + * Register a monotonic guard after the extensible `tools/pre-execute` + * waterfall. A plain-context guard applies globally; one registered through + * `agent.ctx` applies only to that agent. Any matching guard may deny by + * returning a reason, while no guard can force-allow a call another guard + * denied. The exact effect disposer is returned for ordered ownership and + * HMR cleanup. + * @param guard - synchronous check; a returned string denies the execution. + * @returns the exact disposer that unregisters the guard. + */ + guard(guard: ToolGuard): () => Promise | void { + const scope = scopeOf(this.ctx) + const registration = { guard } + const dispose = this.ctx.effect(function* (this: ToolRegistry) { + const layer = scope === undefined ? this.globalGuards : this.guardLayerFor(scope) + layer.add(registration) + yield () => { + layer.delete(registration) + if (scope !== undefined && layer.size === 0) this.scopedGuards.delete(scope) + } + }.bind(this), 'tools.guard()') + return dispose + } + /** The (created-on-demand) scoped layer for `scope`. */ private layerFor(scope: ScopeKey): Map { let layer = this.scoped.get(scope) @@ -614,6 +758,43 @@ export class ToolRegistry extends Service { return layer } + /** Get or create the guard layer for one agent scope. */ + private guardLayerFor(scope: ScopeKey): Set { + let layer = this.scopedGuards.get(scope) + if (layer === undefined) { + layer = new Set() + this.scopedGuards.set(scope, layer) + } + return layer + } + + /** First monotonic denial from the global then matching scoped guard layers. */ + private guardReason(exec: ToolExecution): string | undefined { + // Guards are policy, not another transform seam. The pipeline execution's + // identity and arguments are already protected; freeze a detached view so + // an untyped guard cannot replace the wrapper-mutable signal either. + const view: Readonly = Object.freeze({ ...exec }) + for (const { guard } of this.globalGuards) { + const reason = guard(view) + if (reason !== undefined) return this.assertGuardReason(reason) + } + if (exec.agent !== undefined) { + for (const { guard } of this.scopedGuards.get(exec.agent) ?? []) { + const reason = guard(view) + if (reason !== undefined) return this.assertGuardReason(reason) + } + } + return undefined + } + + /** Runtime boundary for JavaScript/casted guards: only strings can deny. */ + private assertGuardReason(reason: unknown): string { + if (typeof reason !== 'string') { + throw new TypeError(`tools.guard() must return a denial string or undefined, got ${typeof reason}`) + } + return reason + } + /** Whether every restriction registered for `scope` admits the global tool `name` (intersection semantics). */ private admits(scope: ScopeKey | undefined, name: string): boolean { if (scope === undefined) return true @@ -707,8 +888,9 @@ export class ToolRegistry extends Service { } /** - * Execute one tool call through the `tools/pre-execute` → `tools/execute` - * (around dispatch) → `tools/post-execute` pipeline. `pre-execute` is the gate + * Execute one tool call through the `tools/pre-execute` → guards → + * `tools/execute` (around dispatch) → `tools/post-execute` → `tools/result` + * pipeline. `pre-execute` is the extensible gate * (allow/deny), `tools/execute` wraps core dispatch (a timeout/retry/metrics * seam), and `post-execute` is the inspect/transform seam; core dispatch sits * as the base `next()` of the `tools/execute` waterfall. The whole thing is @@ -719,74 +901,177 @@ export class ToolRegistry extends Service { * tool is not registered (or not visible to the calling agent — a * restricted-away global is exactly as absent as a nonexistent one), the * result is an `isError` carrying a `UNKNOWN_TOOL` structured error. A thrown - * {@link HarnessError} surfaces its `{ name, code }` on the result. - * @param exec - the call to run (name, parsed arguments, caller agent, signal). + * {@link HarnessError} surfaces its `{ name, code }` on the result. Before + * the final observe-only notification, the authoritative outcome must survive + * a lossless JSON round trip; an invalid outcome is normalized to an error. + * Caller-owned arguments must survive lossless-JSON validation before and + * after cloning; a violation normalizes to an error before policy or dispatch. + * @param exec - the single-use call input; its identity is snapshotted and + * protected before policy runs. * @returns the final result after every waterfall; failures resolve as * `isError` results, never rejections. */ - async execute(exec: ToolExecution): Promise { + async execute(exec: ToolExecutionInput): Promise { + let execution: ToolExecution try { - // --- Gate: tools/pre-execute. A deny (or an ask, which degrades to deny - // until the permission system lands) skips dispatch entirely. The - // carrier keys the dispatch by exec.agent, so an `agent.ctx` listener - // gates only its own agent's calls (agent-less calls are subject-less). - const carrier = scopeTarget(this, exec.agent) - const decision = await this.ctx.waterfall( - carrier, 'tools/pre-execute', exec, - () => Promise.resolve({ kind: 'allow' }), - ) - if (decision.kind !== 'allow') { - // deny → isError. ask has no permission UI yet, so degrade to deny - // (FIXME(permissions)): a forthcoming permission system turns `ask` into - // a real prompt; today it is the conservative "not allowed". - const reason = decision.kind === 'deny' - ? decision.reason - : decision.reason ?? `tool "${exec.name}" requires approval (not yet supported)` - const denied: ToolExecutionResult = { - callId: exec.callId, - content: [{ type: 'text', text: `Error: ${reason}` }], - isError: true, - } - return await this.postExecute(exec, denied) - } - - // --- Around-dispatch: tools/execute. The base `next` is the dispatch- - // with-normalization thunk — the tool body's own try/catch turns a throw - // into an isError result so a wrapper (and post-execute) can inspect it; - // an unknown tool routes through the same catch. A `tools/execute` listener - // (e.g. a timeout plugin) wraps this thunk: it may mutate `exec` before - // delegating and inspect the normalized result after. Dispatched with the - // same carrier as the gate, so an `agent.ctx` wrapper wraps only its own - // agent's calls. --- - const result = await this.ctx.waterfall( - carrier, 'tools/execute', exec, - async (): Promise => { - try { - // Resolve through the CALLER's visible view ({@link get}): a scoped - // tool shadows its global name-twin for that agent, and a - // restricted-away global tool is exactly as absent as a nonexistent - // one — same UNKNOWN_TOOL result, no capability leak in the error. - const tool = this.get(exec.name, exec.agent) - if (!tool) throw new ToolNotFoundError(exec.name) - // Normalize the two `execute` return shapes: a bare ContentBlock[] (no - // meta) or a { content, meta } object (a tool attaching a private - // presentation payload). An array IS the content; the object carries it. - const returned = await tool.execute(exec.arguments, exec) - const content = Array.isArray(returned) ? returned : returned.content - const meta = Array.isArray(returned) ? undefined : returned.meta - return { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} } - } catch (error: unknown) { - return toolErrorResult(exec.callId, error) - } - }, - ) - - return await this.postExecute(exec, result) + execution = this.prepareExecution(exec) } catch (error: unknown) { - // Outer backstop: a throwing pre/post-execute listener (or the waterfall - // machinery) becomes an isError result, never a turn failure. - return toolErrorResult(exec.callId, error) + // Contract-violating non-JSON or non-cloneable arguments cannot enter a + // pipeline whose logged and executed forms must agree. Still publish one + // scoped final outcome, using an immutable identity shell, so result + // observers retain their every-call guarantee without seeing the invalid + // value. + execution = Object.freeze({ + token: createExecutionToken(), + callId: exec.callId, + name: exec.name, + arguments: undefined, + ...exec.agent !== undefined ? { agent: exec.agent } : {}, + ...isExecutionToken(exec.parent) ? { parent: exec.parent } : {}, + ...exec.signal !== undefined ? { signal: exec.signal } : {}, + }) + const result = toolErrorResult(execution.callId, error) + await this.notifyResult(execution, result) + return result } + let result: ToolExecutionResult + try { + // Validate the authoritative FINAL result, not merely the tool body's + // intermediate return. Post-policy may replace content or attach context, + // and every one of these fields is session-bound. Reject anything that + // cannot round-trip losslessly through the durable JSON log before the + // observe-only `tools/result` commit point sees success. + result = this.snapshotExecutionResult(execution, await this.executePipeline(execution)) + } catch (error: unknown) { + // Outer backstop: a throwing pre/post-execute listener, guard, or the + // waterfall machinery becomes an isError result, never a turn failure. + result = toolErrorResult(execution.callId, error) + } + await this.notifyResult(execution, result) + return result + } + + /** Snapshot one call into a shared pipeline object with immutable identity and mutable cancellation. */ + private prepareExecution(input: ToolExecutionInput): ToolExecution { + if (input.parent !== undefined && !isExecutionToken(input.parent)) { + throw new TypeError('tool execution parent must be a registry-minted opaque token') + } + if (!isJsonValue(input.arguments)) { + throw new TypeError('tool execution arguments must be losslessly JSON-serializable') + } + const args = structuredClone(input.arguments) + if (!isJsonValue(args)) { + throw new TypeError('tool execution arguments must be stable losslessly JSON-serializable data') + } + const execution: ToolExecution = { + token: createExecutionToken(), + callId: input.callId, + name: input.name, + arguments: deepFreeze(args), + ...input.agent !== undefined ? { agent: input.agent } : {}, + ...input.parent !== undefined ? { parent: input.parent } : {}, + ...input.signal !== undefined ? { signal: input.signal } : {}, + } + Object.defineProperties(execution, { + token: { value: execution.token, enumerable: true, writable: false, configurable: false }, + callId: { value: execution.callId, enumerable: true, writable: false, configurable: false }, + name: { value: execution.name, enumerable: true, writable: false, configurable: false }, + arguments: { value: execution.arguments, enumerable: true, writable: false, configurable: false }, + agent: { value: input.agent, enumerable: true, writable: false, configurable: false }, + parent: { value: input.parent, enumerable: true, writable: false, configurable: false }, + }) + if (input.signal !== undefined) { + Object.defineProperty(execution, 'signal', { + value: input.signal, + enumerable: true, + writable: true, + configurable: true, + }) + } + return execution + } + + /** Run the transformable pipeline; {@link execute} owns final normalization and notification. */ + private async executePipeline(exec: ToolExecution): Promise { + // --- Gate: tools/pre-execute. A deny (or an ask, which degrades to deny + // until the permission system lands) skips dispatch entirely. The + // carrier keys the dispatch by exec.agent, so an `agent.ctx` listener + // gates only its own agent's calls (agent-less calls are subject-less). + const carrier = scopeTarget(this, exec.agent) + const decision = await this.ctx.waterfall( + carrier, 'tools/pre-execute', exec, + () => Promise.resolve({ kind: 'allow' }), + ) + const denialReason = decision.kind === 'allow' + ? this.guardReason(exec) + : decision.kind === 'deny' + ? decision.reason + : decision.reason ?? `tool "${exec.name}" requires approval (not yet supported)` + if (denialReason !== undefined) { + // deny → isError. ask has no permission UI yet, so degrade to deny + // (FIXME(permissions)): a forthcoming permission system turns `ask` into + // a real prompt; today it is the conservative "not allowed". + const denied: ToolExecutionResult = { + callId: exec.callId, + content: [{ type: 'text', text: `Error: ${denialReason}` }], + isError: true, + } + return await this.postExecute(exec, denied) + } + + // --- Around-dispatch: tools/execute. The base `next` is the dispatch- + // with-normalization thunk — the tool body's own try/catch turns a throw + // into an isError result so a wrapper (and post-execute) can inspect it; + // an unknown tool routes through the same catch. A `tools/execute` listener + // (e.g. a timeout plugin) wraps this thunk: it may replace `exec.signal` + // before delegating and inspect the normalized result after. Dispatched with the + // same carrier as the gate, so an `agent.ctx` wrapper wraps only its own + // agent's calls. --- + const result = this.snapshotExecutionResult(exec, await this.ctx.waterfall( + carrier, 'tools/execute', exec, + async (): Promise => { + try { + // Resolve through the CALLER's visible view ({@link get}): a scoped + // tool shadows its global name-twin for that agent, and a + // restricted-away global tool is exactly as absent as a nonexistent + // one — same UNKNOWN_TOOL result, no capability leak in the error. + const tool = this.get(exec.name, exec.agent) + if (!tool) throw new ToolNotFoundError(exec.name) + // Normalize the two `execute` return shapes: a bare ContentBlock[] (no + // meta) or a { content, meta } object (a tool attaching a private + // presentation payload). An array IS the content; the object carries it. + const returned = await tool.execute(exec.arguments, exec) + const content = Array.isArray(returned) ? returned : returned.content + const meta = Array.isArray(returned) ? undefined : returned.meta + return { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} } + } catch (error: unknown) { + return toolErrorResult(exec.callId, error) + } + }, + )) + + return await this.postExecute(exec, result) + } + + /** Notify final-result observers without giving them a mutation/error channel into the outcome. */ + private async notifyResult(exec: ToolExecution, result: ToolExecutionResult): Promise { + // The pipeline is over: freeze the remaining mutable signal slot so every + // observer sees the SAME WeakMap-keyable execution without a mutation race. + Object.freeze(exec) + // postExecute clones every accepted result/decision before rebuilding the + // outcome; all error paths construct plain data. The final result is thus + // structurally cloneable before it reaches this observe-only boundary. + const snapshot = deepFreeze(structuredClone(result)) + const callbacks = this.ctx.events.dispatch('parallel', [ + scopeTarget(this, exec.agent), 'tools/result', exec, snapshot, + ]) + await Promise.all(callbacks.map(async (callback) => { + try { + await callback(exec, snapshot) + } catch (error: unknown) { + this.ctx.logger.warn(`tool "${exec.name}" (${exec.callId}): tools/result observer failed: ${errorMessage(error)}`) + } + })) } /** @@ -804,21 +1089,14 @@ export class ToolRegistry extends Service { // authoritative-call-id requirement and the "preserve the dispatched // isError/error" contract. The decision is the ONLY sanctioned channel for a // listener to change the outcome (block, or accept-with-replacement); the - // call id is always the authoritative `exec.callId`. `content` is copied into - // a fresh array so a listener's in-place `push`/`splice` on `result.content` - // cannot leak into the returned content either (the elements are the same - // references — the snapshot guards the array structure, not deep immutability). - const dispatched = { - callId: exec.callId, - content: [...result.content], - isError: result.isError, - ...result.error ? { error: result.error } : {}, - ...result.meta !== undefined ? { meta: result.meta } : {}, - } - const decision = await this.ctx.waterfall( + // call id is always the authoritative `exec.callId`. Deep cloning protects + // nested content, error, and meta data from in-place listener mutation. + const dispatched = this.snapshotExecutionResult(exec, result) + const decision = structuredClone(await this.ctx.waterfall( scopeTarget(this, exec.agent), 'tools/post-execute', exec, result, () => Promise.resolve({ kind: 'accept' }), - ) + )) + this.assertPostDecision(decision) const additionalContext = decision.additionalContext if (decision.kind === 'block') { return { @@ -835,6 +1113,74 @@ export class ToolRegistry extends Service { ...additionalContext ? { additionalContext } : {}, } } + + /** Validate and detach an around-dispatch result before policy can observe or mutate it. */ + private snapshotExecutionResult(exec: ToolExecution, value: unknown): ToolExecutionResult { + if (typeof value !== 'object' || value === null) { + throw new TypeError('tools/execute must return a ToolExecutionResult object') + } + const result = value as Partial + if (!Array.isArray(result.content) || typeof result.isError !== 'boolean') { + throw new TypeError('tools/execute must return a ToolExecutionResult with content[] and boolean isError') + } + if (result.callId !== exec.callId) { + throw new TypeError(`tools/execute returned callId "${String(result.callId)}" for authoritative call "${exec.callId}"`) + } + const candidate = { + callId: exec.callId, + content: result.content, + isError: result.isError, + ...result.error !== undefined ? { error: result.error } : {}, + ...result.additionalContext !== undefined ? { additionalContext: result.additionalContext } : {}, + ...result.meta !== undefined ? { meta: result.meta } : {}, + } + // Validate BEFORE cloning: structuredClone turns some forbidden exotic or + // class instances into plain objects, which would hide a lossy JSON + // boundary violation. Validate the detached clone again to contain hostile + // getters whose value changes between inspection and snapshotting. + if (!isJsonValue(candidate)) { + throw new TypeError('tools/execute must return a losslessly JSON-serializable ToolExecutionResult') + } + const snapshot = structuredClone(candidate) + if (!isJsonValue(snapshot)) { + throw new TypeError('tools/execute must return a stable losslessly JSON-serializable ToolExecutionResult') + } + return snapshot + } + + /** Reject malformed JavaScript/casted post decisions at the public event boundary. */ + private assertPostDecision(value: unknown): asserts value is PostToolDecision { + if (typeof value !== 'object' || value === null) { + throw new TypeError('tools/post-execute must return a PostToolDecision object') + } + const decision = value as Partial + switch (decision.kind) { + case 'accept': + if (decision.content !== undefined && !Array.isArray(decision.content)) { + throw new TypeError('tools/post-execute accept content must be an array') + } + return + case 'block': + if (!Array.isArray(decision.feedback)) { + throw new TypeError('tools/post-execute block feedback must be an array') + } + return + default: + throw new TypeError('tools/post-execute must return an accept or block decision') + } + } +} + +/** Mint a frozen, property-free correlation token whose identity is its value. */ +function createExecutionToken(): ToolExecutionToken { + const token = Object.freeze(Object.create(null)) as ToolExecutionToken + executionTokens.add(token) + return token +} + +/** Runtime counterpart of the opaque token type, including `undefined` input. */ +function isExecutionToken(value: unknown): value is ToolExecutionToken { + return typeof value === 'object' && value !== null && executionTokens.has(value) } function toolErrorResult(callId: ToolExecution['callId'], error: unknown): ToolExecutionResult { diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 782447e7ec..da5725f10e 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -123,6 +123,23 @@ describe('mode-aware wire contribution', () => { expect(sdk?.text).not.toContain('run_code(args:') }) + it.each(['code', 'both'] as const)('restores Code Mode infrastructure after assembly listeners in mode %s', async (mode) => { + const { ctx, systemPrompt } = await setup({ mode }) + registerEcho(ctx) + ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { + const assembly = await next() + return { + ...assembly, + sections: assembly.sections.filter(section => section.name !== 'tools:sdk'), + tools: assembly.tools.filter(tool => tool.name !== RUN_CODE_NAME), + } + }, { prepend: true }) + + const assembly = await systemPrompt.assemble() + expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(true) + expect(assembly.tools.some(tool => tool.name === RUN_CODE_NAME)).toBe(true) + }) + it("mode 'both' contributes every native schema plus run_code, and the SDK section", async () => { const { ctx, systemPrompt } = await setup({ mode: 'both' }) registerEcho(ctx) @@ -197,13 +214,40 @@ describe('mode-aware wire contribution', () => { expect(() => scope.ctx.tools.register(impostor)).toThrow(/reserved for the Code Mode presentation transport/) expect(() => ctx.tools.register(impostor)).toThrow(/reserved for the Code Mode presentation transport/) + expect(() => scope.ctx.systemPrompt.section({ name: 'tools:sdk', order: -999, text: 'malicious SDK' })) + .toThrow(/globally protected and cannot be shadowed/) expect(() => scope.ctx.tools.restrict({ allow: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/) expect(() => scope.ctx.tools.restrict({ deny: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/) + const transport = ctx.tools.get(RUN_CODE_NAME)! + expect(Object.isFrozen(transport)).toBe(true) + expect(Object.isFrozen(transport.parameters)).toBe(true) + expect(() => { transport.name = 'mutated_transport' }).toThrow(TypeError) + + const mutableSection = { name: 'scoped-note', order: 149, text: 'safe note' } + scope.ctx.systemPrompt.section(mutableSection) + mutableSection.name = 'tools:sdk' + mutableSection.text = 'mutated SDK' + const mutableTool = defineTool({ + name: 'scoped_safe', + description: 'Safe scoped tool.', + parameters: {}, + execute: () => Promise.resolve([{ type: 'text' as const, text: 'safe' }]), + }) + scope.ctx.tools.register(mutableTool) + mutableTool.name = RUN_CODE_NAME + mutableTool.description = 'Mutated transport impostor.' + const stored = ctx.tools.get('scoped_safe', agent)! + expect(Object.isFrozen(stored)).toBe(true) + expect(Object.isFrozen(stored.parameters)).toBe(true) + expect(() => { stored.name = RUN_CODE_NAME }).toThrow(TypeError) const assembly = await systemPrompt.assemble({ scope: agent }) const transports = assembly.tools.filter(tool => tool.name === RUN_CODE_NAME) expect(transports).toHaveLength(1) expect(transports[0]?.description).toContain('Execute a TypeScript program') + expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).not.toContain('mutated SDK') + expect(assembly.sections.find(section => section.name === 'scoped-note')?.text).toBe('safe note') + expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('scoped_safe(args:') expect(ctx.tools.get(RUN_CODE_NAME, agent)).toBe(ctx.tools.get(RUN_CODE_NAME)) expect(ctx.tools.knownNames(agent)).not.toContain(RUN_CODE_NAME) const result = await runCode(ctx, 'return 1', { agent }) @@ -306,6 +350,36 @@ describe('the run_code dispatch bridge', () => { expect(result.meta).toEqual({ logs: [{ source: 'console', level: 'log', text: 'saw echo:one' }], dispatches: 2 }) }) + it('exposes only an opaque parent token to nested result observers', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + registerEcho(ctx) + runtime.behavior = async (request) => { + await request.bindings[0]!.functions.echo!({ value: 'nested' }) + return { logs: [], value: 'done' } + } + + // Model a timeout-style outer wrapper: it temporarily installs a signal, + // delegates, then restores the exact prior shape. A nested result observer + // is observe-only and must not receive the live outer execution object; + // freezing the correlation value it sees therefore cannot break restore. + ctx.on('tools/execute', async (exec, next) => { + if (exec.name !== RUN_CODE_NAME) return next() + const previous = exec.signal + exec.signal = new AbortController().signal + const result = await next() + if (previous === undefined) delete exec.signal + else exec.signal = previous + return result + }) + ctx.on('tools/result', (exec) => { + if (exec.parent !== undefined) Object.freeze(exec.parent) + }) + + const result = await runCode(ctx, 'await tools.echo({ value: "nested" })') + expect(result.isError).toBe(false) + expect(result.content).toEqual([{ type: 'text', text: 'done' }]) + }) + it('serializes Promise.all dispatches: tool executions never overlap, in submission order', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) const intervals: [string, string][] = [] @@ -639,16 +713,17 @@ describe('the run_code dispatch bridge', () => { expect(events.filter(event => event.type === 'tool/code-dispatch')).toEqual([]) }) - it('logs the value the tool RECEIVED even when the tool mutates its arguments', async () => { + it('gives the tool and durable log the same immutable argument value', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) const { agent, events } = fakeAgent() + let mutationSucceeded: boolean | undefined ctx.tools.register(defineTool({ name: 'mutator', - description: 'Mutates its own args object.', + description: 'Attempts to mutate its args object.', parameters: { list: { type: 'array', required: true } }, execute(args) { - args.list.push('injected-by-tool') - return Promise.resolve([{ type: 'text' as const, text: 'mutated' }]) + mutationSucceeded = Reflect.set(args.list, 1, 'injected-by-tool') + return Promise.resolve([{ type: 'text' as const, text: 'protected' }]) }, })) runtime.behavior = async (request) => { @@ -657,6 +732,7 @@ describe('the run_code dispatch bridge', () => { } const result = await runCode(ctx, 'program', { agent }) expect(result.isError).toBe(false) + expect(mutationSucceeded).toBe(false) const dispatch = events.find(event => event.type === 'tool/code-dispatch')?.data as SessionEventMap['tool/code-dispatch'] expect(dispatch.arguments).toEqual({ list: ['original'] }) }) diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index 86bdd1414a..cac4a7e29b 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -1,10 +1,10 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import type { PreToolDecision, ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools' +import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import type { Agent, AgentId } from '@deepseek-ai/dsh-agent' import { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' @@ -149,6 +149,11 @@ describe('restrict()', () => { expect(() => scope.ctx.tools.restrict({})).toThrow(/no-op/) expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown tool "reall"; known tools for this scope: real/) expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown tools "ghost", "wraith"/) + + const emptyCtx = await mount() + const { scope: emptyScope } = await mintAgentScope(emptyCtx, 'empty') + expect(() => emptyScope.ctx.tools.restrict({ deny: ['ghost'] })) + .toThrow(/known tools for this scope: \(none\)/) }) }) @@ -170,4 +175,296 @@ describe('scoped execution dispatch', () => { expect(await run(ctx, 't')).toBe('ran:t') expect(seen).toEqual(['a']) }) + + it('applies scoped guards after pre-execute and unwinds duplicate registrations independently', async () => { + const ctx = await mount() + const { scope, key } = await mintAgentScope(ctx, 'a') + const other = { id: 'other' as AgentId } as Agent + let bodyCalls = 0 + ctx.tools.register({ + ...tool('t'), + execute: () => { + bodyCalls += 1 + return Promise.resolve([{ type: 'text', text: 'ran:t' }]) + }, + }) + let guardViewFrozen = false + const guard = (execution: Readonly): string => { + guardViewFrozen = Object.isFrozen(execution) && Object.isFrozen(execution.arguments) + return 'terminal policy' + } + const liftFirst = scope.ctx.tools.guard(guard) + scope.ctx.tools.guard(guard) + // Registered later and prepended outside every existing waterfall listener: + // it can force the extensible pre decision to allow, but cannot bypass the + // owner-level monotonic guard that runs after the waterfall. + scope.ctx.on('tools/pre-execute', () => Promise.resolve({ kind: 'allow' }), { prepend: true }) + + expect(await run(ctx, 't', key)).toBe('Error: terminal policy') + expect(guardViewFrozen).toBe(true) + expect(await run(ctx, 't', other)).toBe('ran:t') + expect(bodyCalls).toBe(1) + + await liftFirst() + expect(await run(ctx, 't', key)).toBe('Error: terminal policy') + await scope.dispose() + expect(await run(ctx, 't', key)).toBe('ran:t') + expect(bodyCalls).toBe(2) + }) + + it('composes global guards monotonically when one abstains and a later one denies', async () => { + const ctx = await mount() + let bodyCalls = 0 + ctx.tools.register({ + ...tool('t'), + execute: () => { + bodyCalls += 1 + return Promise.resolve([]) + }, + }) + ctx.tools.guard(() => undefined) + ctx.tools.guard(() => 'global denial') + + expect(await run(ctx, 't')).toBe('Error: global denial') + expect(bodyCalls).toBe(0) + }) + + it('protects call identity before policy and dispatch while leaving only signal mutable', async () => { + const ctx = await mount() + const { scope, key } = await mintAgentScope(ctx, 'a') + let safeCalls = 0 + let dangerCalls = 0 + let scopedResults = 0 + let safeArguments: unknown + ctx.tools.register({ + ...tool('safe'), + execute: (args) => { + safeCalls += 1 + safeArguments = args + return Promise.resolve([{ type: 'text', text: 'safe' }]) + }, + }) + ctx.tools.register({ + ...tool('danger'), + execute: () => { + dangerCalls += 1 + return Promise.resolve([{ type: 'text', text: 'danger' }]) + }, + }) + scope.ctx.tools.guard(exec => exec.name === 'danger' ? 'danger denied' : undefined) + ctx.on('tools/pre-execute', (exec, next) => { + expect(Reflect.set(exec, 'agent', undefined)).toBe(false) + expect(Reflect.set(exec, 'name', 'safe')).toBe(false) + expect(Reflect.set(exec.arguments as object, 'injected', true)).toBe(false) + return next() + }) + ctx.on('tools/execute', (exec, next) => { + expect(Reflect.set(exec, 'name', 'danger')).toBe(false) + return next() + }) + ctx.on('tools/post-execute', (exec, _result, next) => { + expect(Reflect.set(exec, 'agent', undefined)).toBe(false) + return next() + }) + scope.ctx.on('tools/result', () => { scopedResults += 1 }) + + expect(await run(ctx, 'danger', key)).toBe('Error: danger denied') + const callerArguments = { source: true } + const safeResult = await ctx.tools.execute({ + callId: CallId('safe-call'), + name: 'safe', + arguments: callerArguments, + agent: key, + }) + expect(safeResult.content[0]).toMatchObject({ text: 'safe' }) + expect(Object.isFrozen(callerArguments)).toBe(false) + expect(safeArguments).not.toBe(callerArguments) + expect(Object.isFrozen(safeArguments)).toBe(true) + expect(callerArguments).toEqual({ source: true }) + expect({ safeCalls, dangerCalls, scopedResults }).toEqual({ + safeCalls: 1, + dangerCalls: 0, + scopedResults: 2, + }) + }) + + it('normalizes non-cloneable arguments and still publishes one scoped final outcome', async () => { + const ctx = await mount() + const { scope, key } = await mintAgentScope(ctx, 'a') + let policyCalls = 0 + let bodyCalls = 0 + let scopedObserved = 0 + let globalObserved = 0 + ctx.tools.register({ + ...tool('t'), + execute: () => { + bodyCalls += 1 + return Promise.resolve([]) + }, + }) + ctx.on('tools/pre-execute', (_exec, next) => { + policyCalls += 1 + return next() + }) + let parent!: ToolExecutionToken + ctx.tools.register(tool('parent')) + const stopCapture = ctx.on('tools/pre-execute', (exec, next) => { + if (exec.name === 'parent') parent = exec.token + return next() + }) + await ctx.tools.execute({ callId: CallId('parent'), name: 'parent', arguments: {} }) + stopCapture() + policyCalls = 0 + const signal = new AbortController().signal + scope.ctx.on('tools/result', (exec, result) => { + scopedObserved += 1 + expect(exec.arguments).toBeUndefined() + expect(exec.parent).toBe(parent) + expect(exec.signal).toBe(signal) + expect(Object.isFrozen(exec)).toBe(true) + expect(result.isError).toBe(true) + }) + ctx.on('tools/result', () => { globalObserved += 1 }) + const callerArguments = { invalid: () => undefined } + + const scopedResult = await ctx.tools.execute({ + callId: CallId('non-cloneable'), + name: 't', + arguments: callerArguments, + agent: key, + parent, + signal, + }) + const subjectlessResult = await ctx.tools.execute({ + callId: CallId('non-cloneable-subjectless'), + name: 't', + arguments: { invalid: () => undefined }, + }) + expect(scopedResult.isError).toBe(true) + expect(scopedResult.content[0]?.type === 'text' && scopedResult.content[0].text).toContain('losslessly JSON-serializable') + expect(subjectlessResult.isError).toBe(true) + expect({ policyCalls, bodyCalls, scopedObserved, globalObserved }).toEqual({ + policyCalls: 0, + bodyCalls: 0, + scopedObserved: 1, + globalObserved: 2, + }) + expect(Object.isFrozen(callerArguments)).toBe(false) + expect(callerArguments.invalid).toBeTypeOf('function') + }) + + it('rejects a forged mutable parent token without exposing it to final observers', async () => { + const ctx = await mount() + ctx.tools.register(tool('t')) + const forged = { mutable: true } as unknown as ToolExecutionToken + let observedParent: ToolExecutionToken | undefined = forged + ctx.on('tools/result', (exec) => { observedParent = exec.parent }) + + const result = await ctx.tools.execute({ + callId: CallId('forged-parent'), name: 't', arguments: {}, parent: forged, + }) + + expect(result.isError).toBe(true) + expect(result.content).toEqual([{ + type: 'text', text: 'Error: tool execution parent must be a registry-minted opaque token', + }]) + expect(observedParent).toBeUndefined() + expect(Object.isFrozen(forged)).toBe(false) + }) + + it.each([ + ['Map', new Map([['mutable', true]])], + ['class instance', new (class Arguments { value = 1 })()], + ])('rejects cloneable non-JSON arguments (%s) before policy or dispatch', async (_kind, argumentsValue) => { + const ctx = await mount() + let policyCalls = 0 + let bodyCalls = 0 + let observed = 0 + ctx.tools.register({ + ...tool('t'), + execute: () => { + bodyCalls += 1 + return Promise.resolve([]) + }, + }) + ctx.on('tools/pre-execute', (_exec, next) => { + policyCalls += 1 + return next() + }) + ctx.on('tools/result', (exec, result) => { + observed += 1 + expect(exec.arguments).toBeUndefined() + expect(result.isError).toBe(true) + }) + + const result = await ctx.tools.execute({ + callId: CallId('bad-arguments'), name: 't', arguments: argumentsValue, + }) + + expect(result.isError).toBe(true) + expect(result.content).toEqual([{ + type: 'text', text: 'Error: tool execution arguments must be losslessly JSON-serializable', + }]) + expect({ policyCalls, bodyCalls, observed }).toEqual({ policyCalls: 0, bodyCalls: 0, observed: 1 }) + }) + + it('rejects arguments that change to non-JSON data while being snapshotted', async () => { + const ctx = await mount() + ctx.tools.register(tool('t')) + let reads = 0 + const argumentsValue = Object.defineProperty({}, 'value', { + enumerable: true, + get: () => ++reads === 1 ? 'safe' : new Map([['mutable', true]]), + }) + + const result = await ctx.tools.execute({ + callId: CallId('unstable-arguments'), name: 't', arguments: argumentsValue, + }) + + expect(result).toEqual({ + callId: CallId('unstable-arguments'), + content: [{ + type: 'text', text: 'Error: tool execution arguments must be stable losslessly JSON-serializable data', + }], + isError: true, + }) + }) + + it('notifies every tools/result observer with the frozen final outcome and contains failures', async () => { + const ctx = await mount() + const { scope, key } = await mintAgentScope(ctx, 'a') + ctx.tools.register(tool('t')) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => ctx.logger) + const seen: boolean[] = [] + const dispatchModes: string[] = [] + ctx.on('internal/dispatch', (mode, name) => { + if (name === 'tools/result') dispatchModes.push(mode) + }) + ctx.on('tools/execute', async (exec, next) => { + await next() + return { + callId: exec.callId, + content: [{ type: 'text', text: 'outer failure' }], + isError: true, + } + }, { prepend: true }) + scope.ctx.on('tools/result', (_exec, result) => { + expect(Object.isFrozen(_exec)).toBe(true) + expect(Object.isFrozen(_exec.arguments)).toBe(true) + expect(Object.isFrozen(result)).toBe(true) + expect(Object.isFrozen(result.content)).toBe(true) + seen.push(result.isError) + }) + ctx.on('tools/result', () => { + throw { toString: () => { throw new Error('coercion trap') } } + }) + ctx.on('tools/result', (_exec, result) => { seen.push(result.isError) }) + + const result = await ctx.tools.execute({ callId: CallId('final'), name: 't', arguments: {}, agent: key }) + expect(result).toMatchObject({ isError: true, content: [{ type: 'text', text: 'outer failure' }] }) + expect(seen).toEqual([true, true]) + expect(dispatchModes).toEqual(['parallel']) + expect(warn).toHaveBeenCalledOnce() + expect(String(warn.mock.calls[0]?.[0])).toContain('') + }) }) diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 4cea334552..d32b349f93 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -5,7 +5,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision, - type ToolExecution, type ToolExecutionResult, + type ToolExecution, type ToolExecutionResult, type ToolGuard, } from '@deepseek-ai/dsh-tools' async function setup() { @@ -113,6 +113,53 @@ describe('ToolRegistry', () => { expect('meta' in result).toBe(false) }) + it('normalizes a contract-violating non-cloneable result before final notification', async () => { + const ctx = await setup() + let observedError: boolean | undefined + ctx.on('tools/result', (_exec, result) => { observedError = result.isError }) + ctx.tools.register({ + ...echoTool, + name: 'bad-meta', + async execute() { + return { content: [], meta: () => undefined } + }, + }) + + const result = await ctx.tools.execute({ + callId: CallId('bad-meta'), name: 'bad-meta', arguments: {}, + }) + expect(result.isError).toBe(true) + expect(result.content[0]?.type === 'text' && result.content[0].text).toContain('Error:') + expect(observedError).toBe(true) + }) + + it('normalizes a result that changes to non-JSON data while being snapshotted', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + let reads = 0 + const hostileBlock = Object.defineProperty({ type: 'text' }, 'text', { + enumerable: true, + get: () => ++reads === 1 ? 'safe' : new Map([['mutable', true]]), + }) + ctx.on('tools/execute', async exec => ({ + callId: exec.callId, + content: [hostileBlock], + isError: false, + }) as unknown as ToolExecutionResult) + + const result = await ctx.tools.execute({ + callId: CallId('unstable-result'), name: 'echo', arguments: {}, + }) + + expect(result).toEqual({ + callId: CallId('unstable-result'), + content: [{ + type: 'text', text: 'Error: tools/execute must return a stable losslessly JSON-serializable ToolExecutionResult', + }], + isError: true, + }) + }) + it('returns isError results for unknown tools and throwing tools', async () => { const ctx = await setup() ctx.tools.register({ @@ -134,6 +181,28 @@ describe('ToolRegistry', () => { expect(thrown.content[0]).toMatchObject({ text: 'Error: exploded' }) }) + it('normalizes a hostile thrown value whose inspection and coercion both throw', async () => { + const ctx = await setup() + ctx.tools.register({ + ...echoTool, + name: 'hostile-throw', + async execute() { + throw new Proxy({}, { + getPrototypeOf: () => { throw new Error('prototype trap') }, + has: () => { throw new Error('has trap') }, + get: () => { throw new Error('get trap') }, + }) + }, + }) + + await expect(ctx.tools.execute({ + callId: CallId('hostile'), name: 'hostile-throw', arguments: {}, + })).resolves.toMatchObject({ + isError: true, + content: [{ type: 'text', text: 'Error: ' }], + }) + }) + it('ToolNotFoundError carries the tool name and a stable code', async () => { const { HarnessError } = await import('@deepseek-ai/dsh-llm') const err = new ToolNotFoundError('ghost') @@ -158,6 +227,25 @@ describe('ToolRegistry', () => { expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' }) }) + it('rejects a JavaScript guard that returns an async/non-string decision', async () => { + const ctx = await setup() + let bodyCalls = 0 + ctx.tools.register({ + ...echoTool, + async execute() { + bodyCalls += 1 + return [] + }, + }) + ctx.tools.guard((() => Promise.resolve('late denial')) as unknown as ToolGuard) + + const result = await ctx.tools.execute({ callId: CallId('bad-guard'), name: 'echo', arguments: {} }) + expect(result.isError).toBe(true) + expect(result.content[0]?.type === 'text' && result.content[0].text) + .toContain('tools.guard() must return') + expect(bodyCalls).toBe(0) + }) + it('an ask decision degrades to deny until the permission system lands', async () => { const ctx = await setup() ctx.tools.register(echoTool) @@ -233,7 +321,7 @@ describe('ToolRegistry', () => { expect(result.additionalContext).toMatchObject({ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } }) }) - it('a post-execute listener mutating the result object cannot corrupt callId/isError/error', async () => { + it('a post-execute listener cannot mutate any nested part of the dispatched result', async () => { // The decision is the ONLY sanctioned channel to change the outcome. A // listener that reaches in and mutates the passed result reference (flipping // isError, rewriting callId, attaching a bogus error) must NOT affect what @@ -241,23 +329,45 @@ describe('ToolRegistry', () => { // the waterfall and rebuilds from the snapshot + decision. const ctx = await setup() ctx.tools.register(echoTool) + ctx.on('tools/execute', async (_exec, next) => { + await next() + return { + callId: CallId('c1'), + content: [{ type: 'text', text: 'original' }], + isError: true, + error: { name: 'OriginalError', code: 'ORIGINAL' }, + meta: { nested: { label: 'original' } }, + } + }) ctx.on('tools/post-execute', async (_exec, result, next) => { - const mutable = result as { callId: string; isError: boolean; error?: unknown; content: unknown[] } + const mutable = result as { + callId: string + isError: boolean + error?: { name: string; code: string } + content: { type: 'text'; text: string }[] + meta?: { nested: { label: string } } + } mutable.callId = 'hijacked' - mutable.isError = true - mutable.error = { name: 'Evil', code: 'EVIL' } + mutable.isError = false + if (mutable.error) { + mutable.error.name = 'Evil' + mutable.error.code = 'EVIL' + } + mutable.content[0]!.text = 'MUTATED' mutable.content.push({ type: 'text', text: 'INJECTED' }) // in-place array mutation + if (mutable.meta) mutable.meta.nested.label = 'MUTATED' return next() // delegate to the default accept — no decision-level override }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.callId).toBe(CallId('c1')) // authoritative exec.callId, not 'hijacked' - expect(result.isError).toBe(false) // the real (successful) dispatch outcome - expect(result.error).toBeUndefined() // no listener-injected error + expect(result.isError).toBe(true) + expect(result.error).toEqual({ name: 'OriginalError', code: 'ORIGINAL' }) expect(result.content).toHaveLength(1) // the in-place push did not leak in - expect(result.content[0]).toMatchObject({ text: 'hi' }) + expect(result.content[0]).toMatchObject({ text: 'original' }) expect(result.content.some(b => (b as { text?: string }).text === 'INJECTED')).toBe(false) + expect(result.meta).toEqual({ nested: { label: 'original' } }) }) it('composes pre + post waterfalls around dispatch (sandbox-wrap pattern)', async () => { @@ -416,6 +526,109 @@ describe('ToolRegistry', () => { expect(result.content[0]).toMatchObject({ text: 'short-circuited' }) }) + it('preserves additionalContext supplied by an around-dispatch result', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + ctx.on('tools/execute', async exec => ({ + callId: exec.callId, + content: [{ type: 'text', text: 'short-circuited with context' }], + isError: false, + additionalContext: { + content: [{ type: 'text', text: 'from around dispatch' }], + source: { kind: 'plugin', plugin: 'test' }, + }, + })) + + const result = await ctx.tools.execute({ + callId: CallId('around-context'), name: 'echo', arguments: {}, + }) + expect(result.additionalContext).toEqual({ + content: [{ type: 'text', text: 'from around dispatch' }], + source: { kind: 'plugin', plugin: 'test' }, + }) + }) + + it('normalizes malformed tools/execute results instead of treating them as success', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + let observedError: boolean | undefined + ctx.on('tools/execute', async (_exec, next) => { + await next() + return {} as ToolExecutionResult + }) + ctx.on('tools/result', (_exec, result) => { observedError = result.isError }) + + const result = await ctx.tools.execute({ callId: CallId('malformed'), name: 'echo', arguments: {} }) + expect(result.isError).toBe(true) + expect(result.content[0]).toMatchObject({ + text: 'Error: tools/execute must return a ToolExecutionResult with content[] and boolean isError', + }) + expect(observedError).toBe(true) + }) + + it.each([ + { + name: 'non-object result', + replacement: null, + message: 'tools/execute must return a ToolExecutionResult object', + }, + { + name: 'wrong call id', + replacement: { callId: CallId('other'), content: [], isError: false }, + message: 'tools/execute returned callId "other" for authoritative call "malformed-shape"', + }, + ])('normalizes a tools/execute $name', async ({ replacement, message }) => { + const ctx = await setup() + ctx.tools.register(echoTool) + ctx.on('tools/execute', async () => replacement as ToolExecutionResult) + + const result = await ctx.tools.execute({ + callId: CallId('malformed-shape'), name: 'echo', arguments: {}, + }) + expect(result.isError).toBe(true) + expect(result.content[0]).toMatchObject({ text: `Error: ${message}` }) + }) + + it('normalizes malformed tools/post-execute decisions', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + ctx.on('tools/post-execute', async () => ({ kind: 'accept', content: 'not blocks' }) as unknown as PostToolDecision) + + const result = await ctx.tools.execute({ callId: CallId('malformed-post'), name: 'echo', arguments: {} }) + expect(result.isError).toBe(true) + expect(result.content[0]).toMatchObject({ + text: 'Error: tools/post-execute accept content must be an array', + }) + }) + + it.each([ + { + name: 'non-object decision', + replacement: null, + message: 'tools/post-execute must return a PostToolDecision object', + }, + { + name: 'block without feedback blocks', + replacement: { kind: 'block', feedback: 'not blocks' }, + message: 'tools/post-execute block feedback must be an array', + }, + { + name: 'unknown decision kind', + replacement: { kind: 'defer' }, + message: 'tools/post-execute must return an accept or block decision', + }, + ])('normalizes a tools/post-execute $name', async ({ replacement, message }) => { + const ctx = await setup() + ctx.tools.register(echoTool) + ctx.on('tools/post-execute', async () => replacement as unknown as PostToolDecision) + + const result = await ctx.tools.execute({ + callId: CallId('malformed-post-shape'), name: 'echo', arguments: {}, + }) + expect(result.isError).toBe(true) + expect(result.content[0]).toMatchObject({ text: `Error: ${message}` }) + }) + it('returns an isError result when a tools/execute listener throws', async () => { const ctx = await setup() ctx.tools.register(echoTool) @@ -493,6 +706,61 @@ describe('ToolRegistry', () => { }]) }) + it.each([ + ['Map', new Map([['mutable', true]])], + ['class instance', new (class Parameters { value = 1 })()], + ])('rejects cloneable non-JSON tool parameters (%s) without registry residue', async (_kind, parameters) => { + const ctx = await setup() + const definition = { + ...echoTool, + name: 'invalid-parameters', + parameters, + } as unknown as typeof echoTool + + expect(() => ctx.tools.register(definition)).toThrow( + 'tool parameters must be losslessly JSON-serializable', + ) + expect(ctx.tools.get('invalid-parameters')).toBeUndefined() + }) + + it('rejects tool parameters that change to non-JSON data while being snapshotted', async () => { + const ctx = await setup() + let reads = 0 + const parameters = Object.defineProperty({}, 'properties', { + enumerable: true, + get: () => ++reads === 1 ? {} : new Map([['mutable', true]]), + }) + + expect(() => ctx.tools.register({ + ...echoTool, + name: 'unstable-parameters', + parameters, + })).toThrow('tool parameters must be stable losslessly JSON-serializable data') + expect(ctx.tools.get('unstable-parameters')).toBeUndefined() + }) + + it('snapshots callbacks while preserving their registration-time method receiver', async () => { + const ctx = await setup() + const receivers: object[] = [] + const definition = { + ...echoTool, + name: 'callback-snapshot', + async execute() { + receivers.push(this) + return [{ type: 'text' as const, text: 'original' }] + }, + } + ctx.tools.register(definition) + definition.execute = async () => [{ type: 'text' as const, text: 'replacement' }] + + const result = await ctx.tools.execute({ + callId: CallId('callback-snapshot'), name: definition.name, arguments: {}, + }) + + expect(receivers).toEqual([definition]) + expect(result.content).toEqual([{ type: 'text', text: 'original' }]) + }) + it('rejects duplicate names and unregisters on fiber dispose (HMR safety)', async () => { const ctx = await setup() ctx.tools.register(echoTool) diff --git a/packages/fs/tool-fs/tests/fs-tools.e2e.ts b/packages/fs/tool-fs/tests/fs-tools.e2e.ts index da5412530d..a270e7c6d8 100644 --- a/packages/fs/tool-fs/tests/fs-tools.e2e.ts +++ b/packages/fs/tool-fs/tests/fs-tools.e2e.ts @@ -64,7 +64,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => const sessionDir = await mkdtemp(join(tmpdir(), 'dsh-fs-e2e-session-')) try { ctx = await fsHarness(configDir, SYSTEM) - const handle = ctx.agents.create({ + const handle = await ctx.agents.create({ agentId: AgentId('fs-e2e-cwd'), sessionId: SessionId(`fs-e2e-cwd-${Date.now()}`), meta: { cwd: sessionDir }, diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 53f912cce2..c17bd875ba 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -164,11 +164,10 @@ describe('read tool', () => { expect(text(result)).toContain('offset must be a positive integer') }) - it('rejects a fractional or NaN offset, and a zero/negative limit', async () => { + it('rejects a fractional offset and a zero/negative limit', async () => { const { ctx } = await setup() for (const args of [ { file_path: 'a.txt', offset: 1.5 }, - { file_path: 'a.txt', offset: Number.NaN }, { file_path: 'a.txt', limit: 0 }, { file_path: 'a.txt', limit: -3 }, ]) { @@ -178,6 +177,13 @@ describe('read tool', () => { } }) + it('rejects a non-JSON numeric offset before tool-specific validation', async () => { + const { ctx } = await setup() + const result = await call(ctx, 'read', { file_path: 'a.txt', offset: Number.NaN }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('tool execution arguments must be losslessly JSON-serializable') + }) + it('rejects a limit above the cap', async () => { const { ctx } = await setup() const result = await call(ctx, 'read', { file_path: 'a.txt', limit: 99999 }) diff --git a/packages/hooks/hooks-claude/tests/coverage.spec.ts b/packages/hooks/hooks-claude/tests/coverage.spec.ts index f06cdcf6b4..7feb46df05 100644 --- a/packages/hooks/hooks-claude/tests/coverage.spec.ts +++ b/packages/hooks/hooks-claude/tests/coverage.spec.ts @@ -448,7 +448,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => const ctx = await harness(path, adapter) // NB: no projectDir // The factory create() path honors meta.cwd (the plain agentLoop.create() does not). const { SessionId } = await import('@deepseek-ai/dsh-session') - const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { model: 'mock' } }) + const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { model: 'mock' } }) handle.agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, handle.agent as ReactLoopAgent) expect(events(handle.agent as ReactLoopAgent).some(e => e.type === 'context/message' @@ -613,7 +613,7 @@ describe('hooks-claude coverage — hook runs in the session cwd, not the server ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const { SessionId } = await import('@deepseek-ai/dsh-session') - const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } }) + const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } }) handle.agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, handle.agent as ReactLoopAgent) @@ -649,7 +649,7 @@ describe('hooks-claude coverage — hook runs in the session cwd, not the server // Register a live child on its own session cwd; emit subagent/end with its id. const { SessionId } = await import('@deepseek-ai/dsh-session') - const childHandle = ctx.agents.create({ agentId: AgentId('child-stop'), sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { model: 'mock' } }) + const childHandle = await ctx.agents.create({ agentId: AgentId('child-stop'), sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { model: 'mock' } }) ctx.emit('subagent/end', { provider: 'inproc', id: childHandle.agent.id, stopReason: 'completed' }) await waitFor(() => existsSync(marker)) diff --git a/packages/hooks/hooks-codex/tests/coverage.spec.ts b/packages/hooks/hooks-codex/tests/coverage.spec.ts index c83137df53..774b308fa1 100644 --- a/packages/hooks/hooks-codex/tests/coverage.spec.ts +++ b/packages/hooks/hooks-codex/tests/coverage.spec.ts @@ -553,7 +553,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { ctx.llm.registerAdapter(['mock'], adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const { SessionId } = await import('@deepseek-ai/dsh-session') - const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } }) + const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } }) handle.agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, handle.agent as ReactLoopAgent) expect(existsSync(marker)).toBe(true) diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index de650fa4cd..3b9243aa2e 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -32,7 +32,7 @@ export const name = 'subagent-fork' // per-run structured runtime gates its capture-tool registration on `tools` // itself, so this backend's apply timing (and the delegation tool's position // in the model-visible tool list) is unchanged by structured output. -export const inject = ['subagents', 'agents'] +export const inject = ['subagents'] /** Config: the registry name to register the provider under. */ export interface Config { @@ -77,7 +77,6 @@ class ForkProvider implements SubagentProvider { start(request: SubagentStartRequest) { const seed = completedTurnPrefix(request.parent) return startInProcessRun(this.ctx, request, { - providerName: this.name, // Only pass a seed when there's a completed turn to inherit; an empty seed // is equivalent to a fresh child, so omit it to keep the session unseeded. ...seed.length > 0 ? { seed } : {}, diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index 5356eba6e0..31a4f4c895 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -200,12 +200,12 @@ describe('dsh-subagent-fork', () => { it('has the namespace-plugin export shape (no stray default)', () => { expect('default' in fork).toBe(false) expect(fork.name).toBe('subagent-fork') - expect(fork.inject).toEqual(['subagents', 'agents']) + expect(fork.inject).toEqual(['subagents']) const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(fork) as Record expect(unwrapped).toBe(fork) expect(unwrapped.name).toBe('subagent-fork') - expect(unwrapped.inject).toEqual(['subagents', 'agents']) + expect(unwrapped.inject).toEqual(['subagents']) expect(typeof unwrapped.apply).toBe('function') }) }) diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index cd0202f1bb..61867e1b39 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -7,16 +7,18 @@ * a prefix of the parent's log); everything downstream — drive the child, read * its final output, map the stop reason, dispose — is identical and lives here. * - * This package owns no provider and registers nothing; it is a pure library the - * backend packages depend on, so neither backend needs to know about the other. + * This package declares no provider and performs no import-time registration; + * it is a library the backend packages depend on, so neither backend needs to + * know about the other. Each accepted run does install one provider-owned + * effect for structured-concurrency cleanup. * * @module @deepseek-ai/dsh-subagent-inprocess */ import { randomUUID } from 'node:crypto' -import type { Context } from 'cordis' +import type { Context, Fiber } from 'cordis' import { AgentId, type Agent, type AgentHandle, type AgentOptions } from '@deepseek-ai/dsh-agent' -import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' +import { SessionId, isJsonValue, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' @@ -86,8 +88,6 @@ function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason { /** Extra inputs the spawn/fork backends supply to {@link startInProcessRun}. */ export interface InProcessRunOptions { - /** The provider name (`spawn`/`fork`), for error context only. */ - readonly providerName: string /** * The child session's seed: a balanced, contiguous-from-0 prefix of the * parent's log (FORK), or `undefined` for a fresh child (SPAWN). @@ -95,6 +95,12 @@ export interface InProcessRunOptions { readonly seed?: SessionEvent[] } +/** Dispose a run-owner fiber and follow an already-started unload to quiescence. */ +async function quiesceFiber(fiber: Fiber): Promise { + await Promise.resolve(fiber.dispose()) + while (fiber.inertia !== undefined) await fiber.inertia +} + /** * Start an in-process child agent for `request` and return a {@link SubagentRun}. * @@ -108,9 +114,10 @@ export interface InProcessRunOptions { * * Throws {@link SubagentDepthError} before creating anything when the child's * depth (parent depth + 1) would exceed `request.maxDepth`. - * @param ctx - the context whose `agents` factory creates and owns the child. + * @param ctx - the provider context that owns the live run as a second + * structured-concurrency boundary alongside the parent agent. * @param request - the start request (prompt, parent, signal, per-child options). - * @param options - the backend's inputs: provider name plus the optional seed. + * @param options - the backend's optional child-session seed. * @returns the live run handle for the child agent. */ export function startInProcessRun( @@ -118,7 +125,15 @@ export function startInProcessRun( request: SubagentStartRequest, options: InProcessRunOptions, ): SubagentRun { - const childDepth = depthOf(request.parent) + 1 + // Snapshot the accepted request synchronously. The parent and signal are + // identity capabilities (kept live but never reread from the mutable request + // record); every data field is detached before asynchronous owner setup. + const parent = request.parent + const signal = request.signal + const persona = request.persona + const toolFilter = request.toolFilter === undefined ? undefined : structuredClone(request.toolFilter) + const seed = options.seed === undefined ? undefined : structuredClone(options.seed) + const childDepth = depthOf(parent) + 1 if (request.maxDepth !== undefined && childDepth > request.maxDepth) { throw new SubagentDepthError(childDepth, request.maxDepth) } @@ -134,6 +149,17 @@ export function startInProcessRun( // validateStructuredValue to one isolation-immutable value. if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema) const schema = request.outputSchema === undefined ? undefined : structuredClone(request.outputSchema) + // The accepted request owns a value snapshot, not the caller's mutable + // content array. Validate the same lossless-JSON contract Session.append + // enforces before any child exists, then detach it synchronously so mutation + // during async creation cannot change what is logged or sent to the model. + if (!isJsonValue(request.prompt)) { + throw new TypeError('subagent prompt must be losslessly JSON-serializable') + } + const prompt = structuredClone(request.prompt) + if (!isJsonValue(prompt)) { + throw new TypeError('subagent prompt must be stable losslessly JSON-serializable data') + } const childId = AgentId(randomUUID()) // The child's OWN events begin after the seed (fork seeds the parent's @@ -141,76 +167,43 @@ export function startInProcessRun( // boundary so a child that produces no message of its own never returns the // SEEDED parent's last assistant message as its result. const seedLength = options.seed?.length ?? 0 - const parentHeader = request.parent.session.header + const parentHeader = parent.session.header // Inherit the parent's model by default (a child with no model cannot run); // an explicit `request.agentOptions.model` overrides it. The deployment // persona needs no inheritance (a context-wide section both render); a // per-child `request.persona` becomes a SCOPED section of the same name in // the setup below, shadowing the deployment's for this child alone. - const agentOptions: AgentOptions = { - ...request.parent.options.model !== undefined ? { model: request.parent.options.model } : {}, + const agentOptions: AgentOptions = structuredClone({ + ...parent.options.model !== undefined ? { model: parent.options.model } : {}, ...request.agentOptions, subagentDepth: childDepth, - } + }) - // The child's scoped world, composed in the factory's setup window (after - // the child's scope exists and it is registered, before agent/session-start - // and the first prompt assembly; a throw here unwinds the half-created - // child inside the factory's rollback boundary): + // The child's scoped world, composed in the factory's unpublished setup + // window. The factory awaits it before inserting or announcing the child, so + // a throw/rejection exposes neither id and every first assembly sees it: // - persona: a scoped `deployment:persona` section shadowing the global one; // - toolFilter: a scoped restrict() masking the global tool surface // (loud unknown-name validation lives in the registry); // - outputSchema: the structured runtime, attached as scoped registrations. let structured: StructuredAttachment | undefined const setup = (childCtx: Context): void => { - if (request.persona !== undefined) { - childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: request.persona }) + if (persona !== undefined) { + childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: persona }) } - if (request.toolFilter !== undefined) { - childCtx.tools.restrict(request.toolFilter) + if (toolFilter !== undefined) { + childCtx.tools.restrict(toolFilter) } if (schema !== undefined) { structured = attachStructuredRuntime(childCtx, schema) } } - const handle: AgentHandle = ctx.agents.create({ - agentId: childId, - sessionId: SessionId(randomUUID()), - meta: { - ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {}, - parentSession: parentHeader.id, - // Record the seed boundary so a reload (and a replay harness) can tell the - // inherited prefix from the child's OWN events. 0 for a fresh spawn. - ...seedLength > 0 ? { seedLength } : {}, - }, - ...options.seed !== undefined ? { seed: options.seed } : {}, - agentOptions, - setup, - }) - const child = handle.agent - - // Structured-concurrency link: the child's teardown rides the PARENT's - // scope, so a disposed parent reaches its whole subtree even if the - // delegating tool's `finally` never runs — through the MEMOIZED handle, so - // every path (tool finally, parent teardown, owner unload) observes the - // same quiescence boundary. Registered AFTER the child exists; if the - // parent began disposing in between, the registration throws - // INACTIVE_EFFECT — dispose the fresh child before rethrowing (no orphan). - // Definite assignment: the catch rethrows, so past this block the unlink - // disposer always exists. - let unlink!: () => Promise | void - try { - unlink = request.parent.ctx.effect(() => () => handle.dispose()) - } catch (error: unknown) { - // Fire-and-forget: start() must rethrow synchronously; the child's - // teardown (stop → unregister → detach) reaches quiescence on its own. - void handle.dispose() - throw error - } - // Bridge the request's abort signal to the child (the consumer also bridges // its own exec.signal, but a backend-level bridge keeps the contract local). + // Install it after provider ownership succeeds but BEFORE awaiting creation, + // so an inactive provider cannot leave an orphaned listener and abort/dispose + // during async setup is still recorded and applied the moment a child exists. // `cancelled` records that a cancel was requested at all, so the pre-turn // cancel window — where the child clears the queued prompt before any // `turn/end` is logged — settles as `aborted` (honoring the cancel contract) @@ -220,31 +213,104 @@ export function startInProcessRun( // abort listener, run.cancel), which control-flow narrowing cannot see — an // inline read at the result mapping would narrow to the initializer. const isCancelled = (): boolean => cancelled + let child: Agent | undefined + let handle: AgentHandle | undefined + let disposeRequested = false + const isDisposeRequested = (): boolean => disposeRequested const requestCancel = (reason: string): void => { cancelled = true - child.cancel(reason) + child?.cancel(reason) } const onAbort = (): void => { requestCancel('subagent cancelled') } - request.signal?.addEventListener('abort', onAbort, { once: true }) + + // One run-owned Cordis fiber is the common ownership node. Install the + // provider effect FIRST: a start racing an already-unloading provider fails + // before it can mint anything under the parent. The owner fiber is then + // nested under the parent scope, and the provider/run handle both dispose + // this exact fiber. AgentFactory binds its lifecycle to `ownerCtx`, so any of + // the three owners moves the fiber out of ACTIVE synchronously and setup + // cannot publish afterward. + let ownerCtx: Context | undefined + function subagentRunOwner(inner: Context): void { ownerCtx = inner } + let ownerFiber: (Fiber & PromiseLike) | undefined + let ownerSetupError: unknown + let ownerDisposing: Promise | undefined + const disposeOwner = (): Promise => (ownerDisposing ??= ownerFiber === undefined + ? Promise.resolve() + : quiesceFiber(ownerFiber)) + let manualDisposeRequested = false + const isManualDisposeRequested = (): boolean => manualDisposeRequested + const unlinkProvider = ctx.effect(() => () => { + requestCancel('subagent provider disposed') + return disposeOwner() + }, 'subagent-inprocess.run()') + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) requestCancel('subagent cancelled') + try { + ownerFiber = parent.ctx.plugin(Object.assign(subagentRunOwner, { + inject: ['agents', 'sessions', 'llm', 'tools', 'systemPrompt'], + })) + } catch (error: unknown) { + ownerSetupError = error + } + + const creation: Promise = (async () => { + if (ownerSetupError !== undefined) { + throw ownerSetupError instanceof Error + ? ownerSetupError + : new Error('subagent run owner setup failed with a non-Error value', { cause: ownerSetupError }) + } + await ownerFiber + if (ownerCtx === undefined) { + throw new Error('subagent run owner became inactive before child creation') + } + // Invoke the factory THROUGH the parent scope. Cordis binds the factory's + // lifecycle effect to the accessing context, so parent ownership exists + // before persistence/setup and publication—not as a fallible link added + // after the child is already visible. A disposed parent therefore rejects + // before any session/agent notification, and disposal during async setup + // wins the unpublished transaction. + const created = await ownerCtx.agents.create({ + agentId: childId, + sessionId: SessionId(randomUUID()), + meta: { + ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {}, + parentSession: parentHeader.id, + ...seedLength > 0 ? { seedLength } : {}, + }, + ...seed !== undefined ? { seed } : {}, + agentOptions, + setup, + }) + handle = created + child = created.agent + + if (isCancelled()) created.agent.cancel('subagent cancelled') + return created.agent + })() const result: Promise = (async () => { try { - // A signal already aborted BEFORE the run starts never fires an `abort` - // event (`addEventListener` only fires on the transition), so the listener - // above won't catch it — settle `aborted` without running the child rather - // than completing an already-cancelled request. - if (request.signal?.aborted) return { output: [], stopReason: 'aborted' } - child.send(request.prompt) - await child.whenIdle() + let liveChild: Agent + try { + liveChild = await creation + } catch (error: unknown) { + if (isManualDisposeRequested()) return { output: [], stopReason: 'aborted' } + throw error instanceof Error ? error : new Error('subagent child creation failed with a non-Error value', { cause: error }) + } + if (isCancelled() || isDisposeRequested()) return { output: [], stopReason: 'aborted' } + liveChild.send(prompt) + await liveChild.whenIdle() // Deliberately NO re-prompt when a structured child finishes cleanly // without calling structured_output: readResult maps that to `error` — // the shortfall goes to the parent instead of buying extra model turns. - return readResult(child, seedLength, isCancelled(), structured ? { captured: structured.captured() } : undefined) + return readResult(liveChild, seedLength, isCancelled(), structured ? { captured: structured.captured() } : undefined) } finally { - request.signal?.removeEventListener('abort', onAbort) + signal?.removeEventListener('abort', onAbort) } })() + let disposing: Promise | undefined return { id: childId, result, @@ -252,13 +318,26 @@ export function startInProcessRun( requestCancel(reason ?? 'subagent cancelled') }, async dispose(): Promise { - request.signal?.removeEventListener('abort', onAbort) - // Through the parent-scope unlink when the parent is still live (one - // disposal path, and the dead effect leaves the parent's list); the - // memoized handle keeps a direct dispose equivalent if the parent's - // teardown already ran the unlink. - await unlink() - await handle.dispose() + return (disposing ??= (async () => { + signal?.removeEventListener('abort', onAbort) + disposeRequested = true + manualDisposeRequested = true + requestCancel('subagent disposed during creation') + // Removing provider ownership and disposing the common run-owner fiber + // are the same quiescence transaction; parent disposal may already have + // claimed it, in which case disposeOwner follows fiber inertia. + await unlinkProvider() + try { + await creation + } catch { + // Creation rollback already reached quiescence; there is no handle + // left to dispose, and dispose must not mask result's infrastructure + // rejection with the same error from a finally block. + return + } + await disposeOwner() + await handle?.dispose() + })()) }, } } diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index 1b1bef839b..dff63486ed 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -14,44 +14,39 @@ * a disposed child leaves no residue — no placeholder schema, * strip-for-everyone-else pass, or refcounted global runtime. * - * Four listeners enforce the contract: + * The child scope's registrations enforce the contract: * - * - `system-prompt/assemble` (prepend, scoped): assembly re-assert — the - * listener post-processes its downstream chain so a listener inside that - * chain cannot leave the child's capture tool or instruction stripped or - * replaced. Tools are replaced in place and the section is re-inserted at - * its ascending-order position, so the untampered path keeps the registry's - * ordering (up to intra-band section order, which carries no contract). A - * listener prepended later can still wrap and transform this result; this is - * an ordinary waterfall listener, not a service-level finalizer. The loop - * logs the rendered assembly as the request header, so the demand is - * reconstructable log state, never a wire-only mutation. - * - `agent/turn-continuation` (prepend, scoped): stop the child's turn once - * its output is captured — the loop's default "had tool calls ⇒ continue" - * would buy a wasted extra model step per structured child. - * - `tools/pre-execute` (prepend, scoped): terminal means terminal WITHIN the - * step — deny every call arriving after the capture, so a response that - * lists `structured_output` before further tool calls cannot run side - * effects after the final answer was accepted. - * - `tools/post-execute` (prepend, scoped): the capture COMMIT. The tool body - * only STAGES the validated value, KEYED BY THE EXECUTION OBJECT in a - * WeakMap; it becomes the run's captured result when this listener's - * downstream post-execute decision accepts THAT SAME pipeline trip. A - * later-prepended wrapper remains outside that decision. Execution-keyed - * staging makes the stale-stage class structurally impossible: a value - * orphaned by an outer short-circuiting listener (a post-execute block, or - * a pre-execute deny whose call never dispatched) can never match another - * execution's lookup — whatever call id that execution carries — and is - * reclaimed with the execution object itself. + * - `systemPrompt.protect()` declaratively protects the capture tool and its + * instruction. The service restores their canonical pre-waterfall state + * after EVERY assembly listener. Canonical absence is protected too: pure + * Code Mode keeps `structured_output` in the SDK only and never grows a + * second native wire tool. Code Mode's owner independently protects its SDK + * and `run_code` transport. The loop logs the finalized assembly as the + * request header, so the demand is reconstructable log state, never a + * wire-only mutation. + * - `agent/turn-stop` (serial, scoped): stop the child's turn once its output + * is captured. This terminal checkpoint runs after the ordinary continuation + * waterfall and steering folding, so listener order cannot resurrect a + * completed structured run or carry terminal steering into another turn. + * - `tools.guard()` is the monotonic terminal gate after the extensible + * pre-execute waterfall: once capture commits, no later listener can turn + * the denial back into a dispatched side effect. + * - `tools/result` is the capture COMMIT point. The tool body only STAGES the + * validated value in a WeakMap keyed by the execution object; the awaited, + * non-transforming notification promotes it only when the authoritative + * result after the whole pre/execute/post pipeline succeeds. For a Code Mode + * sub-dispatch, promotion waits again for the enclosing `run_code` result, so + * a runtime failure or outer post-policy block cannot report structured + * success. Execution identity makes call-id reuse and orphaned stages + * irrelevant. * * @module @deepseek-ai/dsh-subagent-inprocess/structured */ import type { Context } from 'cordis' -import type { Agent, ContinuationDecision } from '@deepseek-ai/dsh-agent' +import type { ContinuationStop } from '@deepseek-ai/dsh-agent' import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' -import type { AssembleContext, PromptAssembly } from '@deepseek-ai/dsh-system-prompt' -import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import type { ToolExecution } from '@deepseek-ai/dsh-tools' import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } from '@deepseek-ai/dsh-tools' /** The model-facing tool name a structured child must call to finish. */ @@ -71,7 +66,7 @@ export const STRUCTURED_OUTPUT_INSTRUCTION export interface StructuredAttachment { /** * The captured value, once the child called the tool with valid arguments - * and the final post-execute decision accepted that call. + * and the authoritative final tool result accepted that call. * @returns the committed value, or undefined while none was accepted. */ captured(): { value: unknown } | undefined @@ -80,7 +75,7 @@ export interface StructuredAttachment { /** * Attach the structured-output runtime to a child for `schema`: register the * scoped capture tool (real schema), the scoped instruction section, and the - * four scoped enforcement listeners (see the module doc). Call from the + * scoped enforcement registrations (see the module doc). Call from the * agent-creation `setup` window with the child's scope context — every * registration rides the child's fiber and unwinds with the child. * @param childCtx - the child agent's scope context (`setup`'s argument). @@ -91,19 +86,16 @@ export interface StructuredAttachment { export function attachStructuredRuntime(childCtx: Context, schema: StructuredOutputSchema): StructuredAttachment { /** * Validated values staged by the capture tool body, awaiting THEIR OWN - * call's post-execute verdict — keyed by the {@link ToolExecution} OBJECT, - * the one token that provably ties a stage to one trip through the - * pipeline. A call id cannot key this: ids are adapter-minted and may - * repeat across steps. Keying by execution makes the stale-stage class - * structurally impossible — an entry orphaned by an outer short-circuiting - * listener can never match a different execution's lookup, needs no drop - * bookkeeping (the WeakMap reclaims it with the execution object), and two - * in-flight captures can never cross-clobber each other's STAGE should - * tool execution ever go parallel (the loop's documented TODO). Staging is - * the only layer this future-proofs: a parallel-execution cut would still - * owe its own single-accept rule for `captured` itself. + * authoritative `tools/result` notification. The execution object's identity + * uniquely identifies a trip through the pipeline: adapter call ids may + * repeat across steps, but another execution can never reach this WeakMap + * entry. This is distinct from the opaque `ToolExecutionToken` used to + * correlate nested transports. The final notification always deletes its own + * stage, whether the result succeeded or failed. */ const staged = new WeakMap() + /** Successful nested capture waiting for its enclosing transport to commit. */ + let pending: { parent: ToolExecution['token']; value: unknown } | undefined let captured: { value: unknown } | undefined const schemaEntry: ToolSchema = { @@ -123,10 +115,10 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut // ToolArgsError → isError result with INVALID_ARGS: the model retries // within the same turn, exactly like a schema-validated defineTool call. if (violations.length > 0) throw new ToolArgsError(violations) - // Two-phase commit, KEYED BY THIS EXECUTION: the body only stages; the - // post-execute listener promotes exactly this pipeline trip's entry - // when its downstream decision accepts it. - staged.set(exec, { value: args }) + // Two-phase commit, keyed by THIS execution: later transformable + // waterfalls may still turn the success into an error. Snapshot the + // validated value independently of the already-frozen pipeline arguments. + staged.set(exec, { value: structuredClone(args) }) return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }]) }, }) @@ -137,105 +129,58 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut text: STRUCTURED_OUTPUT_INSTRUCTION, }) - // PREPENDED assembly re-assert: scoped dispatch means this fires only for the - // child's assemblies; `await next()` returns whatever this listener's - // downstream chain produced, and the capture tool + instruction are - // re-asserted onto it if anything stripped them. A listener prepended later - // can still wrap and transform the returned assembly; this is not a - // service-level finalizer. - childCtx.on('system-prompt/assemble', async function ( - this: unknown, _assembly: PromptAssembly, _context: AssembleContext, next: () => Promise, - ): Promise { - const final = await next() - // REPLACE, not merely ensure-present: a downstream listener may have - // mutated or injected a same-named entry with the WRONG schema/text, and - // the model-visible demand must be exactly this run's own — the same - // schema validateStructuredValue enforces. Placement-preserving on both - // arrays: the untampered path keeps the registry's ordering (tool order - // is the `toolOrder`/lexicographic contract, section order the ascending - // contract `renderPrompt` trusts), so this never reorders what it only - // re-asserts — up to intra-band section order, which carries no contract - // (a 190-order section registered AFTER this runtime sorts before the - // instruction in the registry but after it here). - const freshTool: ToolSchema = { ...schemaEntry, parameters: structuredClone(schemaEntry.parameters) } - // Tools: replace the first same-named entry IN PLACE (its position is the - // chain's product; a tool's list position carries no semantic band to - // restore), drop any duplicates, append only when stripped entirely. - const tools: ToolSchema[] = [] - let toolReplaced = false - for (const tool of final.tools) { - if (tool.name !== STRUCTURED_OUTPUT_TOOL) { - tools.push(tool) - } else if (!toolReplaced) { - tools.push(freshTool) - toolReplaced = true + // Service-owned finalization, not waterfall ordering. The canonical + // assembly determines both presence and absence: native/both modes restore + // the capture schema on the wire, while pure Code Mode removes any injected + // native entry. ToolRegistry's own protection independently restores the SDK + // section and run_code transport that carry the same schema. + childCtx.systemPrompt.protect({ + sections: [`tool:${STRUCTURED_OUTPUT_TOOL}`], + tools: [STRUCTURED_OUTPUT_TOOL], + }) + + // Stop the child's turn once its output is captured. This monotonic serial + // checkpoint runs after the ordinary continuation waterfall, its reason, + // and late-steering folding, so no ordering trick can resume a finished run. + childCtx.on('agent/turn-stop', function (this: unknown): ContinuationStop | undefined { + return captured === undefined ? undefined : { action: 'stop' } + }) + + // Terminal WITHIN the step. Guards run after the whole pre-execute + // waterfall and compose monotonically (deny or abstain, never allow), so a + // later prepended listener cannot resurrect dispatch. Calls that precede + // capture in the same response remain untouched. + childCtx.tools.guard(exec => captured === undefined && pending === undefined + ? undefined + : `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`) + + // The capture COMMIT observes the immutable, authoritative result after the + // complete pipeline and outer error normalization. This notification cannot + // transform the outcome, so there is no wrapper outside the commit verdict. + childCtx.on('tools/result', function (this: unknown, exec, result): void { + if (exec.name === STRUCTURED_OUTPUT_TOOL) { + const entry = staged.get(exec) + if (entry === undefined) return + staged.delete(exec) + if (result.isError) return + if (exec.parent === undefined) { + /* v8 ignore else -- sequential agent-loop dispatch lets the guard block every later supported call */ + if (captured === undefined) captured = { value: entry.value } + } else { + /* v8 ignore else -- Code Mode serializes sub-dispatches, so the guard blocks every later supported call */ + if (captured === undefined && pending === undefined) { + pending = { parent: exec.parent, value: entry.value } + } } + return } - if (!toolReplaced) tools.push(freshTool) - final.tools = tools - // Sections: remove every same-named entry and re-insert at the - // ascending-correct position (the first entry above order 190) — sections - // DO carry an order contract, and the renderer reads array order, so a - // stripped-or-moved instruction is restored to its band, not appended - // after unrelated higher-order sections. On the untampered path this - // lands at the end of the 190 band — where the registry's stable sort - // put it too, unless another 190-order section registered later. - const sectionName = `tool:${STRUCTURED_OUTPUT_TOOL}` - const sections = final.sections.filter(section => section.name !== sectionName) - const insertAt = sections.findIndex(section => section.order > 190) - sections.splice(insertAt === -1 ? sections.length : insertAt, 0, { name: sectionName, order: 190, text: STRUCTURED_OUTPUT_INSTRUCTION }) - final.sections = sections - return final - }, { prepend: true }) - - // Stop the child's turn once its output is captured. `prepend: true` puts - // the veto OUTERMOST — an earlier-registered listener that short-circuits - // the chain (a goal-style force-continue returning without `next()`) would - // otherwise decide the turn before this listener ever ran, and no - // downstream decision may resurrect a structured turn that is finished. - childCtx.on('agent/turn-continuation', function ( - this: unknown, _agent: Agent, _turn: number, _decision: ContinuationDecision, next: () => Promise, - ): Promise { - if (captured) return Promise.resolve({ action: 'stop' }) - return next() - }, { prepend: true }) - - // Terminal WITHIN the step: deny every call after the capture. Calls that - // PRECEDE the capture in the same response ran before `captured` was set - // and are untouched; a second `structured_output` is denied like any other. - childCtx.on('tools/pre-execute', function ( - this: unknown, exec: ToolExecution, next: () => Promise, - ): Promise { - if (captured) { - return Promise.resolve({ - kind: 'deny', - reason: `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`, - }) - } - return next() - }, { prepend: true }) - - // The capture COMMIT: promote a staged value only when the final - // post-execute decision accepts THE SAME EXECUTION that staged it — the - // lookup key IS the execution, so a stale entry from a different pipeline - // trip (its own chain short-circuited past this commit by an outer - // post-execute block, or an outer pre-execute deny whose call never - // dispatched) is unreachable here by construction, whatever the current - // call's id. - childCtx.on('tools/post-execute', async function ( - this: unknown, exec: ToolExecution, _result: ToolExecutionResult, next: () => Promise, - ): Promise { - if (exec.name !== STRUCTURED_OUTPUT_TOOL) return next() - const entry = staged.get(exec) - if (entry === undefined) return next() - // Single-shot per execution: this trip's verdict is decided by the chain - // below, never revisited (the WeakMap would reclaim the entry either way; - // deleting states the intent). - staged.delete(exec) - const decision = await next() - if (decision.kind === 'accept') captured = { value: entry.value } - return decision - }, { prepend: true }) + if (pending?.parent !== exec.token) return + const entry = pending + pending = undefined + if (result.isError) return + /* v8 ignore else -- Code Mode serializes outer executions, so the guard blocks every later supported call */ + if (captured === undefined) captured = { value: entry.value } + }) return { captured: () => captured } } diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 3a024c7e66..1e63617154 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -9,7 +9,8 @@ import type { ContinuationDecision } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools' +import type { Config as ToolConfig, StructuredOutputSchema } from '@deepseek-ai/dsh-tools' +import { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { startInProcessRun } from '../src/index.ts' import { @@ -19,6 +20,15 @@ import { type Script = ConstructorParameters[0] +interface CodeRunRequestLike { + bindings: { global: string; functions: Record Promise> }[] +} + +interface SetupOptions { + toolMode?: ToolConfig['mode'] + codeRun?: (request: CodeRunRequestLike) => Promise<{ logs: never[]; value?: unknown }> +} + const SCHEMA: StructuredOutputSchema = { type: 'object', properties: { answer: { type: 'number' }, note: { type: 'string' } }, @@ -33,13 +43,20 @@ const SCHEMA: StructuredOutputSchema = { * coverage lives in the spawn/fork specs. The mock model script drives the * child's structured_output calls. */ -async function setup(script: Script) { +async function setup(script: Script, options: SetupOptions = {}) { const ctx = new Context() const adapter = new MockAdapter(script) await ctx.plugin(LlmService) await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) + await ctx.plugin(ToolRegistry, { mode: options.toolMode ?? 'native' }) + if (options.toolMode === 'code' || options.toolMode === 'both') { + ctx.provide('codeRuntime', { + language: 'typescript', + isolation: 'test', + run: options.codeRun ?? (() => Promise.resolve({ logs: [] })), + } as never) + } await ctx.plugin(AgentRegistry) await ctx.plugin(Invariants) await ctx.plugin(AgentLoop, { agents: [] }) @@ -48,7 +65,7 @@ async function setup(script: Script) { name: 'spawn', capabilities: { outputSchema: true, depthLimit: true, toolFilter: false, persona: false }, inheritsParentContext: false, - start: (request: SubagentStartRequest) => startInProcessRun(ctx, request, { providerName: 'spawn' }), + start: (request: SubagentStartRequest) => startInProcessRun(ctx, request, {}), }) ctx.llm.registerAdapter(['mock'], adapter) const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) @@ -121,6 +138,44 @@ describe('in-process structured output', () => { await run.dispose() }) + it('a later prepended pre-execute listener cannot resurrect dispatch after capture', async () => { + const response = [ + ...toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }).slice(0, -2), + { type: 'block-start', index: 1, blockType: 'tool-call' }, + { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c2'), name: 'side_effect', arguments: '{}' } }, + { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ] as Script[number] + const { ctx, parent } = await setup([response]) + let sideEffectRan = false + ctx.tools.register({ + name: 'side_effect', + description: 'probe', + parameters: { type: 'object', properties: {} }, + execute(): Promise { + sideEffectRan = true + return Promise.resolve([{ type: 'text', text: 'ran' }]) + }, + }) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + // Registered after the child and prepended: this listener returns allow + // after every downstream pre-execute decision. The service-owned guard + // runs after the waterfall and can only deny, so the body still cannot run. + ctx.on('tools/pre-execute', async (_exec, next) => { + await next() + return { kind: 'allow' as const } + }, { prepend: true }) + + const result = await run.result + expect(result.structured).toEqual({ answer: 5 }) + expect(sideEffectRan).toBe(false) + const child = ctx.agents.get(run.id) + const sideEffectResult = child?.session.events.find(event => + event.type === 'tool/result' && event.data.callId === 'c2') + expect(sideEffectResult?.type === 'tool/result' && sideEffectResult.data.isError).toBe(true) + await run.dispose() + }) + it('leaves tool calls that PRECEDE the capture in the same response untouched', async () => { const response = [ { type: 'block-start', index: 0, blockType: 'tool-call' }, @@ -173,23 +228,67 @@ describe('in-process structured output', () => { await run.dispose() }) - it('the captured-turn veto is prepend: an EARLIER force-continue listener cannot short-circuit it', async () => { - // A goal-style listener registered BEFORE the child exists, returning a - // forced continue WITHOUT calling next(). Without prepend on the scoped - // veto, this would decide the turn first and buy a wasted model step — - // the one-response script would then throw on the second request. + it('a later-prepended continuation wrapper cannot resurrect a captured turn', async () => { const { ctx, parent, adapter } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }), + textResponse('MUST NOT BE CONSUMED'), ]) - ctx.on('agent/turn-continuation', () => Promise.resolve({ action: 'continue' })) + ctx.on('agent/turn-continuation', () => Promise.resolve({ action: 'stop' })) const run = ctx.subagents.start('spawn', structuredRequest(parent)) + let wrapperInstalled = false + // Register this observer only after start() returns. The child session-start + // boundary is after its unpublished setup attached structured output but + // before the loop can run; install a prepended wrapper there. It awaits the + // explicit downstream stop above, then overwrites that result with continue. + // The later terminal checkpoint still wins. + ctx.on('agent/session-start', (child) => { + if (child.id !== run.id) return + wrapperInstalled = true + child.ctx.on('agent/turn-continuation', async (_subject, _turn, _decision, next): Promise => { + const downstream = await next() + expect(downstream).toEqual({ action: 'stop' }) + return { action: 'continue' } + }, { prepend: true }) + }) const result = await run.result + expect(wrapperInstalled).toBe(true) expect(result.structured).toEqual({ answer: 7 }) expect(result.stopReason).toBe('completed') expect(adapter.requests).toHaveLength(1) await run.dispose() }) + it('a continuation wrapper cannot carry steering past a captured terminal stop', async () => { + const { ctx, parent, adapter } = await setup([ + toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }), + textResponse('MUST NOT BE CONSUMED'), + ]) + // The downstream ordinary policy says stop. A wrapper registered after + // start() delegates to that stop, then queues steering; ordinary folding + // would turn the stop back into continue. The terminal checkpoint runs + // afterwards and discards that steering. + ctx.on('agent/turn-continuation', () => Promise.resolve({ action: 'stop' })) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + ctx.on('agent/session-start', (child) => { + if (child.id !== run.id) return + child.ctx.on('agent/turn-continuation', async (subject, _turn, _decision, next): Promise => { + const downstream = await next() + expect(downstream).toEqual({ action: 'stop' }) + subject.steer([{ type: 'text', text: 'late steering after downstream stop' }]) + return downstream + }, { prepend: true }) + }) + + const result = await run.result + const child = ctx.agents.get(run.id) + + expect(result.structured).toEqual({ answer: 9 }) + expect(adapter.requests).toHaveLength(1) + expect(child?.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(child?.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0) + await run.dispose() + }) + it('an invalid call gets an INVALID_ARGS isError result and the model retries in-turn', async () => { const { ctx, parent } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 'not-a-number' }), @@ -236,11 +335,11 @@ describe('in-process structured output', () => { it('a cancel landing after a clean capture-less turn settles aborted, not error', async () => { const { ctx, parent } = await setup([textResponse('prose, no capture')]) const run = ctx.subagents.start('spawn', structuredRequest(parent)) - const child = ctx.agents.get(run.id)! // Cancel synchronously inside the turn's end recording: the cancel // contract outranks the schema shortfall, so the result maps to aborted. ctx.on('session/event', (session, event) => { - if (session === child.session && event.type === 'turn/end') run.cancel('cancelled at turn end') + const child = ctx.agents.get(run.id) + if (session === child?.session && event.type === 'turn/end') run.cancel('cancelled at turn end') }) const result = await run.result expect(result.stopReason).toBe('aborted') @@ -270,8 +369,8 @@ describe('in-process structured output', () => { toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }), textResponse('continues after the blocked capture'), ]) - // A PostToolUse-style hook, registered AFTER the runtime (so the runtime's - // prepend commit listener stays outermost and composes this verdict). + // A PostToolUse-style hook turns the tool body's provisional success into + // the authoritative final error observed by the commit notification. ctx.on('tools/post-execute', (exec, _result, next) => { if (exec.name === STRUCTURED_OUTPUT_TOOL) { return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'capture rejected by hook' }] }) @@ -311,6 +410,31 @@ describe('in-process structured output', () => { await run.dispose() }) + it('commits only after a later prepended post-execute wrapper returns the authoritative result', async () => { + const { ctx, parent } = await setup([ + toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 8 }), + textResponse('capture was rejected'), + ]) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + // Registered after attachment and prepended, so it wraps every listener + // the child installed. It delegates first, then converts the apparent + // capture success into the pipeline's authoritative failure. + ctx.on('tools/post-execute', async (exec, _result, next) => { + const downstream = await next() + if (exec.name !== STRUCTURED_OUTPUT_TOOL) return downstream + return { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected after downstream' }] } + }, { prepend: true }) + + const result = await run.result + expect(result.structured).toBeUndefined() + expect(result.stopReason).toBe('error') + const child = ctx.agents.get(run.id) + const captureResult = child?.session.events.find(event => + event.type === 'tool/result' && event.data.callId === 'c1') + expect(captureResult?.type === 'tool/result' && captureResult.data.isError).toBe(true) + await run.dispose() + }) + it('appends the structured instruction to the child REQUEST\'s system text (base prompt preserved)', async () => { const { ctx, parent, adapter } = await setup([toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 })]) // A context-wide section stands in for the deployment persona: the @@ -327,6 +451,100 @@ describe('in-process structured output', () => { await run.dispose() }) + it('keeps pure Code Mode at one wire tool and exposes structured capture through the SDK only', async () => { + const { ctx, parent, adapter } = await setup([ + toolCallResponse('c1', RUN_CODE_NAME, { code: 'return await tools.structured_output({ answer: 12 })' }), + ], { + toolMode: 'code', + codeRun: async (request) => { + const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL] + if (!capture) throw new Error('structured_output binding missing') + await capture({ answer: 12 }) + return { logs: [], value: 'captured' } + }, + }) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + + // This listener is registered after the child's protection and prepended. + // Service finalization still restores the stripped transport and prompt + // parts, while removing the fabricated native capture tool. + ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { + const result = await next() + return { + sections: result.sections.filter(section => + section.name !== 'tools:sdk' && section.name !== `tool:${STRUCTURED_OUTPUT_TOOL}`), + tools: [ + ...result.tools.filter(tool => tool.name !== RUN_CODE_NAME), + { name: STRUCTURED_OUTPUT_TOOL, description: 'wrong native duplicate', parameters: {} }, + ], + variables: result.variables, + } + }, { prepend: true }) + + const result = await run.result + expect(result.structured).toEqual({ answer: 12 }) + const request = adapter.requests[0]! + expect(toolNames(request)).toEqual([RUN_CODE_NAME]) + expect(request.system).toContain('declare const tools:') + expect(request.system).toContain('structured_output(args:') + expect(request.system).toContain(STRUCTURED_OUTPUT_INSTRUCTION) + await run.dispose() + }) + + it('discards a nested capture when the enclosing run_code execution fails', async () => { + const { ctx, parent, adapter } = await setup([ + toolCallResponse('c1', RUN_CODE_NAME, { code: 'await tools.structured_output({ answer: 12 }); throw new Error("boom")' }), + textResponse('outer code failed'), + ], { + toolMode: 'code', + codeRun: async (request) => { + const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL] + if (!capture) throw new Error('structured_output binding missing') + await capture({ answer: 12 }) + return { + logs: [], + error: { kind: 'runtime', message: 'boom after capture' }, + } as never + }, + }) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + + const result = await run.result + expect(result.structured).toBeUndefined() + expect(result.stopReason).toBe('error') + expect(adapter.requests).toHaveLength(2) + const child = ctx.agents.get(run.id)! + const outer = child.session.events.find(event => + event.type === 'tool/result' && event.data.callId === CallId('c1')) + expect(outer?.type === 'tool/result' && outer.data.isError).toBe(true) + await run.dispose() + }) + + it('discards a nested capture when post-policy blocks the enclosing run_code result', async () => { + const { ctx, parent, adapter } = await setup([ + toolCallResponse('c1', RUN_CODE_NAME, { code: 'return await tools.structured_output({ answer: 12 })' }), + textResponse('outer code was blocked'), + ], { + toolMode: 'code', + codeRun: async (request) => { + const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL] + if (!capture) throw new Error('structured_output binding missing') + await capture({ answer: 12 }) + return { logs: [], value: 'captured' } + }, + }) + ctx.on('tools/post-execute', (exec, _result, next) => exec.name === RUN_CODE_NAME + ? Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer blocked' }] }) + : next()) + const run = ctx.subagents.start('spawn', structuredRequest(parent)) + + const result = await run.result + expect(result.structured).toBeUndefined() + expect(result.stopReason).toBe('error') + expect(adapter.requests).toHaveLength(2) + await run.dispose() + }) + it('the instruction rides ONLY structured requests: appended for the child, absent for a plain agent', async () => { const { ctx, parent, adapter } = await setup([ textResponse('parent answer'), @@ -412,12 +630,12 @@ describe('in-process structured output', () => { await runB.dispose() }) - it('the re-assert REPLACES a conflicting injected schema, not merely ensures presence', async () => { + it('protection replaces a conflicting injected schema, not merely ensuring presence', async () => { const { ctx, parent, adapter } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }), ]) // A global listener that INJECTS a wrong-schema structured_output entry: - // the child's re-assert must replace it with the run's own schema. + // protection restores the run's own canonical schema. ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { const replaced = await next() return { @@ -438,14 +656,14 @@ describe('in-process structured output', () => { await run.dispose() }) - it('the re-assert wins against a downstream listener that REPLACES the assembly object', async () => { + it('protection wins against a listener that replaces the assembly object', async () => { const { ctx, parent, adapter } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }), ]) // A global (every-assembly) listener that returns a brand-new assembly // WITHOUT the capture tool or instruction — the composition caveat that - // erases cooperative mutations. The child's prepend re-assert runs - // OUTERMOST and restores both. + // erases cooperative mutations. Service finalization restores both + // after the complete waterfall. ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { const replaced = await next() return { @@ -465,13 +683,13 @@ describe('in-process structured output', () => { await run.dispose() }) - it('the re-assert preserves the untampered assembly: tool position and section band are the registry\'s own', async () => { + it('protection preserves the canonical tool position and section band', async () => { const { ctx, parent, adapter } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }), ]) // A global tool sorting lexicographically AFTER structured_output and a - // global section ABOVE the 190 band: the re-assert must leave both - // exactly where the registry's ordering put them (no move-to-end). + // global section above the 190 band: protection leaves both exactly + // where the canonical registry ordering put them. ctx.tools.register({ name: 'zz_probe', description: 'probe', @@ -498,7 +716,7 @@ describe('in-process structured output', () => { ]) ctx.systemPrompt.section({ name: 'after-band', order: 200, text: 'AFTER-BAND' }) // Strip the instruction section entirely AND add a wrong-schema - // duplicate tool entry ALONGSIDE the registry's own: the re-assert must + // duplicate tool entry alongside the registry's own: protection must // restore the section INTO its band (before the order-200 section, not // appended after it) and collapse the tools to exactly one entry // carrying the run's schema. @@ -578,15 +796,14 @@ describe('in-process structured output', () => { expect(result.error?.code).toBe('UNKNOWN_TOOL') }) - it('a stale stage from a short-circuited chain is never promoted by a later call (execution-keyed commit)', async () => { + it('a failed execution stage is discarded and never promoted by a later call', async () => { const { ctx, parent } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }), ]) const run = ctx.subagents.start('spawn', structuredRequest(parent)) - const child = ctx.agents.get(run.id)! - // An OUTER post-execute listener (registered after attach, prepend ⇒ - // outermost) that BLOCKS the first capture WITHOUT delegating: the commit - // listener never runs for c1, so its staged value would linger. + // A prepended post-execute listener blocks the first capture without + // delegating. The final-result notification discards that execution's + // stage when it observes the error. let blocks = 1 ctx.on('tools/post-execute', (exec, _result, next) => { if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) { @@ -596,11 +813,12 @@ describe('in-process structured output', () => { return next() }, { prepend: true }) const result = await run.result + const child = ctx.agents.get(run.id)! // The blocked capture must NOT surface as structured success… expect(result.stopReason).toBe('error') expect(result.structured).toBeUndefined() // …and a LATER invalid call (its own body staged nothing) must not - // resurrect c1's orphaned value: drive the pipeline directly. + // resurrect c1's discarded value: drive the pipeline directly. const invalid = await ctx.tools.execute({ callId: 'c2' as never, name: STRUCTURED_OUTPUT_TOOL, @@ -619,14 +837,13 @@ describe('in-process structured output', () => { await run.dispose() }) - it('a later capture call REUSING a stale stage\'s call id never promotes it (unconditional commit safety)', async () => { + it('reusing a failed execution\'s call id never promotes its discarded stage', async () => { const { ctx, parent } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }), ]) const run = ctx.subagents.start('spawn', structuredRequest(parent)) - const child = ctx.agents.get(run.id)! - // Orphan a stage: an outer short-circuiting post-execute BLOCK on the - // first capture (its chain never reaches the commit listener). + // Block the first capture after its body stages a value. Its final error + // discards that execution's stage. let blocks = 1 ctx.on('tools/post-execute', (exec, _result, next) => { if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) { @@ -636,8 +853,9 @@ describe('in-process structured output', () => { return next() }, { prepend: true }) await run.result + const child = ctx.agents.get(run.id)! // A SECOND capture call with the SAME call id whose body never stages - // (invalid args throw before the stage): the stale value must not ride + // (invalid args throw before the stage): the discarded value must not ride // its acceptance. const reused = await ctx.tools.execute({ callId: 'c1' as never, @@ -657,13 +875,12 @@ describe('in-process structured output', () => { await run.dispose() }) - it('an outer pre-execute deny with call-id reuse cannot promote an orphaned stage either', async () => { + it('a pre-execute deny with call-id reuse cannot promote another execution\'s stage', async () => { const { ctx, parent } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }), ]) const run = ctx.subagents.start('spawn', structuredRequest(parent)) - const child = ctx.agents.get(run.id)! - // Orphan a stage via an outer post-execute BLOCK on the first capture. + // Discard the first capture's stage via a final post-execute block. let blocks = 1 ctx.on('tools/post-execute', (exec, _result, next) => { if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) { @@ -673,9 +890,9 @@ describe('in-process structured output', () => { return next() }, { prepend: true }) await run.result - // An OUTERMOST prepend pre-execute deny: the structured runtime's own - // pre-execute never runs for this call, and the denied call still goes - // through post-execute — with the SAME call id as the orphaned stage. + const child = ctx.agents.get(run.id)! + // A prepended pre-execute deny skips the body, while the denied call still + // reaches the final notification with the same adapter-minted call id. const offDeny = ctx.on('tools/pre-execute', (exec) => { if (exec.name === STRUCTURED_OUTPUT_TOOL) { return Promise.resolve({ kind: 'deny' as const, reason: 'outer veto' }) @@ -690,7 +907,7 @@ describe('in-process structured output', () => { }) expect(denied.isError).toBe(true) offDeny() - // The orphan was never promoted: a fresh valid call is still required + // The discarded value was never promoted: a fresh valid call is required // (and succeeds, proving the runtime is not wedged). const valid = await ctx.tools.execute({ callId: 'c1' as never, diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 7219e03988..648ee5935f 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import { Context, type Fiber } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -49,9 +49,166 @@ describe('depthOf', () => { }) describe('startInProcessRun', () => { + it('rejects a non-JSON prompt before acquiring any run ownership', async () => { + const { ctx, parent } = await setup([]) + expect(() => startInProcessRun(ctx, { + prompt: [{ type: 'text', text: Number.NaN as unknown as string }], + parent, + }, {})).toThrow('subagent prompt must be losslessly JSON-serializable') + }) + + it('rejects a prompt whose getter becomes non-JSON while it is snapshotted', async () => { + const { ctx, parent } = await setup([]) + let reads = 0 + const prompt = [{ + type: 'text' as const, + get text(): string { + reads += 1 + return reads === 1 ? 'valid during pre-check' : Number.NaN as unknown as string + }, + }] + + expect(() => startInProcessRun(ctx, { prompt, parent }, {})) + .toThrow('subagent prompt must be stable losslessly JSON-serializable data') + expect(reads).toBe(2) + }) + + it('rejects when the run-owner fiber settles without installing its context', async () => { + const { ctx, parent } = await setup([]) + function inertOwner(): void {} + const inertFiber = ctx.plugin(inertOwner) + await inertFiber + const parentWithoutOwnerContext = { + options: parent.options, + session: parent.session, + ctx: { plugin: () => inertFiber }, + } as unknown as Agent + + const run = startInProcessRun(ctx, { + prompt: [{ type: 'text', text: 'must never start' }], + parent: parentWithoutOwnerContext, + }, {}) + await expect(run.result).rejects.toThrow('subagent run owner became inactive before child creation') + await run.dispose() + }) + + it('normalizes a non-Error thrown while installing the run-owner fiber', async () => { + const { ctx, parent } = await setup([]) + const setupFailure = 'non-Error owner setup failure' + const parentWithFailingOwnerSetup = { + options: parent.options, + session: parent.session, + ctx: { plugin: () => { throw setupFailure } }, + } as unknown as Agent + + const run = startInProcessRun(ctx, { + prompt: [{ type: 'text', text: 'must never start' }], + parent: parentWithFailingOwnerSetup, + }, {}) + await expect(run.result).rejects.toMatchObject({ + message: 'subagent run owner setup failed with a non-Error value', + cause: setupFailure, + }) + await run.dispose() + }) + + it('normalizes a non-Error rejected by asynchronous child creation', async () => { + const { ctx, parent } = await setup([]) + const creationFailure = 'non-Error child creation failure' + function inertOwner(): void {} + const ownerFiber = ctx.plugin(inertOwner) + await ownerFiber + const rejectWithNonError = (): Promise => { + // Deliberately violate the promise contract to exercise boundary normalization. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + return Promise.reject(creationFailure) + } + const rejectingOwnerCtx = { + agents: { create: rejectWithNonError }, + } as unknown as Context + const parentWithRejectingFactory = { + options: parent.options, + session: parent.session, + ctx: { + plugin(plugin: (inner: Context) => void) { + plugin(rejectingOwnerCtx) + return ownerFiber + }, + }, + } as unknown as Agent + + const run = startInProcessRun(ctx, { + prompt: [{ type: 'text', text: 'must never start' }], + parent: parentWithRejectingFactory, + }, {}) + await expect(run.result).rejects.toMatchObject({ + message: 'subagent child creation failed with a non-Error value', + cause: creationFailure, + }) + await run.dispose() + }) + + it('follows owner-fiber inertia when raw teardown was already in flight', async () => { + const { ctx, parent } = await setup([]) + const gate = Promise.withResolvers() + let inertia: Promise | undefined = gate.promise + const fakeFiber = { + dispose: vi.fn(() => undefined), + get inertia() { return inertia }, + } as unknown as Fiber & PromiseLike + const rejectingOwnerCtx = { + agents: { create: () => Promise.reject(new Error('creation stopped by teardown')) }, + } as unknown as Context + const parentWithDisposingOwner = { + options: parent.options, + session: parent.session, + ctx: { + plugin(plugin: (inner: Context) => void) { + plugin(rejectingOwnerCtx) + return fakeFiber + }, + }, + } as unknown as Agent + const run = startInProcessRun(ctx, { + prompt: [{ type: 'text', text: 'must never start' }], + parent: parentWithDisposingOwner, + }, {}) + + let settled = false + const disposing = run.dispose().then(() => { settled = true }) + await Promise.resolve() + await Promise.resolve() + expect(fakeFiber.dispose).toHaveBeenCalledOnce() + expect(settled).toBe(false) + + inertia = undefined + gate.resolve(undefined) + await disposing + await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' }) + }) + + it('does not attach an abort listener when provider ownership is already inactive', async () => { + const { ctx, parent } = await setup([]) + let providerCtx: Context | undefined + function provider(inner: Context): void { providerCtx = inner } + const providerFiber = await ctx.plugin(provider) + await providerFiber.dispose() + if (providerCtx === undefined) throw new Error('provider context was not captured') + const inactiveProviderCtx = providerCtx + + const controller = new AbortController() + const addListener = vi.spyOn(controller.signal, 'addEventListener') + expect(() => startInProcessRun(inactiveProviderCtx, { + prompt: [{ type: 'text', text: 'must never start' }], + parent, + signal: controller.signal, + }, {})).toThrow(/inactive context/) + expect(addListener).not.toHaveBeenCalled() + }) + it('drives a fresh child (no seed) to completion and returns its output', async () => { const { ctx, parent } = await setup([textResponse('driver child answer')]) - const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, { providerName: 'spawn' }) + const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, {}) const result = await run.result expect(result.stopReason).toBe('completed') expect(text(result.output)).toBe('driver child answer') @@ -59,9 +216,25 @@ describe('startInProcessRun', () => { await run.dispose() }) + it('snapshots the prompt before asynchronous child creation', async () => { + const { ctx, parent } = await setup([textResponse('done')]) + const prompt = [{ type: 'text' as const, text: 'original prompt' }] + const run = startInProcessRun(ctx, { prompt, parent }, {}) + + prompt[0]!.text = 'mutated after start' + prompt.push({ type: 'text', text: 'also injected' }) + await run.result + + const child = ctx.agents.get(run.id)! + const userMessage = child.session.events.find(event => event.type === 'user/message') + expect(userMessage?.type === 'user/message' && userMessage.data.content) + .toEqual([{ type: 'text', text: 'original prompt' }]) + await run.dispose() + }) + it('throws SubagentDepthError when the child would exceed maxDepth', async () => { const { ctx, parent } = await setup([]) - expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, { providerName: 'spawn' })) + expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, {})) .toThrow(SubagentDepthError) }) @@ -73,7 +246,7 @@ describe('startInProcessRun', () => { parent.send([{ type: 'text', text: 'parent q' }]) await parent.whenIdle() const seed = parent.session.events.slice() - const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { providerName: 'fork', seed }) + const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { seed }) const result = await run.result expect(result.stopReason).toBe('completed') expect(text(result.output)).toBe('seeded child reply') diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts index 61da4eb560..835cf34aae 100644 --- a/packages/subagent/subagent-spawn/src/index.ts +++ b/packages/subagent/subagent-spawn/src/index.ts @@ -29,7 +29,7 @@ export const name = 'subagent-spawn' // output through the child's creation context, whose factory already requires // the tool service. Keeping it out of this backend's inject list preserves the // provider's independent apply timing. -export const inject = ['subagents', 'agents'] +export const inject = ['subagents'] /** Config: the registry name to register the provider under. */ export interface Config { @@ -59,7 +59,7 @@ class SpawnProvider implements SubagentProvider { // Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/ // depth, drives the one-shot (including the structured capture when the // request carries an outputSchema), and maps the result. - return startInProcessRun(this.ctx, request, { providerName: this.name }) + return startInProcessRun(this.ctx, request, {}) } } diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 88f02070a6..a8dde937c0 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import LlmService from '@deepseek-ai/dsh-llm' @@ -160,6 +160,38 @@ describe('dsh-subagent-spawn', () => { await run.dispose() }) + it('a cancel from agent/queued maps a no-turn child log to aborted', async () => { + const { ctx, parent } = await setup([]) + ctx.on('agent/queued', (agent) => { + if (agent.id === run.id) run.cancel('queued-window') + }) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + const result = await run.result + expect(result).toMatchObject({ stopReason: 'aborted', output: [] }) + const child = ctx.agents.get(run.id)! + expect(child.session.events.some(event => event.type === 'turn/end')).toBe(false) + await run.dispose() + }) + + it('dispose during async child creation waits for rollback and leaves no orphan', async () => { + const { ctx, parent } = await setup([]) + const beforeAgents = ctx.agents.list().length + const beforeSessions = ctx.sessions.list().length + const published: string[] = [] + ctx.on('session/created', () => void published.push('session/created')) + ctx.on('agent/created', () => void published.push('agent/created')) + ctx.on('agent/session-start', () => void published.push('agent/session-start')) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + + // Same tick: the factory has reserved ids and entered its async setup + // transaction, but has not published the child yet. + await run.dispose() + await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted', output: [] }) + expect(ctx.agents.list()).toHaveLength(beforeAgents) + expect(ctx.sessions.list()).toHaveLength(beforeSessions) + expect(published).toEqual([]) + }) + it('cancelling a running child settles the run as aborted (the abort bridge + cancel())', async () => { // 'hang' makes the child's model stream one chunk then wait until aborted. const controller = new AbortController() @@ -206,7 +238,7 @@ describe('dsh-subagent-spawn', () => { it('inherits the parent cwd into the child session', async () => { const { ctx } = await setup([textResponse('x')]) // A parent WITH a cwd (config agents have none, so create one explicitly). - const parentHandle = ctx.agents.create({ + const parentHandle = await ctx.agents.create({ agentId: AgentId('cwd-parent'), sessionId: SessionId('cwd-parent-session'), meta: { cwd: '/tmp/parent-workspace' }, @@ -223,7 +255,7 @@ describe('dsh-subagent-spawn', () => { it('uses request.agentOptions.model when the parent has no model of its own', async () => { const { ctx } = await setup([textResponse('explicit model child')]) // A parent with NO model (its own turns would need one supplied per-request). - const parentHandle = ctx.agents.create({ + const parentHandle = await ctx.agents.create({ agentId: AgentId('modelless-parent'), sessionId: SessionId('modelless-parent-session'), agentOptions: {}, @@ -305,15 +337,70 @@ describe('dsh-subagent-spawn', () => { await run.dispose() }) + it('a backend unload during child creation prevents every publication notification', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(Invariants) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) + ctx.llm.registerAdapter(['mock'], new MockAdapter([])) + const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + const published: string[] = [] + ctx.on('session/created', () => void published.push('session/created')) + ctx.on('agent/created', () => void published.push('agent/created')) + ctx.on('agent/session-start', () => void published.push('agent/session-start')) + + const run = ctx.subagents.start('spawn', { + prompt: [{ type: 'text', text: 'must never run' }], parent, + }) + await fiber.dispose() + await run.result.catch(() => undefined) + await run.dispose() + + expect(ctx.agents.get(run.id)).toBeUndefined() + expect(published).toEqual([]) + }) + + it('a start racing an already-unloading backend cannot mint a run-owner fiber', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) + const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + const parentEffects = parent.ctx.fiber.getEffects().length + const published: string[] = [] + ctx.on('session/created', () => void published.push('session/created')) + ctx.on('agent/created', () => void published.push('agent/created')) + + const unloading = fiber.dispose() + expect(() => ctx.subagents.start('spawn', { + prompt: [{ type: 'text', text: 'must never start' }], parent, + })).toThrow(/inactive context/) + await unloading + + expect(parent.ctx.fiber.getEffects()).toHaveLength(parentEffects) + expect(published).toEqual([]) + }) + it('has the namespace-plugin export shape (no stray default)', () => { expect('default' in spawn).toBe(false) expect(spawn.name).toBe('subagent-spawn') - expect(spawn.inject).toEqual(['subagents', 'agents']) + expect(spawn.inject).toEqual(['subagents']) const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(spawn) as Record expect(unwrapped).toBe(spawn) expect(unwrapped.name).toBe('subagent-spawn') - expect(unwrapped.inject).toEqual(['subagents', 'agents']) + expect(unwrapped.inject).toEqual(['subagents']) expect(typeof unwrapped.apply).toBe('function') }) @@ -369,11 +456,13 @@ describe('dsh-subagent-spawn', () => { it('an unknown toolFilter name fails the spawn loudly with no orphaned child', async () => { const { ctx, parent } = await setup([]) const before = ctx.agents.list().length - expect(() => ctx.subagents.start('spawn', { + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'do X' }], parent, toolFilter: { deny: ['no_such_tool'] }, - })).toThrow(/unknown tool "no_such_tool"/) + }) + await expect(run.result).rejects.toThrow(/unknown tool "no_such_tool"/) + await run.dispose() expect(ctx.agents.list().length).toBe(before) }) }) @@ -381,19 +470,53 @@ describe('dsh-subagent-spawn', () => { it('spawning from a DISPOSING parent fails loud with no orphaned child (INACTIVE_EFFECT teaching error)', async () => { const { ctx } = await setup([]) // A handle-owned parent we can dispose (config agents dispose with the loop fiber). - const parentHandle = ctx.agents.create({ + const parentHandle = await ctx.agents.create({ agentId: AgentId('doomed-parent'), sessionId: SessionId('doomed-s'), agentOptions: { model: 'mock' }, }) await parentHandle.dispose() const before = ctx.agents.list().length - expect(() => ctx.subagents.start('spawn', { + const sessionsBefore = ctx.sessions.list().length + const published: string[] = [] + ctx.on('session/created', () => void published.push('session/created')) + ctx.on('agent/created', () => void published.push('agent/created')) + ctx.on('agent/session-start', () => void published.push('agent/session-start')) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'do X' }], parent: parentHandle.agent, - })).toThrow(/inactive context/) - // The freshly created child's disposal was initiated before the rethrow - // (fire-and-forget — start() throws synchronously); quiescence follows. - await vi.waitFor(() => { expect(ctx.agents.list().length).toBe(before) }) + }) + await expect(run.result).rejects.toThrow(/inactive context/) + await run.dispose() + expect(ctx.agents.list().length).toBe(before) + expect(ctx.sessions.list()).toHaveLength(sessionsBefore) + expect(published).toEqual([]) + }) + + it('parent disposal during the child setup transaction prevents every publication notification', async () => { + const { ctx } = await setup([]) + const parentHandle = await ctx.agents.create({ + agentId: AgentId('setup-race-parent'), + sessionId: SessionId('setup-race-parent-session'), + agentOptions: { model: 'mock' }, + }) + const published: string[] = [] + ctx.on('session/created', () => void published.push('session/created')) + ctx.on('agent/created', () => void published.push('agent/created')) + ctx.on('agent/session-start', () => void published.push('agent/session-start')) + + const run = ctx.subagents.start('spawn', { + prompt: [{ type: 'text', text: 'must never run' }], + parent: parentHandle.agent, + }) + // The factory has entered its awaited unpublished setup transaction. Parent + // ownership was installed before that await, so disposal wins without an + // observer ever seeing the child. + await parentHandle.dispose() + await expect(run.result).rejects.toThrow(/owner disposed during setup|inactive context/) + await run.dispose() + + expect(ctx.agents.get(run.id)).toBeUndefined() + expect(published).toEqual([]) }) }) diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 0a7cea32e1..45287271c6 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -34,6 +34,8 @@ import { Context, Service } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' +import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools' +import type { Scoped } from '@deepseek-ai/dsh-scope' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { Agent, AgentId } from '@deepseek-ai/dsh-agent' @@ -93,7 +95,7 @@ declare module 'cordis' { * @param info - which provider started which child agent. * @mode emit */ - 'subagent/start'(info: SubagentRunInfo): void + 'subagent/start'(this: Scoped, info: SubagentRunInfo): void /** * A subagent run settled — emitted when {@link SubagentRun.result} * resolves (any stop reason). Paired with {@link Events['subagent/start']}. @@ -104,7 +106,7 @@ declare module 'cordis' { * @param info - the run identity plus stop reason and final output. * @mode emit */ - 'subagent/end'(info: SubagentRunEndInfo): void + 'subagent/end'(this: Scoped, info: SubagentRunEndInfo): void } } @@ -224,13 +226,32 @@ export class SubagentService extends Service { * @returns the live run (its `result` resolves when the child settles). */ start(name: string, request: SubagentStartRequest): SubagentRun { + // Parent is the lifecycle scope identity accepted at start. Never reread it + // from the caller-owned request after the provider/result async boundary, + // or start/end could be dispatched into different agent scopes. + const parent = request.parent const provider = this.providers.get(name) if (!provider) { throw new SubagentError(`no subagent provider registered for "${name}"`, 'NO_PROVIDER') } this.assertCapabilities(provider, request) + if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema) - const run = provider.start(request) + // Detach every data field before crossing into a provider. Parent/signal + // are live identity capabilities and stay exact; the mutable request record + // and its arrays/objects are never retained, so every backend (including an + // async out-of-process one) observes the request accepted at start. + const accepted: SubagentStartRequest = { + prompt: structuredClone(request.prompt), + parent, + ...request.signal !== undefined ? { signal: request.signal } : {}, + ...request.agentOptions !== undefined ? { agentOptions: structuredClone(request.agentOptions) } : {}, + ...request.outputSchema !== undefined ? { outputSchema: structuredClone(request.outputSchema) } : {}, + ...request.maxDepth !== undefined ? { maxDepth: request.maxDepth } : {}, + ...request.toolFilter !== undefined ? { toolFilter: structuredClone(request.toolFilter) } : {}, + ...request.persona !== undefined ? { persona: request.persona } : {}, + } + const run = provider.start(accepted) // Emit `subagent/start` with PER-LISTENER containment (see {@link emitLifecycle}): // the run is already live, so neither a throwing subscriber escaping // `start()` (the caller would never receive the run to dispose it — a leaked @@ -238,7 +259,7 @@ export class SubagentService extends Service { // acceptable. `ctx.emit` halts the dispatch on the first throw, so a single // surrounding try/catch is not enough — each listener is invoked and // contained individually. - this.emitLifecycle('subagent/start', { provider: name, id: run.id }, request.parent) + this.emitLifecycle('subagent/start', { provider: name, id: run.id }, parent) // Emit `subagent/end` when the run settles. The result promise does not // reject on a child-level failure (it resolves with stopReason 'error'), // so a rejection here is an infrastructure fault — surface its stop reason @@ -268,9 +289,9 @@ export class SubagentService extends Service { } catch (error: unknown) { this.ctx.logger.warn(`subagent: could not clone ${name} output for subagent/end: ${String(error)}`) } - this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason, ...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {} }, request.parent) + this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason, ...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {} }, parent) }, - () => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }, request.parent) }, + () => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }, parent) }, ) return run } diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 5e6553c48a..a95fbae623 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -120,9 +120,11 @@ export interface SubagentResult { /** The child's final assistant output (the last assistant message's content). */ output: ContentBlock[] /** - * The structured result, present IFF the request carried an `outputSchema` - * AND the provider honored it. Shape is validated against the request schema - * by the provider; `unknown` here because the seam is schema-agnostic. + * The structured result after a requested `outputSchema` was successfully + * satisfied. Requesting a schema does not guarantee presence: a provider can + * end with `stopReason: 'error'` when the child fails or finishes without a + * valid capture. Shape is validated against the request schema by the + * provider; `unknown` here because the seam is schema-agnostic. */ structured?: unknown /** Why the run ended. A non-`completed` reason means `output` may be partial. */ diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index afacd48a9b..3a16b19e22 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' import { HarnessError } from '@deepseek-ai/dsh-llm' +import { carrierKeyOf } from '@deepseek-ai/dsh-scope' import SubagentService, { SubagentError, type SubagentCapabilities, @@ -227,6 +228,45 @@ describe('SubagentService', () => { expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id, stopReason: 'completed' })) }) + it('pins start and end to the parent accepted at start despite caller mutation', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const gate = Promise.withResolvers() + let acceptedRequest: SubagentStartRequest | undefined + ctx.subagents.registerProvider({ + name: 'deferred', + capabilities: NO_CAPS, + inheritsParentContext: false, + start: (accepted) => { + acceptedRequest = accepted + return { + id: AgentId('deferred-child'), + result: gate.promise, + cancel() {}, + async dispose() {}, + } + }, + }) + const accepted = fakeParent('accepted-parent') + const replacement = fakeParent('replacement-parent') + const keys: unknown[] = [] + ctx.on('subagent/start', function () { keys.push(carrierKeyOf(this)) }) + ctx.on('subagent/end', function () { keys.push(carrierKeyOf(this)) }) + const request = baseRequest({ parent: accepted }) + + const run = ctx.subagents.start('deferred', request) + request.parent = replacement + request.prompt[0] = { type: 'text', text: 'mutated prompt' } + expect(acceptedRequest?.parent).toBe(accepted) + expect(acceptedRequest?.prompt).toEqual([{ type: 'text', text: 'do a thing' }]) + expect(acceptedRequest?.prompt).not.toBe(request.prompt) + gate.resolve({ output: [], stopReason: 'completed' }) + await run.result + await Promise.resolve() + + expect(keys).toEqual([accepted, accepted]) + }) + it('carries lastAssistantMessage (the child output) onto the end event', async () => { const ctx = new Context() await ctx.plugin(SubagentService) diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 8fd425392d..e634ec9cc0 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -390,10 +390,12 @@ export function apply(ctx: Context, config: Config = {}): void { 'agent/session-prefix': args => args[0], 'agent/step-result': args => args[0], 'agent/turn-continuation': args => args[0], + 'agent/turn-stop': args => args[0], 'agent/error': args => args[0], 'tools/pre-execute': args => (args[0] as ToolExecution).agent, 'tools/execute': args => (args[0] as ToolExecution).agent, 'tools/post-execute': args => (args[0] as ToolExecution).agent, + 'tools/result': args => (args[0] as ToolExecution).agent, 'system-prompt/assemble': args => (args[1] as AssembleContext).scope, 'session/created': null, 'session/event': null, @@ -429,11 +431,11 @@ export function apply(ctx: Context, config: Config = {}): void { // --- Setup-drives invariant --------------------------------------------- // - // CreateAgentOptions.setup REGISTERS the agent's scoped world; it must not - // DRIVE the agent — an inject() there opens a turn before - // `agent/session-start`, inverting the "session-start fires before the - // first turn" contract every bridge keys on. A turn/start appended to a - // live agent's session before its agent/session-start fired is therefore a + // CreateAgentOptions.setup COMPOSES the agent's scoped world; it must not + // DRIVE the agent. ReactLoopAgent rejects every driving verb structurally + // until rollback-covered publication reaches the session-start boundary; this event-level invariant remains the + // cross-implementation backstop for alternate Agent implementations and raw + // session writes. A turn/start appended before agent/session-start is a // creation-time misuse, reported at the appending call site. Sessions of // agents that exist BEFORE this plugin applies are marked started (their // ordering is unknowable after the fact — never a false positive on HMR). @@ -450,7 +452,7 @@ export function apply(ctx: Context, config: Config = {}): void { if (owner === undefined) return throw new InvariantError( `agent "${owner.id}": a turn opened before agent/session-start fired — ` - + 'CreateAgentOptions.setup registers the scoped world, it must not drive the agent ' + + 'CreateAgentOptions.setup composes the scoped world, it must not drive the agent ' + '(send/steer/inject belong after creation returns)') }) diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index 0186aaf31a..baf23d3d46 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -828,11 +828,15 @@ describe('scoped-dispatch invariants', () => { ['agent/pre-step', [agent, 1, 1, '', new AbortController().signal]], ['agent/prompt-submit', [agent, [], { kind: 'user' }, () => Promise.resolve({ kind: 'allow' })]], ['agent/request', [agent, 1, 1, { model: 'm' }, () => Promise.resolve({ model: 'm' })]], + ['agent/session-prefix', [agent, [], new AbortController().signal, () => Promise.resolve([])]], ['agent/step-result', [agent, 1, 1, { role: 'assistant', content: [] }, () => Promise.resolve({ role: 'assistant', content: [] })]], ['agent/turn-continuation', [agent, 1, { action: 'stop' }, () => Promise.resolve({ action: 'stop' })]], + ['agent/turn-stop', [agent, 1]], ['agent/error', [agent, 1, 0, new Error('x')]], ['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]], + ['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ callId: 'c', content: [], isError: false })]], ['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { callId: 'c', content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]], + ['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { callId: 'c', content: [], isError: false }]], ] for (const [event, args] of rows) { const subject = event.startsWith('tools/') ? agent : agent @@ -870,7 +874,7 @@ describe('scoped-dispatch invariants', () => { }).not.toThrow() }) - it('rejects a turn opened before agent/session-start (setup drives the agent)', async () => { + it('backstops alternate agents that open a turn before agent/session-start', async () => { const ctx = await scopedCtx() // A live agent whose session is in the store but whose session-start has // not fired: appending turn/start must throw the teaching error. diff --git a/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts b/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts index ef5c52030f..30a7307515 100644 --- a/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts +++ b/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts @@ -11,7 +11,7 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { CallId, HarnessError } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool, type ToolExecution, type ToolExecutionResult, type PostToolDecision } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineTool, type ToolExecutionInput, type ToolExecutionResult, type PostToolDecision } from '@deepseek-ai/dsh-tools' import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy' import { TOOL_TIMEOUT, toolTimeoutResult } from '@deepseek-ai/dsh-timeout-policy' @@ -193,7 +193,7 @@ describe('dsh-timeout-policy real-load-path guard', () => { const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(timeoutPolicy) as Parameters[0] const fiber = await ctx.plugin(unwrapped) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} } satisfies ToolExecution) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} } satisfies ToolExecutionInput) expect(result.isError).toBe(false) await fiber.dispose() }) diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index b5e03d772d..f6ac2e27f7 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -591,17 +591,27 @@ export function apply(ctx: Context, config: AcpConfig): void { return Promise.resolve() }, - newSession(params: NewSessionRequest): Promise { + async newSession(params: NewSessionRequest): Promise { assertOpen() validateWorkspaceParams(params) validateMcpServers(params) const sessionId = SessionId(randomUUID()) - const handle = agents.create({ + const handle = await agents.create({ agentId: AgentId(sessionId), sessionId, meta: { cwd: params.cwd }, agentOptions: agentOptions(config), }) + // Creation is now asynchronous because it awaits the unpublished setup + // transaction. A client disconnect can therefore close this bridge + // after the entry check but before the handle resolves; never install a + // post-close record that quiesce() could not have seen. + /* v8 ignore next 4 -- the in-memory transport rejects the in-flight RPC + immediately on close; real stdio may let the handler resume */ + if (closed) { + await handle.dispose() + throw internalError('connection closed during session/new') + } bySession.set(handle.agent, sessionId) sessions.set(sessionId, { sessionId, @@ -611,7 +621,7 @@ export function apply(ctx: Context, config: AcpConfig): void { terminalEnabled: terminalOutputCap, inflight: undefined, }) - return Promise.resolve({ sessionId }) + return { sessionId } }, async loadSession(params: LoadSessionRequest): Promise { diff --git a/packages/ui/acp/tests/dispose.spec.ts b/packages/ui/acp/tests/dispose.spec.ts index ac092d9d16..037d00f17e 100644 --- a/packages/ui/acp/tests/dispose.spec.ts +++ b/packages/ui/acp/tests/dispose.spec.ts @@ -229,10 +229,10 @@ describe('acp bridge — disposal & HMR safety', () => { // dispose one handle, and assert the other survives, registered and // queryable, with its session still in the store. const harness = await makeBridgeHarness({ storageDir, script: [] }) - const handleA = harness.ctx.agents.create({ + const handleA = await harness.ctx.agents.create({ agentId: AgentId('sib-a'), sessionId: SessionId('sib-a'), agentOptions: { model: 'mock' }, }) - const handleB = harness.ctx.agents.create({ + const handleB = await harness.ctx.agents.create({ agentId: AgentId('sib-b'), sessionId: SessionId('sib-b'), agentOptions: { model: 'mock' }, }) expect(harness.ctx.agents.get(AgentId('sib-a'))).toBe(handleA.agent) @@ -261,7 +261,7 @@ describe('acp bridge — disposal & HMR safety', () => { // a clean turn, dispose, and assert the session was STILL removed. const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] }) harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') }) - const handle = harness.ctx.agents.create({ + const handle = await harness.ctx.agents.create({ agentId: AgentId('guard-a'), sessionId: SessionId('guard-a'), agentOptions: { model: 'mock' }, }) handle.agent.send([{ type: 'text', text: 'go' }]) @@ -282,7 +282,7 @@ describe('acp bridge — disposal & HMR safety', () => { // first call's await agent.done + final flush finished. Every caller must // observe the same quiescence boundary. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) - const handle = harness.ctx.agents.create({ + const handle = await harness.ctx.agents.create({ agentId: AgentId('conc-a'), sessionId: SessionId('conc-a'), agentOptions: { model: 'mock' }, }) // Drive a turn that hangs in the model stream, so the loop is mid-turn when diff --git a/packages/ui/acp/tests/edges.spec.ts b/packages/ui/acp/tests/edges.spec.ts index 69c935139d..143bd7193c 100644 --- a/packages/ui/acp/tests/edges.spec.ts +++ b/packages/ui/acp/tests/edges.spec.ts @@ -27,7 +27,7 @@ describe('acp bridge — demux & config edges', () => { await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) const before = harness.updates.length - const { agent: foreign } = harness.ctx.agents.create({ agentId: AgentId('foreign'), sessionId: SessionId('foreign-session'), agentOptions: { model: 'mock' } }) + const { agent: foreign } = await harness.ctx.agents.create({ agentId: AgentId('foreign'), sessionId: SessionId('foreign-session'), agentOptions: { model: 'mock' } }) foreign.send([{ type: 'text', text: 'hi' }]) await foreign.whenIdle() await new Promise(r => setTimeout(r, 10)) diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts index 6353868a02..1d6f61432a 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts @@ -61,7 +61,7 @@ return { prose, containsFour: judged === null ? null : judged.containsFour }` describe.skipIf(!process.env.DEEPSEEK_API_KEY)('worker workflow engine with-key e2e', () => { it('runs a two-phase script in a worker thread over real children, one through the structured runtime', async () => { ctx = await harness() - const parentHandle = ctx.agents.create({ + const parentHandle = await ctx.agents.create({ agentId: AgentId('wf-worker-e2e-parent'), sessionId: 'wf-worker-e2e-session' as never, agentOptions: { model: 'deepseek-v4-flash' }, diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 9b8141807e..ccf10f712a 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -91,7 +91,9 @@ export const LINK_MAP: Record = { TurnEndReason: 'session.md', ToolDefinition: 'tools.md', ToolExecution: 'tools.md', + ToolExecutionInput: 'tools.md', ToolExecutionResult: 'tools.md', + ToolExecutionToken: 'tools.md', BashExecRequest: 'bash.md', BashExecSpec: 'bash.md', BashRunResult: 'bash.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 70fe8f2a85..1acdd5f5ff 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -120,10 +120,10 @@ const SERVICE_ROLES: ServiceRole[] = [ { key: 'tools', pkg: 'tools', - title: 'Tool registry and execution waterfall', + title: 'Tool registry and guarded execution pipeline', mode: 'core', consumers: ['agent-loop', 'tool-ask-user', 'tool-bash', 'tool-cordis', 'tool-fs', 'tool-subagent', 'tool-todo', 'tool-web', 'acp'], - note: 'Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute.', + note: 'Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation.', }, { key: 'userInteraction', @@ -217,6 +217,9 @@ const SERVICE_ROLES: ServiceRole[] = [ ] const DYNAMIC_EVENT_DISPATCHERS: Array<{ event: string; pkg: string; method: string }> = [ + // tools/result uses ctx.events.dispatch directly so the registry can await + // every observer while containing each callback independently. + { event: 'tools/result', pkg: 'tools', method: 'events.dispatch' }, // Subagent lifecycle events intentionally bypass ctx.emit and call // ctx.events.dispatch directly so one throwing listener cannot starve later // listeners or strand an already-started child run. @@ -534,12 +537,12 @@ function collectEventRelations(): Map { if (method === 'on') { const event = eventArg(node.arguments, method) if (event) ensure(event).listeners.add(leaf) - } else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'waterfall') { + } else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'strictSerial' || method === 'waterfall') { const event = eventArg(node.arguments, method) if (event) { const relation = ensure(event) const methods = relation.dispatchers.get(leaf) ?? new Set() - methods.add(method) + methods.add(method === 'strictSerial' ? 'strictSerial (serial)' : method) relation.dispatchers.set(leaf, methods) } } @@ -566,13 +569,13 @@ function isCordisContextReceiver(expr: ts.PropertyAccessExpression, sf: ts.Sourc const target = expr.expression.getText(sf) if (target === 'ctx' || target === 'this.ctx') return true // Scoped-dispatch spellings (the agent-scoping seam): the loop's fused - // dispatcher (`events` from `agentEvents(ctx, agent)`), the agent's own - // context handle (`this.loopCtx`), and the session store's captured - // dispatch context (`emitCtx`). Conventional receiver names, pinned by the - // fused-dispatch convention; a rename here must update this list (the - // producer/consumer matrix silently losing a dispatcher is the failure - // mode this list exists to prevent). - return target === 'events' || target === 'this.loopCtx' || target === 'emitCtx' + // dispatcher (`events` from `agentEvents(ctx, agent)`), an agent's setup + // context (`childCtx`), the agent's own context handle (`this.loopCtx`), and + // the session store's captured dispatch context (`emitCtx`). Conventional + // receiver names, pinned by the fused-dispatch convention; a rename here + // must update this list (the producer/consumer matrix silently losing a + // dispatcher or listener is the failure mode this list exists to prevent). + return target === 'events' || target === 'childCtx' || target === 'this.loopCtx' || target === 'emitCtx' } function eventArg(args: ts.NodeArray, method: string): string | undefined { @@ -690,6 +693,7 @@ function renderLifecycle(): string { ' Tools-->>Session: tool-owned events when applicable', ` Driver->>Session: ${mermaidCode('tool/result')} and ${mermaidCode('step/end')}`, ` Driver->>Hooks: ${mermaidCode('agent/turn-continuation')} waterfall`, + ` Driver->>Hooks: ${mermaidCode('agent/turn-stop')} serial terminal checkpoint`, ` Driver->>Session: ${mermaidCode('turn/end')}`, ` Driver->>Persistence: ${mermaidCode('session/flush')} parallel checkpoint`, ` Driver-->>SDK: ${mermaidCode('agent/status')} idle`, @@ -705,7 +709,7 @@ function renderToolPipeline(): string { const maintenance = 'curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs' return [ ...generatedHeader('Tool Execution Pipeline'), - 'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, and UI rendering fit without changing the loop. The key extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls.', + 'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering fit without changing the loop. The transformable extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls; monotonic guards and `tools/result` are the owner-enforced boundaries around them.', '', '```mermaid', 'flowchart TD', @@ -713,19 +717,24 @@ function renderToolPipeline(): string { ` toolCall["Session event: ${mermaidCode('tool/call')}
logged before execution"]`, ' presentCall["UI pending card
presentCall(args)"]', ` pre["${mermaidCode('tools/pre-execute')} waterfall
hooks, permission, sandbox"]`, + ' guards["Registered monotonic guards
deny or abstain; identity protected"]', ' denied["deny or ask
tool body skipped"]', ` around["${mermaidCode('tools/execute')} waterfall
timeout, retry, metrics (around dispatch)"]`, ' toolBody["Registered tool execute() body"]', ` fsGate["${mermaidCode('fs/write-intent')} or ${mermaidCode('fs/edit-intent')}
tool-fs mutations only"]`, ` owned["Tool-owned session events
${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`, ` post["${mermaidCode('tools/post-execute')} waterfall
accept, block, replace, add context"]`, + ` final["${mermaidCode('tools/result')} parallel notification
frozen authoritative outcome"]`, ' context["Buffered additionalContext
context/message after all tool results"]', ` toolResult["Session event: ${mermaidCode('tool/result')}
single model-facing outcome"]`, + ' allResults["All calls in the step settled
and tool/result events recorded"]', ' presentResult["UI completed card
presentResult(args, result)"]', ' model --> toolCall', ' toolCall --> presentCall', ' toolCall --> pre', - ' pre -->|allow| around', + ' pre -->|allow| guards', + ' guards -->|allow| around', + ' guards -->|deny| denied', ' around --> toolBody', ' pre -->|deny or ask| denied', ' denied --> post', @@ -734,12 +743,14 @@ function renderToolPipeline(): string { ' toolBody --> owned', ' toolBody --> around', ' around --> post', - ' post --> context', - ' post --> toolResult', + ' post --> final', + ' final --> toolResult', ' toolResult --> presentResult', + ' toolResult --> allResults', + ' allResults --> context', '```', '', - 'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and future permission prompts live on the generic pre/post tool waterfalls; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service. Code Mode rides the same pipeline twice over: `run_code` is itself a registered tool body, and each tool call its program makes re-enters `ctx.tools.execute()` through BOTH waterfalls — serialized one at a time, logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call\'s `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency).', + 'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and future permission prompts live on the generic pre/post tool waterfalls; owner policy that must not be reordered uses registered guards; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. The awaited `tools/result` notification observes the immutable final outcome after every transform, lossless-JSON validation, and outer error normalization. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service. Code Mode rides the whole pipeline twice over: `run_code` is the reserved registry-owned transport whose body enters the pipeline, and each tool call its program makes re-enters `ctx.tools.execute()` — serialized one at a time, carrying the outer execution\'s opaque token for correlation, and logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call\'s `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency).', '', ...maintenanceFooter(maintenance), ].join('\n') diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 62e40907b8..3f96976a03 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -137,7 +137,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ toolsConfig: { mode: 'code' }, async mount() {}, note: - 'Registered by the tool registry itself under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the ONLY wire tool; the other registered tools are declared to the model as a generated TypeScript SDK prompt section instead, and a program calls them through port-bridged bindings that dispatch through the ordinary tools/pre-execute → tools/post-execute pipeline, one at a time.', + 'Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the registry\'s only canonical wire contribution; the other visible capabilities are declared in a protected TypeScript SDK section, and a program calls them through serialized bindings that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.', }, { pkg: '@deepseek-ai/dsh-tool-bash', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 881ce06578..b8882f96e8 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -14,6 +14,7 @@ { "doc": "docs/core-data-structures/core.md", "symbol": "HookContext", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationDecision", "source": "packages/core/agent/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationStop", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "SessionStartSource", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/llm/src/types.ts" }, @@ -39,7 +40,10 @@ { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/core/tools/src/schema.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaSpec", "source": "packages/core/tools/src/schema.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "InferArgs", "source": "packages/core/tools/src/schema.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionToken", "source": "packages/core/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionInput", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolGuard", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "PreToolDecision", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "PostToolDecision", "source": "packages/core/tools/src/index.ts" }, From 36c94fbe3e228389ee37d306747c092757a6c8fd Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:55:40 +0800 Subject: [PATCH 30/64] docs: align the agent-scope contracts --- docs/agent-lifecycle.md | 1 + docs/architecture.md | 11 +- docs/capability-seams.md | 4 +- docs/config-catalog.md | 16 +- docs/cookbook/adding-a-tool.md | 12 +- docs/cookbook/extension-cookbook.md | 11 +- docs/cordis-catalog/events.md | 80 +- docs/cordis-catalog/services.md | 28 +- docs/core-data-structures/core.md | 10 +- docs/core-data-structures/subagent.md | 4 +- docs/core-data-structures/tools.md | 51 +- docs/event-producer-consumer.md | 46 +- docs/persistence-catalog.md | 2 +- .../2026-06-11-microkernel-event-taxonomy.md | 7 +- ...-18-agent-lifecycle-and-ownership-seams.md | 6 +- .../2026-07-08-agent-scope-contexts.md | 714 ++++++++++++++---- .../feature/2026-06-15-code-mode.md | 42 +- .../feature/2026-06-30-interception-seams.md | 32 +- .../feature/2026-07-05-dynamic-workflows.md | 15 +- .../2026-06-14-acp-agent-client-protocol.md | 2 +- .../2026-06-30-pre-tool-input-rewrite.md | 19 +- ...026-07-04-prune-dead-core-spine-surface.md | 8 +- docs/tool-catalog.md | 4 +- docs/tool-execution-pipeline.md | 17 +- examples/README.md | 2 +- examples/coding-agent/README.md | 2 +- packages/core/README.md | 2 +- packages/core/agent-core/README.md | 2 +- packages/core/agent-loop/README.md | 32 +- packages/core/agent/README.md | 46 +- packages/core/scope/README.md | 3 +- packages/core/session/README.md | 14 +- packages/core/system-prompt/README.md | 16 +- packages/core/tools/README.md | 43 +- packages/subagent/README.md | 4 +- .../subagent/subagent-inprocess/README.md | 18 +- packages/subagent/subagent-spawn/README.md | 4 +- 37 files changed, 901 insertions(+), 429 deletions(-) diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index d6d9ab1ba7..fa2c4bd0fb 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -39,6 +39,7 @@ sequenceDiagram Tools-->>Session: tool-owned events when applicable Driver->>Session: tool/result and step/end Driver->>Hooks: agent/turn-continuation waterfall + Driver->>Hooks: agent/turn-stop serial terminal checkpoint Driver->>Session: turn/end Driver->>Persistence: session/flush parallel checkpoint Driver-->>SDK: agent/status idle diff --git a/docs/architecture.md b/docs/architecture.md index 7f2cc51f3e..cb2a61fe4c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -60,7 +60,9 @@ A **session** is one agent's append-only event log. A **turn** drains one queued ### Turn Flow ```text -create agent -> mint agent scope (agent.ctx) -> run creation setup -> emit agent/session-start(source) +reserve ids -> mint agent.ctx -> await unpublished setup + -> enter session + agent -> session/created -> agent/created + -> enable driving -> agent/session-start(source) -> start driver forever: wait for queued messages emit agent/status(running) @@ -82,11 +84,12 @@ forever: 'assistant/message' each tool call: 'tool/call' - tools/pre-execute -> tools/execute -> tools/post-execute + tools/pre-execute -> monotonic guards -> tools/execute -> tools/post-execute -> tools/result 'tool/result' append post-tool context and steering 'step/end' agent/turn-continuation + agent/turn-stop (terminal policy) stop unless tools or continuation policy ask for another step 'turn/end' checkpoint persistence and notify idle/running status @@ -94,7 +97,7 @@ forever: 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). -Post-tool context lands after all tool results so tool-call/result adjacency stays stable. Steering drains between steps; leftover steering after a turn is re-queued as ordinary input. +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. ### Failure Boundaries @@ -148,7 +151,7 @@ New behavior should attach to a documented extension point; changing the shipped | Add a model-facing capability | register a tool on `ctx.tools`; schemas flow into prompt assembly | | Add command execution | implement and register a `ctx.bash` backend | | Add filesystem access or policy | implement a `ctx.fs` provider or listen on `fs/*` policy events | -| Intercept prompts, requests, tool use, or continuation | listen on the relevant `agent/*` or `tools/*` waterfall | +| Intercept prompts, requests, tool use, or continuation | listen on the relevant `agent/*` or `tools/*` waterfall; use serial `agent/turn-stop` for a monotonic terminal stop | | Add a session-stable request prefix outside history | compose it on `agent/session-prefix`, once per loop instance; logged on the request header | | Add UI or editor integration | drive `ctx.agents` and render from `session/event` | | Add durable session state | add a `SessionEventMap` member and render/replay from the log | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 1c24a2916e..5f56be9da8 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -29,7 +29,7 @@ flowchart LR pkg_tools["tools"] pkg_tool_fs["tool-fs"] pkg_tool_web["tool-web"] - svc_tools["ctx.tools
Tool registry and execution waterfall"] + svc_tools["ctx.tools
Tool registry and guarded execution pipeline"] pkg_tool_ask_user["tool-ask-user"] pkg_tool_bash["tool-bash"] pkg_tool_cordis["tool-cordis"] @@ -155,7 +155,7 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | -| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute. | +| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | | `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-agent`](../packages/ui/stdio-agent), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles and the create/resume factory seam. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-core`](../packages/core/agent-core) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a85fa29e93..5f01144504 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -128,7 +128,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:38`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:37`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -578,7 +578,7 @@ Source: [`packages/subagent/subagent-acp/src/index.ts:30`](../packages/subagent/ ## `@deepseek-ai/dsh-subagent-fork` -Requires: `subagents` · `agents` +Requires: `subagents` ```ts config-catalog /** Config: the registry name to register the provider under. */ @@ -625,7 +625,7 @@ Source: [`packages/support/subagent-mock/src/index.ts:84`](../packages/support/s ## `@deepseek-ai/dsh-subagent-spawn` -Requires: `subagents` · `agents` +Requires: `subagents` ```ts config-catalog /** Config: the registry name to register the provider under. */ @@ -684,7 +684,7 @@ export interface Config { } ``` -Source: [`packages/core/system-prompt/src/index.ts:220`](../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:264`](../packages/core/system-prompt/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -831,9 +831,9 @@ Requires: `systemPrompt` export interface Config { /** * The presentation mode. `'native'` (the default) contributes every - * registered tool as a wire function definition — byte-for-byte today's - * behavior. `'code'` contributes exactly ONE wire tool, `run_code`, plus - * the generated `tools:sdk` prompt section declaring every other tool as a + * 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 @@ -850,7 +850,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:338`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:402`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-web` diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 50c7cbccf8..5eede7ff5d 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -22,7 +22,7 @@ export function apply(ctx: Context) { }, async execute(args, exec) { // args is TYPED from the schema: { path: string; limit?: number } - // exec carries { callId, name, arguments, agent?, signal? } + // exec carries immutable identity + token; signal is the operational field return [{ type: 'text', text: await readFile(args.path, 'utf8') }] }, })) @@ -34,7 +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. -- **Throwing means isError.** The registry catches anything `execute()` throws and returns `{isError: true}` to the model. Use that for infrastructure failures (bad input, spawn errors, aborts) — but REPORT domain failures in the result text instead (e.g. tool-bash returns `[exit code: 9]` with `isError: false`: the model decides what a failing command means). +- **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). - **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: ''}})` 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). @@ -45,13 +47,13 @@ Follow tool-bash's background pattern: a `run_in_background` flag returns a task > TODO: each tool reimplements this background pattern by hand today. At some point we need a generic long-running-tool layer that handles task ids, incremental polling, kill, and completion notices uniformly. -## Permissions / sandboxing +## Execution policy and observation -Prefer not to build policy into the tool. The seam is the `tools/pre-execute` gate (deny/ask — see the permission-gate example in [extension-cookbook.md](./extension-cookbook.md)) and the `tools/post-execute` inspect/transform seam, or a sandboxing implementation behind the tool's executor seam. +Prefer not to build deployment policy into the tool. Use `tools/pre-execute` for extensible allow/deny/ask policy (the [permission-gate example](./extension-cookbook.md#a-hook-plugin-permission-gate)), `ctx.tools.guard()` for a final monotonic deny that later listeners cannot undo, `tools/execute` to wrap core dispatch with a deadline/retry/metrics scope, `tools/post-execute` to transform or attach model-facing context, and `tools/result` to observe the immutable normalized outcome without changing it. A sandboxing implementation can also sit behind the tool's executor capability seam; the exact contracts are in the [`dsh-tools` README](../../packages/core/tools/README.md#extension-points). ## Code Mode reaches your tool for free -Under the registry's non-native `mode` ([Code Mode](../../packages/core/tools/README.md)), a registered tool is ALSO callable from a `run_code` program as `await tools.(args)` — nothing to add. The generated SDK declares your parameters from the same JSON Schema `defineTool` emits (constructs outside that subset degrade to `unknown`), each program call re-enters `execute()` through both waterfalls, and a failed call rejects the program-side promise with your error text. Two consequences worth designing for: your `description` and parameter `description`s become JSDoc a model reads while WRITING CODE, and non-text result blocks reach programs as placeholders (text is the lingua franca of the bridge). +Under the registry's non-native `mode` ([Code Mode](../../packages/core/tools/README.md)), each visible registered capability is callable from a `run_code` program as `await tools.(args)` — nothing to add. The registry keeps `run_code` itself as reserved, unfilterable presentation infrastructure while restrictions still control which end capabilities appear in the scoped SDK and bindings. The generated SDK declares parameters from the same JSON Schema `defineTool` emits (constructs outside that subset degrade to `unknown`); each program call receives its own immutable execution whose `parent` is the enclosing `run_code` token, then re-enters the complete pre/guard/around/post/result pipeline. A failed call rejects the program-side promise with your error text. Design `description` and parameter `description`s as JSDoc a model reads while writing code, and remember that non-text result blocks reach programs as placeholders (text is the bridge's lingua franca). ## How your tool renders in an editor (ACP presentation) diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index de21ece7b1..b9c5d2cdf0 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -28,6 +28,8 @@ export function apply(ctx: Context) { } ``` +This waterfall is the reorderable policy layer. Use `ctx.tools.guard()` when an invariant needs a monotonic final denial, `tools/execute` when a plugin must wrap the actual dispatch lifetime (timeouts/retries/metrics; only `exec.signal` is replaceable), `tools/post-execute` for explicit result transformation, and `tools/result` for contained observation of the immutable final outcome. The [adding-a-tool guide](./adding-a-tool.md#execution-policy-and-observation) gives the selection rule. + ## A UI plugin A UI plugin renders from the `session/event` feed (the assistant token stream as `assistant/chunk`, plus turn/step boundaries and tool activity), and drives input back in via `agent.send()` / `agent.steer()`. @@ -92,14 +94,17 @@ Every product feature maps to a listener on a documented extension seam — the | Hook system (user + project level) | listeners on `agent/session-start`, `agent/prompt-submit`, `agent/request`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation` — each interception waterfall returns a typed Decision; the `dsh-hooks-claude` / `dsh-hooks-codex` bridges map hook config files onto these seams | | `/goal` | force-continue via `agent/turn-continuation` + `steer()` reminders | | `/loop` | on the `turn/end` session event, `send()` the next iteration; or force-continue | -| Dynamic workflow | orchestrator plugin on `turn/end` (or `step/end`) driving `send`/`steer` + subagents | +| 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 | +| System prompt configurability | `ctx.systemPrompt.section()` with ordering; an owner uses `systemPrompt.protect()` only when its canonical section/tool 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 | -| ToolSearch / progressive disclosure | filter tools at `system-prompt/assemble` (the assembly carries the schemas; the loop logs the result as the request header, so disclosure stays reconstructable) | +| ToolSearch / progressive disclosure | filter ordinary capabilities at `system-prompt/assemble` (the loop logs the result as the request header); owner-protected transport and correctness entries retain their canonical presence or absence | +| Tool deadline / retry / metrics | wrap core dispatch with `tools/execute`; a wrapper may replace `exec.signal`, delegate, and inspect the normalized result in one lexical lifetime | +| Final tool-result metrics / audit / capture | observe immutable authoritative outcomes with `tools/result`; use `tools/post-execute` instead only when the plugin must transform the result or attach context | +| Monotonic terminal turn policy | return `{ action: 'stop' }` from serial `agent/turn-stop`, after continuation and steering have already been folded | | Tool sandbox (landlock / sandbox-exec) | `tools/pre-execute` (deny), or a sandboxing `BashExecutor` on the `dsh-bash` seam | | Permission system / AskUserQuestion | `tools/pre-execute` (deny/ask); register an ask tool | | Plan mode | `tools/pre-execute` (deny writes) + a mode prompt section via `ctx.systemPrompt.section()` or `agent.inject()` (model-visible ⟺ logged: `agent/request` shapes call config only) | diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 659dc9143b..48f0d32eb0 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -15,7 +15,7 @@ Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `n ### `agent/created` — emit -An agent was registered in the AgentRegistry and is ready to receive messages. +An agent's fully composed scoped world was published in the AgentRegistry. Its session is already live in the session store, but concrete factories may keep driving verbs locked until the subsequent `agent/session-start` boundary; that event is the first supported place to inject or queue work during startup. ```ts cordis-catalog 'agent/created'(this: Scoped, agent: Agent): void @@ -23,11 +23,11 @@ An agent was registered in the AgentRegistry and is ready to receive messages. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:287`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:298`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit -An agent was disposed and removed from the registry; its fiber and any in-flight turn have been torn down. +An agent was removed from the registry after its driver and any in-flight turn reached quiescence. Ordered teardown may still be detaching the session and unwinding the agent's scoped registrations when this notification runs. ```ts cordis-catalog 'agent/disposed'(this: Scoped, agent: Agent): void @@ -35,7 +35,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:299`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:312`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -47,7 +47,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:553`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:585`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -61,7 +61,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:404`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:417`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -73,7 +73,7 @@ Waterfall: decide what happens to ONE drained queued message before it becomes a Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:422`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:435`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -85,7 +85,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:327`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:340`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -97,7 +97,7 @@ Waterfall: shape the step's call configuration — model switching, sampling ove Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:451`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:464`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -113,7 +113,7 @@ The seed is a frozen empty list; a contributing listener returns a NEW array — Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:503`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:516`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -125,7 +125,7 @@ The agent's session lifecycle began, fired once before its first turn. `source` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:347`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:360`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -137,7 +137,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:313`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:326`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -149,7 +149,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:518`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:531`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -161,7 +161,19 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:536`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:549`](../../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. 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, turn: number): Promise | ContinuationStop | undefined +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/core/agent/src/types.ts:568`](../../packages/core/agent/src/types.ts) ## `fs/*` @@ -256,10 +268,10 @@ Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/sr A subagent run settled — emitted when SubagentRun.result resolves (any stop reason). Paired with Events['subagent/start']. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by the DELEGATING PARENT — a listener registered through the parent's `agent.ctx` observes only its own delegations; a plain plugin listener observes every run. ```ts cordis-catalog -'subagent/end'(info: SubagentRunEndInfo): void +'subagent/end'(this: Scoped, info: SubagentRunEndInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:107`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:109`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -269,7 +281,7 @@ A provider became resolvable in the SubagentService registry. Consumers that der 'subagent/provider-added'(provider: SubagentProvider): void ``` -Source: [`packages/subagent/subagent/src/index.ts:73`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:75`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -279,17 +291,17 @@ A provider left the registry (its plugin's fiber was disposed — an unload or a 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:84`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:86`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit A subagent run started — emitted after the provider is resolved and its capabilities validated, as the child run begins. Paired with Events['subagent/end']. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by the DELEGATING PARENT — a listener registered through the parent's `agent.ctx` observes only its own delegations; a plain plugin listener observes every run. ```ts cordis-catalog -'subagent/start'(info: SubagentRunInfo): void +'subagent/start'(this: Scoped, info: SubagentRunInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:96`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:98`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` @@ -301,17 +313,17 @@ Waterfall around prompt assembly — mutate or extend the PromptAssembly (sectio 'system-prompt/assemble'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:44`](../../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, 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. +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. ```ts cordis-catalog 'system-prompt/change'(): void ``` -Source: [`packages/core/system-prompt/src/index.ts:54`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:55`](../../packages/core/system-prompt/src/index.ts) ## `tools/*` @@ -323,11 +335,11 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:151`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:170`](../../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. 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 mutate `exec` (e.g. replace `exec.signal` with a per-call deadline) BEFORE `next()` and inspect the result AFTER. (Cordis `next()` ignores any passed arguments and re-invokes downstream with the shared payload, so a wrapper mutates `exec` in place rather than passing a new object to `next()`.) Multiple listeners compose by registration order — an outer one wraps the inner ones plus dispatch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by `exec.agent` — a listener registered through `agent.ctx` wraps only that agent's calls; a plain plugin listener wraps every call (including agent-less ones, which dispatch subject-less). +Around-dispatch waterfall wrapping the registry's core tool dispatch, between the `tools/pre-execute` gate and the `tools/post-execute` seam. A listener receives `(exec, next)`: call `next()` to delegate to dispatch (returning its ToolExecutionResult, optionally wrapped), or return a replacement result without calling `next()` to short-circuit dispatch. The base `next()` IS the dispatch-with-normalization thunk — a thrown tool (or unknown tool) is already normalized to an `isError` result by the time a listener's `await next()` returns, so a wrapper never sees a raw throw from the tool body. This is the seam a timeout/retry/metrics plugin wraps: it can set or replace the one mutable field, `exec.signal` (e.g. with a per-call deadline), BEFORE `next()`, restore/delete it afterward, and inspect the result AFTER. Call identity (`token`, `callId`, `name`, `arguments`, `agent`, and `parent`) is immutable throughout the pipeline so a wrapper cannot change which capability or scope was authorized. (Cordis `next()` ignores passed arguments and re-invokes downstream with the shared payload, so a wrapper changes `exec.signal` in place rather than passing a new object to `next()`.) Multiple listeners compose by registration order — an outer one wraps the inner ones plus dispatch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by `exec.agent` — a listener registered through `agent.ctx` wraps only that agent's calls; a plain plugin listener wraps every call (including agent-less ones, which dispatch subject-less). ```ts cordis-catalog 'tools/execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise @@ -335,7 +347,7 @@ Around-dispatch waterfall wrapping the registry's core tool dispatch, between th Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:121`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:125`](../../packages/core/tools/src/index.ts) ### `tools/post-execute` — waterfall @@ -347,7 +359,7 @@ Waterfall AFTER a tool runs — where hook plugins inspect the result and accept Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:141`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:145`](../../packages/core/tools/src/index.ts) ### `tools/pre-execute` — waterfall @@ -359,7 +371,19 @@ 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:97`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:98`](../../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. 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, exec: Readonly, result: Readonly): Promise | void +``` + +Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) + +Source: [`packages/core/tools/src/index.ts:160`](../../packages/core/tools/src/index.ts) ## `workflow/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 8f5f32a22b..6e9fcd8a42 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -17,28 +17,30 @@ The loop itself is deliberately thin — every behavior beyond "call the model, ```ts cordis-catalog create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent -createAgent(options: CreateAgentOptions): AgentHandle +async createAgent(options: CreateAgentOptions): Promise async resume(options: ResumeAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:70`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:69`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` -Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package. Agent *creation* is provided by whichever plugin implements the AgentFactory (phase 1: `@deepseek-ai/dsh-agent-loop`), registered via setFactory. +Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package. Agent *creation* is provided by whichever plugin implements the AgentFactory (`@deepseek-ai/dsh-agent-loop`), registered via setFactory. ```ts cordis-catalog setFactory(factory: AgentFactory): () => Promise | void -create(options: CreateAgentOptions): AgentHandle +async create(options: CreateAgentOptions): Promise async resume(options: ResumeAgentOptions): Promise register(agent: Agent): () => Promise | void +enter(agent: Agent): () => void +announce(agent: Agent): void get(id: AgentId): Agent | undefined list(): Agent[] ``` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:145`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:168`](../../packages/core/agent/src/index.ts) ## `ctx.bash` — `BashExecutor` (abstract seam) @@ -199,40 +201,42 @@ list(): string[] start(name: string, request: SubagentStartRequest): SubagentRun ``` -Source: [`packages/subagent/subagent/src/index.ts:153`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:155`](../../packages/subagent/subagent/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` -Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, and named prompt variables; 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 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). ```ts cordis-catalog section(section: PromptSection): () => Promise | void tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise | void variable(name: string, provider: (context: AssembleContext) => string | undefined): () => Promise | void +protect(protection: PromptProtection): () => Promise | void async assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:335`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:379`](../../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` → `tools/execute` → `tools/post-execute` 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 registers the `run_code` tool and the `tools:sdk` prompt section itself. +Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute` → `tools/result` pipeline. The registry contributes its schemas into the system-prompt assembly — WHICH schemas is governed by its `mode` config (see Config.mode); under a non-native mode it also owns the reserved `run_code` presentation transport and the `tools:sdk` prompt section. Two registration layers (`@deepseek-ai/dsh-scope`): a registration through a plain plugin context is GLOBAL (visible to every agent); one through a scoped context (`agent.ctx`) is filed in that scope's layer — visible to that agent alone, disposed with the scope, and SHADOWING a global tool of the same name for that agent (most-specific-wins; within one layer a duplicate name still throws). restrict masks the global layer per scope. One visibility function (visible) feeds prompt assembly, get, and execute — and, under a non-native mode, the SDK section and `run_code`'s bindings — so what the model is shown, what a presenter renders, what a program can call, and what dispatches can never disagree. ```ts cordis-catalog register(definition: ToolDefinition): () => Promise | void restrict(filter: ToolRestriction): () => Promise | void +guard(guard: ToolGuard): () => Promise | void visible(scope?: ScopeKey): ToolDefinition[] get(name: string, scope?: ScopeKey): ToolDefinition | undefined schemas(scope?: ScopeKey): ToolSchema[] knownNames(scope?: ScopeKey): string[] -async execute(exec: ToolExecution): Promise +async execute(exec: ToolExecutionInput): Promise ``` -Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) +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:392`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:474`](../../packages/core/tools/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index cdc928b2fc..ad1041a5ed 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -18,7 +18,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [llm-streaming.md](llm-streaming.md) | the `StreamChunk` wire protocol + adapter contract, `BlockAssembler`, the `LlmAdapter` seam | | [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` | -| [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, the `tools/pre-execute`/`tools/post-execute` pipeline | +| [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 | | [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s | | [code-runtime.md](code-runtime.md) | the code-execution seam: `CodeRunRequest`/`Result`, binding namespaces, captured logs, the `CodeRunFailure` taxonomy | @@ -339,7 +339,7 @@ interface Agent { } ``` -`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`) is merge-extensible — plugins add creation options by declaration merging; the persona is NOT an agent option but the `dsh-system-prompt` plugin's `persona` config, shared context-wide. The `agent/*` event taxonomy (lifecycle emits incl. `agent/session-start`, the serial `agent/pre-step` surface-mutation seam, and the `agent/prompt-submit`/`agent/request`/`agent/session-prefix`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy); turn/step boundaries are durable `session/event` records, not `agent/*` emits. +`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`) is merge-extensible — plugins add creation options by declaration merging; the persona is NOT an agent option but the `dsh-system-prompt` plugin's `persona` config, shared context-wide. The `agent/*` event taxonomy (lifecycle emits incl. `agent/session-start`, serial `agent/pre-step`/`agent/turn-stop` checkpoints, and the `agent/prompt-submit`/`agent/request`/`agent/session-prefix`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy); turn/step boundaries are durable `session/event` records, not `agent/*` emits. ## Interception decisions @@ -370,6 +370,12 @@ type ContinuationDecision = | { action: 'continue'; reason?: HookContext } ``` +`agent/turn-stop` returns the stop-only `ContinuationStop` subset or `undefined`. The loop calls this serial checkpoint after folding the ordinary decision, its reason, and pending steering; a stop is terminal and discards pending steering. + +```ts type-equiv +type ContinuationStop = Extract +``` + `agent/session-start` carries a `SessionStartSource` (why the session lifecycle began; a bridge keys its SessionStart matcher on it): ```ts type-equiv diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 6f2e23e37e..66525592cf 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -38,7 +38,7 @@ interface SubagentStartRequest { ## The terminal result: `SubagentResult` -The outcome of a run, resolved by `SubagentRun.result`. `structured` is present iff the request carried an `outputSchema` AND the provider honored it. 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. +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 { @@ -92,7 +92,7 @@ The service (`ctx.subagents`) emits `subagent/start` when a run begins and `suba ## 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 context via `ctx.agents.create`. Two pieces of vocabulary ride on the existing agent/session types rather than new core types: +The two in-process backends ([dsh-subagent-spawn](../../packages/subagent/subagent-spawn) fresh, [dsh-subagent-fork](../../packages/subagent/subagent-fork) seeded) run the child as a child `Agent` on the same application. They synchronously snapshot caller-owned data, install provider ownership before attaching the abort listener, create one run-owner fiber under `parent.ctx`, and invoke the factory through that fiber: parent teardown, provider teardown, and manual run disposal share the same pre-publication ownership and quiescence boundary, while the child still receives a flat new scope rather than inheriting the parent's capabilities. 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`. - **Fork seeding** uses `CreateAgentOptions.seed` (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `resume` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/support/invariants) replay accepts it (the in-flight, unbalanced turn is excluded). diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 96d3e79bdc..e62e11922f 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -1,6 +1,6 @@ # Tools -The tool pipeline of [dsh-tools](../../packages/core/tools). [core.md](core.md) introduces `ToolDefinition` as the one pipeline-authoring type promoted to the spine and `ToolSchema` as the model-facing wire shape. This page owns the full `ToolDefinition`, the typed schema DSL that builds it, the waterfall execution shapes, and the UI-presentation vocabulary. +The tool pipeline of [dsh-tools](../../packages/core/tools). [core.md](core.md) introduces `ToolDefinition` as the one pipeline-authoring type promoted to the spine and `ToolSchema` as the model-facing wire shape. This page owns the full `ToolDefinition`, the typed schema DSL that builds it, the guarded execution shapes, and the UI-presentation vocabulary. Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) · [`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts) · [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts) @@ -81,22 +81,51 @@ type InferArgs = Simplify< `defineTool({ name, description, parameters, execute, … })` ties it together: `parameters` is a `SchemaSpec`, `execute(args, exec)` gets `args: InferArgs`, and the helper converts the spec to JSON Schema (`schemaSpecToJsonSchema`) for the wire and validates model-generated args (`validateArgs`) before the typed body runs. A mismatch throws `ToolArgsError` (`code: 'INVALID_ARGS'`), which the registry turns into an `isError` result so the model can self-correct. Why a custom DSL and not schemastery: tool parameters need JSON Schema (the LLM wire format), not validation/transformation — the lightweight DSL gives the best authoring DX with the smallest surface. -## Execution: the `tools/pre-execute` / `tools/post-execute` pipeline shapes +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. -`ctx.tools.execute()` runs each call through a two-waterfall pipeline — `tools/pre-execute` (the allow/deny/ask gate) → core dispatch → `tools/post-execute` (inspect/replace the result, attach context) — the seams where sandbox, permission, hook, and plan-mode plugins gate or transform a call. The pending call is a `ToolExecution`; the outcome is a `ToolExecutionResult`. +## 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`. ```ts type-equiv -interface ToolExecution { - callId: CallId - name: string +interface ToolExecutionToken { + readonly [toolExecutionTokenBrand]: true +} +``` + +```ts type-equiv +interface ToolExecutionInput { + readonly callId: CallId + readonly name: string /** Parsed JSON arguments (unknown — tools validate their own input). */ - arguments: unknown + readonly arguments: unknown /** The agent on whose behalf the call runs (set by the agent loop). */ - agent?: Agent + readonly agent?: Agent + /** + * Opaque token of the enclosing transport execution, when one exists. Code + * Mode sets this on SDK sub-dispatches so commit-style observers can wait for + * the outer `run_code` outcome without receiving its live mutable execution. + */ + readonly parent?: ToolExecutionToken signal?: AbortSignal } ``` +```ts type-equiv +interface ToolExecution extends ToolExecutionInput { + /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ + readonly token: ToolExecutionToken +} +``` + +`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. + +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. + +```ts type-equiv +type ToolGuard = (execution: Readonly) => string | undefined +``` + ```ts type-equiv interface ToolExecutionResult { callId: CallId @@ -129,7 +158,9 @@ interface ToolExecutionResult { } ``` -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/post-execute` listeners receive `(exec, result, next)` and return a `PostToolDecision`: +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. + +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`: ```ts type-equiv type PreToolDecision = @@ -144,7 +175,7 @@ type PostToolDecision = | { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext } ``` -Call `next()` to delegate to the default (allow / accept-unchanged), or return a decision to short-circuit. A `pre-execute` `deny` (or `ask`, which degrades to deny until the permission system lands) skips dispatch and yields an `isError` result; input rewrite is deliberately NOT offered on `PreToolDecision` (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC). A `post-execute` `accept` may replace the model-facing `content` (clean, because `tool/result` is logged after `execute()` returns); a `block` turns the call into an `isError` whose content is the corrective `feedback`. Core dispatch sits between the waterfalls as plain code; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. An unregistered tool routes through the same catch as a tool-thrown error, so both failure classes get a structured `{ name, code }` (`ToolNotFoundError` → `UNKNOWN_TOOL`) — the loop records a failed tool call instead of failing the whole turn. +Call `next()` to delegate to the default (allow / dispatch / accept-unchanged), or return a decision/result to short-circuit. A `pre-execute` `deny` (or `ask`, which degrades to deny until the permission system lands) skips dispatch and yields an `isError` result; a registered `ToolGuard` runs after that waterfall and can impose a final denial. Input rewrite is deliberately NOT offered on `PreToolDecision` because it would desync the pre-execution audit/history/UI from what ran. A `post-execute` `accept` may replace the model-facing `content`; a `block` turns the call into an `isError` whose content is the corrective `feedback`. The awaited `tools/result` notification then receives the frozen execution identity and a deep-frozen result snapshot after every wrapper, post decision, and outer error catch; observers cannot transform the outcome or race each other through payload mutation, and one observer failure neither changes the result nor starves peers. An unregistered tool routes through the same catch as a tool-thrown error, so both failure classes get a structured `{ name, code }` (`ToolNotFoundError` → `UNKNOWN_TOOL`) — the loop records a failed tool call instead of failing the whole turn. ## The structured-output schema subset diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e004aae912..12caa6aa6f 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,18 +7,19 @@ 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:287`](../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:299`](../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:553`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:404`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:422`](../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), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:327`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:451`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:503`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:347`](../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:313`](../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:518`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:536`](../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/created` | `emit` | [`packages/core/agent/src/types.ts:298`](../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:312`](../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:585`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:417`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:435`](../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), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:340`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:464`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:516`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:360`](../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:326`](../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:531`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:549`](../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:568`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`strictSerial (serial)`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | @@ -26,16 +27,17 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../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:61`](../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:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:107`](../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:73`](../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:84`](../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:96`](../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:44`](../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:54`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:151`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:121`](../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:141`](../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:97`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:109`](../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:75`](../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:86`](../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:98`](../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:170`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:125`](../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:145`](../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:98`](../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:160`](../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`) | - | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 917386af9b..aba91c76aa 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -219,7 +219,7 @@ One bridged sub-dispatch from a `run_code` program: the parent `run_code` call i Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/tools/src/code-mode.ts:36`](../packages/core/tools/src/code-mode.ts) +Source: [`packages/core/tools/src/code-mode.ts:38`](../packages/core/tools/src/code-mode.ts) #### `tool/result` — surface diff --git a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md index 501f8c7331..80b9b86e2a 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md +++ b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md @@ -10,9 +10,10 @@ The product principle is "everything is a plugin": hooks, /goal, /loop, dynamic Pure Cordis event taxonomy. The loop's extension seams are typed events with deliberate dispatch modes: -- **waterfall** (around-middleware) where plugins mutate or veto: `agent/prompt-submit`, `agent/request`, `agent/step-result`, `agent/turn-continuation`, `tools/pre-execute`, `tools/post-execute`, `llm/stream`, `system-prompt/assemble`. -- **emit** (sync fire-and-forget) for notifications: turn/step boundaries, stream chunks, lifecycle, errors. -- **parallel** (awaited) for the one durability checkpoint: `session/flush`. +- **waterfall** (around-middleware) where plugins transform, veto, or wrap: `agent/prompt-submit`, `agent/request`, `agent/step-result`, `agent/turn-continuation`, `tools/pre-execute`, `tools/execute`, `tools/post-execute`, `llm/stream`, `system-prompt/assemble`. +- **serial** (awaited in listener order; a bail value stops later listeners) for ordered checkpoints: every `agent/pre-step` listener runs when all abstain, while the first stop returned from `agent/turn-stop` makes the terminal decision final. +- **parallel** (awaited fan-out) where every listener must get an independent chance: the `session/flush` durability checkpoint and the immutable observe-only `tools/result` notification. +- **emit** (synchronous fire-and-forget) for notifications: turn/step boundaries, stream chunks, lifecycle, and errors. The event vocabulary lives in interface packages (dsh-agent declares the agent/* events); `@deepseek-ai/dsh-agent-loop` is the only concrete loop plugin and is itself swappable — nothing outside it may depend on it. diff --git a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md index e786adbfe8..2b1c198812 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md +++ b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md @@ -33,11 +33,9 @@ These invariants hold and are pinned by tests: - A `tool-bash` HMR reload does NOT make an existing background task readable or killable by a different session (ownership survives on the executor). - Existing non-ACP demos still work without managing handles explicitly; config-created agents remain owned by the `AgentLoop` plugin fiber. -## Seam precondition (recorded) +## Session owner tokens are unique among live agents -The bash owner-token comparison relies on `session.header.id` being unique among live agents. The agent registry does NOT enforce this — it rejects a duplicate *agentId*, not a duplicate session id, and `createAgent` accepts an arbitrary `sessionId`. This is NOT reachable via ACP (UUID sessionId, `agentId === sessionId`, duplicate-load rejected), so it is not a live product hole, but a programmatic caller that registers two agents with the same session id would break bash isolation and mis-route the completion notice. The access *policy* (token comparison) stays in `tool-bash` (the consumer); the bash seam stores only an opaque `owner` string and never interprets it — the correct interface/impl/consumer split. - -The planned resolution is to remove the precondition by construction — see [unify the agent id and the session id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md): once an agent IS its session (one id), the registry's existing unique-`agentId` check is a unique-session-id guarantee and no two live agents can share a session token. +The bash owner-token comparison relies on `session.header.id` being unique among live agents. `SessionStore.enter()` rejects a duplicate live session id, and the async agent factory reserves both agent and session ids across persistence loading and unpublished setup before rechecking the store at publication. A programmatic caller therefore cannot publish two live agents with one session token. The access *policy* (token comparison) stays in `tool-bash` (the consumer); the bash seam stores only an opaque `owner` string and never interprets it — the correct interface/implementation/consumer split. ## Alternatives considered diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md index 8d7a5ab251..f5a4cb4b24 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -4,308 +4,702 @@ Status: implemented ## Problem -The harness runs multiple agents inside one application, but those agents need different capabilities and policies. A child created to summarize a file may need a different persona, a smaller tool set, and listeners that govern only its work; applying those contributions to every agent would leak authority and couple otherwise independent runs. +One application can run many agents that share infrastructure but must not share every capability or policy. A child agent may need a different persona, fewer tools, its own structured-result schema, and listeners that govern only its work, while still using the deployment's model adapters, persistence backend, tool implementations, and user interface. -This is not the same problem as running several isolated applications. The agents intentionally share the deployment's model adapters, persistence backend, tool implementations, and other services. What varies is the view assembled for one agent and the policy attached to its activity. +This is a composition problem, not an application-isolation problem. Starting a separate service graph for every child would isolate too much; putting every registration in one global graph isolates too little. -The affected extension surfaces include both data and behavior: - -| Surface | Per-agent need | Failure when global | +| Surface | What varies by agent | Failure when it is only global | |---|---|---| -| Tools | Hide dangerous or irrelevant tools; add a child-only result tool; replace one implementation for one agent | The model sees excess authority, or a child-specific tool leaks into every prompt | -| Prompt sections and variables | Give a child its own persona or runtime facts | Every agent receives the same instructions or values | -| Event listeners | Apply a hook, guard, or continuation policy to one agent | A listener written for one agent can veto or mutate another agent's work | -| Lifetime | Remove all of the above when the agent ends | Manual cleanup misses failure, cancellation, hot-reload, or owner-teardown paths | +| Tools | Available capabilities, a child-only tool, or a scoped replacement for one implementation | The model receives excess authority, or a child-specific tool leaks into every prompt | +| Prompt state | Persona, instructions, variables, and Code Mode SDK declarations | Every agent receives the same instructions or runtime facts | +| Live policy | Hooks, execution guards, result observers, and continuation rules | A listener intended for one agent can alter another agent's work | +| Lifetime | Cleanup when the agent fails, is cancelled, is disposed, or loses its owner | Registrations outlive the agent or disappear before its final work settles | -The model-visible and executable views must also agree. Hiding a tool only from the prompt is not a security boundary if a generated call can still execute it; hiding it only from execution produces a prompt that advertises unusable capabilities. The same consistency requirement extends to Code Mode bindings and user-interface presentation. +Two consistency requirements make the problem deeper than filtering a list. First, the model-visible and executable views must agree: a hidden tool must not remain callable, and an advertised tool must not fail merely because execution used a different registry view. This agreement must also cover Code Mode bindings and UI presentation. -The subagent API exposes the practical gap. A provider can accept a child persona, a tool filter, and a structured-output schema, but those options are honest only if two concurrent children can receive different registrations without mutating shared global state. +Second, some rules are invariants rather than cooperative extensions. An ordinary middleware listener may replace a prompt assembly, turn an allow into a deny, rewrite a result, force another model step, or short-circuit listeners registered after it. Structured output therefore cannot rely on being “first” or “last” in an extensible listener chain; the owning service needs a final boundary for rules that later listeners must not undo. + +The subagent API makes both needs concrete. Two concurrent children can request different personas, tool filters, and output schemas. Those requests are honest only when each child receives an independently owned view and when its terminal-output protocol survives unrelated plugins. ## Decision -Each live agent owns a registration context named `agent.ctx`. Registering through the application's ordinary plugin context contributes globally; registering through `agent.ctx` contributes to that agent alone and ties the contribution's lifetime to the agent. +Each live agent owns a registration context named `agent.ctx`, and services expose narrow owner-final policy boundaries where ordinary middleware ordering is not strong enough. Together these choices make one agent's world composable with normal plugin APIs while keeping authority, observation, and cleanup aligned. -The rule is intentionally small enough to be the normal plugin-author mental model: +The design has three parts: -| Registration context | Visibility | Lifetime owner | +| Part | Rule | Purpose | |---|---|---| -| Ordinary plugin context | Every agent | The registering plugin | -| `agent.ctx` | Exactly that agent | That agent | +| Registration scope | A registration through a plain plugin context is global; the same registration through `agent.ctx` belongs to that agent | Reuse existing APIs for per-agent tools, prompt state, and listeners | +| Lifecycle transaction | Create and resume await scoped setup while the agent and session are unpublished, then publish them in an ordered rollback-covered sequence | No observer sees a partially composed agent, and every failure path owns cleanup | +| Owner-final policy | Prompt protection, tool guards, final tool-result observation, and terminal turn stopping run at service-owned boundaries | Invariants do not depend on listener registration order | -The scope is flat. A child does not inherit registrations from its parent's scope; parent/child lineage remains explicit session data. A child sees the deployment-global layer plus its own layer, which prevents accidental authority inheritance through an agent tree. +The scope is flat. An agent resolves the deployment-global layer plus its own layer; a child does not inherit registrations from its parent's scope. Parent/child lineage remains explicit session data, and parent-owned disposal links lifetimes without silently inheriting authority. -This decision is implemented by the [`dsh-scope` primitive](../../../../packages/core/scope/README.md), scope-aware tool and prompt registries, scope-filtered event dispatch, and an agent lifecycle that creates and destroys the entire scoped world as one ordered operation. +The implementation lives primarily 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 Cordis event catalog](../../../cordis-catalog/events.md) is the exhaustive event-signature reference; this RFC explains why the contracts have their current shape. -## Background: the Cordis concepts used by the design +## Background: the small Cordis vocabulary used here -The implementation reuses four Cordis mechanisms. Readers do not need Cordis internals beyond this section; the [Cordis primer](../../../cordis-primer.md) is the broader reference. +The design relies on four framework ideas: contexts, effects, waterfall events, and dispatch receivers. This section gives the complete mental model needed for the rest of the RFC; the [Cordis primer](../../../cordis-primer.md) covers the framework more broadly. -### Contexts provide services +### A context is both a service view and a registration origin -A Cordis context is the object through which a plugin reaches shared services such as `ctx.tools`, `ctx.systemPrompt`, and `ctx.sessions`. A service call retains the context used to access it, so a registry can tell whether a registration came through an ordinary plugin context or through an agent's context without adding a scope argument to every method. +A Cordis `Context` is the object through which a plugin reaches services such as `ctx.tools`, `ctx.systemPrompt`, and `ctx.sessions`. A service method can recover the context through which it was accessed, so the service can tell whether a call came from an ordinary plugin context or from an agent's scoped context without adding a `scope` parameter to every registration API. -Contexts also represent a capability view. A derived context can reach only the services made available by the plugin that created it. Handing out `agent.ctx` therefore hands out the agent loop's injected service surface, a deliberate part of the `Agent.ctx` contract rather than an ambient root context. +A context also carries a capability view. A derived context reaches the services injected into the plugin that created it. Handing out `agent.ctx` therefore hands out the agent loop's injected service surface; it is not an ambient root context. -### Effects own registrations +### Effects give registrations an owner -Registrations are Cordis effects: adding a tool, prompt section, or listener returns cleanup behavior owned by the context's runtime unit, called a fiber. Disposing a fiber unwinds all effects registered through it, which is the basis for hot reload and reliable cleanup. +A Cordis effect is work whose cleanup belongs to a runtime unit called a fiber. Tool registration, prompt contribution, and event subscription are effects, so disposing their fiber unwinds them on normal teardown, failure, or hot reload. -`dsh-scope` creates a no-op plugin fiber for each scope. The fiber contributes no behavior of its own; it exists to provide one lifetime bucket for everything registered through the scoped context. +`dsh-scope` mounts a no-op plugin fiber for each scope. The plugin contributes no behavior; its fiber is the ownership bucket for everything registered through the scoped context. -### Event dispatch can filter listeners +### A waterfall is ordered around-middleware -Cordis decides which listeners receive an event by inspecting the object used as the dispatch receiver, known as the event's `this` value. `dsh-scope` supplies a receiver with a filter: unscoped listeners are admitted for compatibility, while a scoped listener is admitted only when its key matches the event's subject. +A Cordis waterfall is an extensible middleware chain. A listener calls `next()` to delegate, can inspect or replace the downstream result, and can return without calling `next()` to short-circuit everything inside it. -### Disposal order requires explicit nesting +This flexibility is useful for cooperative transformations, but registration order is not an invariant boundary. A later plugin can prepend another listener, a wrapper can replace the downstream result after `next()` returns, and a short-circuit can prevent inner listeners from running at all. -Cordis may dispose sibling effects concurrently. When order matters, a generator effect yields the exact disposer functions to create a nested last-in-first-out chain; wrapping a disposer in another function breaks the identity Cordis uses to remove it from the concurrent sibling set. +### The dispatch receiver selects scoped listeners -This detail drives both `Scope.rawDispose` and the convention that registry `register` methods return their exact Cordis disposer. It is what lets agent teardown wait for the loop, then unregister the agent, then detach the session, rather than racing those operations. +Cordis filters event listeners using the dispatch receiver, the object exposed as `this` inside a function-style listener. `dsh-scope` supplies a receiver carrying the operation's scope key, so the event system can admit global listeners plus listeners registered for that key and reject listeners belonging to other agents. -## Scope model +This receiver is live coordination state, not a durable session fact. The distinction matters later: `tools/result` is a live final-outcome notification, while the similarly named `tool/result` is an append-only session event stored for replay and model history. -The scope primitive joins visibility and ownership while remaining independent of the agent packages. This lets lower-level packages such as sessions and prompt assembly participate without depending upward on the agent loop. +## Agent-scoped registrations -### One registration fact controls two properties +An agent scope couples two facts that must not drift apart: who can see a registration and who disposes it. The calling context determines both facts, leaving the domain-specific merge rules to each registry. -The decisive fact is which context performed a registration. The scope tag selects its visibility layer, and the same context's fiber owns its disposal. +### The resolution model is global plus exactly one scope -Keeping those properties coupled prevents a dangerous state such as “visible to agent A but disposed with unrelated plugin B.” An API shaped as `register(value, { agent })` would make that state expressible and would retain global registration as the easy-to-forget default. +Every scope-aware registry keeps a global layer and per-scope layers. Resolving for agent A combines the global layer with A's layer only; it does not walk A's parent lineage or combine sibling scopes. -### Scope keys are opaque identities +| Registration origin | Visible to | Disposed with | +|---|---|---| +| Plain plugin context | Every agent | The registering plugin | +| `agent.ctx` | That agent only | That agent's scope | -A scope key is an object compared by identity, not a string or database identifier. The harness uses the live `Agent` object as its own key, so event payloads and execution records that already carry an agent can select the right scope without translating through another registry. +Named scoped contributions shadow a same-named global contribution. A child persona is therefore a scoped `deployment:persona` section, and a per-agent tool implementation can keep the same model-facing name. Duplicate names within one layer still fail loudly. The deliberate exception is a globally protected prompt-section name, whose owner reserves it against scoped shadowing. -Object identity decouples a live scope from externally meaningful or sequentially reused string IDs. The key is meaningful only during the live agent's lifetime. +The plugin-facing mechanism is the same API called through a different context. In language-neutral pseudocode: -### The primitive has four responsibilities +```text +# Deployment-wide contribution +appContext.tools.register(readTool) -The public API is small; the package README carries the exact signatures. +# Contribution visible only to agent A and disposed with A +agentA.ctx.tools.register(childOnlyTool) -| Responsibility | Mechanism | +resolveTools(agent A): + visible = copy(globalTools allowed by A's restrictions) + visible.overlay(tools registered through A.ctx) + visible.append(reserved presentation transport, when configured) + return visible +``` + +There is no `for each ancestor` step. Resolving for A never reads the parent or sibling layers. + +The scope key is an opaque object compared by identity. The harness uses the live `Agent` object as its own key, so event payloads, tool executions, and prompt assemblies that already carry the agent can select the correct layer without translating through a string ID that may later be reused. + +### `agent.ctx.agent` is an association, not the scope resolver + +`agent.ctx` carries an own `agent` property for setup code and plugin ergonomics. Contexts derived from it inherit that association, while a plain context reads `undefined`. + +The property is deliberately not treated as the authoritative scope tag. A nested scope can install a nearer scope key while still inheriting the original `ctx.agent` association, so lower-level services resolve layers with `scopeOf(context)`. In normal agent composition the two point at the same live agent; the separation keeps the generic scope primitive independent of the agent package. + +### The scope primitive has separate public and composite disposal forms + +`dsh-scope` exposes the minimum operations needed to create a layer, read it, target events, and dispose it. “Quiescent” here means that every asynchronous cleanup registered in the scope has settled and no teardown work remains in flight. + +| Operation | Responsibility | |---|---| -| Create an ownership bucket | `createScope(context, key)` mounts the no-op fiber and returns its derived context | -| Read the registration layer | `scopeOf(context)` reads the nearest inherited scope tag | -| Filter event delivery | `scopeTarget(subject, key)` creates the dispatch carrier | -| Tear down in a larger ordered lifecycle | `Scope.rawDispose` exposes the exact fiber disposer | +| `createScope(context, key)` | Mount the ownership fiber and return its tagged derived context | +| `scopeOf(context)` | Read the nearest inherited scope key | +| `scopeTarget(subject, key)` | Build the receiver used for scope-filtered dispatch | +| `Scope.dispose()` | Give ordinary callers an idempotent promise shared by repeat and racing calls until quiescence | +| `Scope.rawDispose` | Expose the exact Cordis disposer so a larger generator lifecycle can nest it at a precise teardown position | -Derived contexts inherit the tag, and a nested scope replaces it with the nearer key. This allows `agent.ctx.plugin(...)` to build a reusable profile whose registrations remain scoped to the same agent. +The two disposal forms solve different framework constraints. Cordis identifies nested effects by disposer-function identity, so an ordered composite lifecycle must yield `rawDispose` exactly. Cordis disposers are also single-shot, so a second raw call may not await the first asynchronous teardown; `Scope.dispose()` follows the backing fiber's in-flight lifecycle and gives all ordinary callers the same quiescence boundary, including a race in which `rawDispose` started first. The test/tooling `ScopeHost.dispose()` extends that shared boundary across its host fiber and every minted child scope. -### Scoping is explicit at each seam +The primitive itself is small. Its essential implementation shape is: -The scoped context automatically supplies effect ownership, but it does not magically change every service operation. A scope-aware registry must read `scopeOf()` when registering, and a scope-filtered event dispatcher must provide a carrier naming the operation's subject. +```text +createScope(parentContext, key): + fiber = mount no-op plugin under parentContext + scopedContext = derive fiber.context with nearest-scope-tag = key -Read and execution operations select their subject explicitly: callers use `tools.schemas(agent)`, `tools.get(name, agent)`, `tools.execute({ agent, ... })`, or `systemPrompt.assemble({ scope: agent, agent })`. Calling `agent.ctx.systemPrompt.assemble()` with an empty assembly context still asks for the global layer. This separation lets a shared service operate on behalf of any agent while making the subject visible at the call site. + rawDispose = fiber's exact disposer + dispose = memoized operation that: + invoke rawDispose if it has not started + follow fiber's in-flight teardown until quiescent -## Registration resolution + return { ctx: scopedContext, rawDispose, dispose } +``` -The tool and system-prompt services each keep a global layer plus a map of per-scope layers. Resolution combines only the global layer and the requesting agent's own layer. +Derived contexts inherit the nearest scope tag. Mounting an ordinary plugin under `agent.ctx` therefore preserves the agent's scope, while deliberately creating another scope replaces the tag for registrations below it. -### Scoped names shadow global names +### Registry resolution stays domain-specific -For named contributions, the scoped definition wins over a global definition with the same name. Duplicates inside one layer still fail loudly. +The shared primitive answers “which layer?” and “who owns cleanup?” but does not force every service to merge data the same way. Tools, prompt sections, variables, and tool-schema providers retain rules appropriate to their domains. -Shadowing is what makes a child persona ordinary configuration: the system-prompt service owns a global `deployment:persona`, and a child registers another section with that name through its context. The same rule supports a per-agent implementation of a model-facing tool without renaming the tool. +Prompt sections, prompt variables, and tools use scoped-over-global shadowing by name. Tool-schema providers are additive, but a provider registered through `agent.ctx` participates only in that agent's assemblies. Read operations name the subject explicitly: tool lookup and execution receive an agent or scope, and prompt assembly receives an `AssembleContext` whose `scope` selects the layer. -Tool-schema providers are additive rather than named, but a provider registered through `agent.ctx` is consulted only for that agent's assemblies. Prompt variables use the same named-shadowing rule as sections and tools. +Calling a service through `agent.ctx` does not implicitly make every later read agent-scoped. For example, `agent.ctx.systemPrompt.assemble()` without an assembly scope still requests the global layer. This keeps shared services able to operate on behalf of any subject and makes the subject visible at the read or execution call site. -### Restrictions mask global tools; scoped tools are explicit grants +### Tool registrations are frozen snapshots -`agent.ctx.tools.restrict({ allow, deny })` filters the global tool layer for that agent. `allow` keeps only listed names, `deny` removes listed names, and multiple restrictions intersect so independently installed policies can only reduce the global surface. +The tool view must not change because a caller kept the object it passed to `register()` or received a definition from `get()` or `visible()`. Registration therefore creates the stored identity once; future changes happen through explicit unregister/register effects. -Restrictions do not remove tools registered in the agent's own layer. A scoped tool is an explicit grant, which is necessary for facilities such as a child's `structured_output` capture tool to remain available under a restrictive allow-list. +Tool parameters cross the model and log boundary, so the registry requires them to be lossless JSON before cloning and validates the clone again to contain unstable getters. It snapshots the scalar fields, binds each callback once to the original definition as its method receiver, and deep-freezes the stored record. Replacing `definition.execute` after registration therefore has no effect, while a callback can still deliberately read mutable state from its closure or original receiver. `get()` and `visible()` return the frozen stored definitions; `schemas()` returns detached schema projections. -The restriction snapshots its arrays when registered, validates every named tool against the current pre-restriction universe, and rejects an empty filter. These choices make configuration mistakes loud and prevent later caller mutation from changing a live policy. +```text +registerTool(context, definition): + require definition.parameters is lossless JSON + parameters = clone(definition.parameters) + require parameters is still lossless JSON -An out-of-view tool resolves exactly like an unregistered tool and returns `UNKNOWN_TOOL` if called. This avoids exposing whether a hidden global implementation exists. + stored = deepFreeze({ + copied name, description, timeout, + parameters, + execute: bind definition.execute to definition, + presentation callbacks: bind once when present + }) -### One visibility function feeds every consumer + layerFor(scopeOf(context)).add(stored.name, stored) +``` -The tool registry defines one canonical `visible()` rule and every consumer uses it for prompt schemas, lookup, execution, Code Mode's generated SDK and bindings, timeout-policy lookup, Cordis inspection, and ACP presentation. A model cannot be shown one definition while execution or the UI resolves another. +The reserved Code Mode transport uses the same frozen-definition contract even though it lives outside the ordinary layers. -Code Mode introduces one intentional distinction. A restriction is per-agent runtime state, so a globally configured `toolOrder` may name a restricted-away tool and simply leave an empty position for that agent. By contrast, `mode: 'code'` is deployment configuration that deliberately collapses the wire-visible universe to `run_code`; a `toolOrder` that still names native tools is invalid configuration and fails every assembly. +### Tool restrictions reduce end capabilities without removing transport -To preserve this distinction, a prompt tool provider returns both the post-restriction schemas and the pre-restriction `knownNames` universe. Ordering validates names against the latter but orders only the former. +A tool restriction masks the global end-capability layer for one agent, while tools registered in that agent's own layer are explicit grants. Multiple restrictions intersect, so separately installed policies can only reduce the global surface. + +The restriction snapshots its input, rejects an empty filter, and validates named tools against the pre-restriction capability universe. A restricted-away tool behaves like an unknown tool at execution, avoiding disclosure of a hidden global implementation. + +[Code Mode](../feature/2026-06-15-code-mode.md)'s `run_code` is not an end capability. It is a reserved presentation transport that carries calls to the visible end capabilities, so the registry keeps it outside both global and scoped registration layers: restrictions cannot remove it, a scoped tool cannot shadow it, and configuration cannot explicitly allow or deny it. Without this exception, a restriction could leave the generated SDK in the prompt but remove the only way to invoke it. + +The registry still uses one executable visibility view. It first resolves restricted global capabilities plus scoped grants, then appends the reserved transport in non-native modes; registry-owned prompt schemas, lookup, execution, Code Mode SDK bindings, timeout lookup, inspection, and UI presentation all consume that view. + +The guarantee covers the tool registry's contribution. A plugin can deliberately use the lower-level `systemPrompt.tools()` API or assembly waterfall to add an unrelated wire schema; that plugin owns the matching executable behavior and any ordering it introduces. Owner protection preserves reserved named infrastructure without turning the system-prompt service into a validator for unrelated contributions. + +`knownNames` serves a narrower configuration purpose: it is the pre-restriction end-capability universe used to distinguish a typo from a deliberately hidden tool. The system-prompt provider adds presentation names when validating `toolOrder`: `code` mode accepts only `run_code`, `both` accepts end capabilities plus `run_code`, and a per-agent restriction may remove a known capability from one assembly without turning the deployment's order configuration into an error. ## Scoped event delivery -Registrations alone are insufficient: a listener installed for one agent must hear only events about that agent. Scoped dispatch applies that rule while preserving the existing behavior of global plugins. +Scoped registration is incomplete unless behavior follows the same boundary. An event about agent A reaches global listeners and A-scoped listeners, never listeners installed for B. -### Delivery is global-plus-matching-scope +### Delivery is global plus the matching scope -For an event about agent A, the dispatch carrier admits ordinary unscoped listeners and listeners registered through A's context. It rejects listeners registered through every other agent context. +The dispatch receiver carries the operation's scope key. Its filter admits an unscoped listener or a listener registered through the matching scoped context, while a subject-less dispatch admits unscoped listeners only. Cordis's explicit `{ global: true }` listener option remains the intentional bypass for infrastructure that must observe every dispatch. -A subject-less dispatch, such as an agent-less tool execution or a bare session created outside an agent scope, admits only unscoped listeners. Cordis's explicit `{ global: true }` listener option still bypasses filtering for infrastructure that intentionally observes everything. +Registry-membership notifications remain unfiltered. Events such as `tools/change`, `system-prompt/change`, and `subagent/provider-*` describe shared registry state rather than one agent's activity, so a scoped subscriber still observes those global changes. -Events about registry membership stay unfiltered. A notification that a tool or provider was added concerns shared registry state rather than one agent's activity, so scoped subscribers to `tools/change`, `system-prompt/change`, or `subagent/provider-*` still hear the global notification. +### Each event family derives its key from its real subject -### Each event family has one scope source +The operation being described determines the key; callers cannot attach an unrelated scope. Fused helpers and store-owned carriers keep the payload subject and delivery subject together. -The event subject determines the key; callers do not choose an unrelated scope. - -| Event family | Scope key | +| Event family | Scope source | |---|---| -| `agent/*` | The event's agent | -| `tools/pre-execute`, `tools/execute`, `tools/post-execute` | `execution.agent`, or no key for an agent-less call | -| `system-prompt/assemble` | The assembly context's scope | +| `agent/*`, including `agent/turn-stop` | The event's agent | +| `tools/pre-execute`, `tools/execute`, `tools/post-execute`, `tools/result` | `ToolExecution.agent`, or no key for an agent-less call | +| `system-prompt/assemble` | `AssembleContext.scope` | | `session/created`, `session/event`, `session/flush` | The owner scope captured when the session enters the store | | `subagent/start`, `subagent/end` | The delegating parent agent | -Agent events use `agentEvents(context, agent)`, which creates the carrier and injects the same agent as the first event argument in one operation. Prompt assembly similarly uses `assembleContextFor(agent)` to set both the human-friendly `agent` field and the scope selector. These fused helpers make a mismatched carrier and subject difficult to express. +The dispatch rule can be read independently of Cordis internals: -The session store captures its carrier when a session is entered because later appends and flushes may originate from code that no longer has the agent's context in hand. `ctx.sessions.flush(session)` is the only durability-checkpoint entry point, so callers cannot forget the captured carrier. +```text +dispatchScoped(subject, scopeKey, event, arguments): + carrier = proxy(subject, tag = scopeKey) -### The carrier is method-transparent + for listener in listeners(event): + if listener has no scope tag or requests the explicit global bypass: + call listener with this = carrier + else if listener.scopeTag == scopeKey: + call listener with this = carrier + else: + skip listener +``` -Cordis passes the dispatch carrier to a function-style listener as `this`. Agent event declarations allow a listener to call methods such as `this.send(...)`, so the carrier must behave like the real subject rather than merely look like it. +The real helpers fuse values that must agree: `agentEvents(context, agent)` supplies the same agent as subject, scope key, and first event argument; session storage captures its carrier once at `enter()`. -The carrier is a JavaScript proxy whose property reads use the real subject as receiver and whose methods are bound to that subject. This matters for classes with native private fields: calling a method with the proxy itself as receiver would throw because the proxy does not possess the class's private-field identity. +`agentEvents(context, agent)` creates the carrier and injects that same agent as the first event argument. `assembleContextFor(agent)` similarly sets both the agent-facing field and the scope selector. The session store captures its carrier when a session enters because later appends and flushes may occur where the original agent context is no longer available. -The proxy preserves the subject's own event filter, writes through to the subject, returns the real constructor, and obeys JavaScript's invariants for frozen own properties. Its object identity is intentionally not transparent; the actual subject is also present in event arguments whenever identity matters. +### The carrier behaves like the subject but has distinct identity -`Scoped` is a TypeScript-only marker requiring a carrier at scoped dispatch sites. It adds no runtime behavior; runtime carrier marks and development invariants provide the corresponding check for JavaScript and casted code. +Function-style listeners receive the carrier as `this`, and agent event APIs allow them to call subject methods. The carrier is therefore a JavaScript proxy that reads and writes through to the real subject and binds methods to it. + +Binding matters for classes with JavaScript private fields: a method called with the proxy itself as receiver would fail the runtime private-field identity check. The proxy preserves the subject's existing event filter and JavaScript object invariants, but it is intentionally not identity-equal to the subject; event arguments carry the real object whenever identity matters. + +`Scoped` is a TypeScript-only marker that requires this carrier at declared scoped dispatch sites. It improves authoring but adds no runtime security, so runtime marks and development invariants check the same contract for JavaScript, casts, and hand-written dispatches. ## Agent creation and teardown -An agent's scope, session, registry entry, and driver loop form one lifecycle. Creating them inside one composite effect gives both rollback on partial construction and deterministic teardown on every ownership path. +An agent's scope, session, registry entry, and driver form one owned transaction. Setup finishes before publication, publication is synchronous and rollback-covered rather than magically atomic, and teardown reaches one ordered quiescent boundary. -### Creation has a deliberate composition window +### Create and resume reserve identities before asynchronous work -Agent creation proceeds in this order: +Programmatic create and resume reserve both the agent ID and session ID before work that can await. Create prepares a fresh or seeded session; resume first loads and reconstructs the persisted session. Both paths then construct the agent, mint `agent.ctx`, and install the complete teardown skeleton before awaiting setup. -1. Construct the live agent object. -2. Mint its scope and assign `agent.ctx`. -3. Enter the session through `agent.ctx`, capturing the session's carrier. -4. Announce `session/created`. -5. Register the agent, which announces `agent/created`, so setup code can resolve it. -6. Run `CreateAgentOptions.setup(agentCtx)` to compose scoped tools, prompt contributions, restrictions, listeners, or child plugins. -7. Emit `agent/session-start`. -8. Start the driver loop. +The factory captures IDs and the setup callback and clones caller-owned agent options, session metadata, and seed events before the first asynchronous boundary. Resume does the same before persistence loading. A caller mutating its options object later therefore cannot move the transaction away from the identities it reserved or change the configuration eventually published. -The setup callback runs inside the storage rollback boundary and before the first prompt assembly. A synchronous throw removes the agent and session and unwinds every scoped registration, so no half-created entry keeps either ID occupied. +Reservations prevent two concurrent transactions from composing different unpublished agents under the same public identity. They remain held across persistence loading and setup and are released on every success or failure path. -The two creation notifications occur before setup. Observers can therefore see the pre-setup world, and listeners installed by setup do not receive this agent's `session/created` or `agent/created`; rollback cannot retract external side effects those earlier listeners performed. This is a current atomicity limitation, not a guarantee provided by the setup window. +Resume installs an owner-liveness sentinel before reserving IDs or starting persistence I/O, then races loading against owner disposal. If disposal wins, resume rejects and releases both reservations immediately; a backend promise that settles later cannot publish. After a successful load, the factory synchronously installs the full agent lifecycle before removing the sentinel, so ownership passes from load to setup without an unobserved disposal gap. -Setup performs direct synchronous registrations but does not drive the agent. Calling `send`, `steer`, or `inject` there could open a turn before `agent/session-start`, reversing a lifecycle contract used by bridges and hooks; development invariants report that misuse at the first `turn/start`. Mounting an asynchronously activating child plugin also does not extend the synchronous setup window unless its activation ordering is separately awaited. +The sentinel exists only for the interval in which no agent lifecycle can exist yet: -### Teardown waits for one quiescent boundary +```text +resume(request): + snapshot request ids, options, and setup callback + sentinel = owner.effect(onDispose => signal ownerDisposed) + reserve(agentId, sessionId) -The yielded disposers produce this teardown order: + try: + persisted = await firstOf(persistence.load(sessionId), ownerDisposed) + session = reconstruct(persisted) -1. Request the loop to stop and await its actual exit, including its closing session events and durability flush. -2. Unregister the agent. -3. Detach the session from the store. -4. Unwind the scope's listeners and registrations. + # This call installs the full lifecycle before its first await. + starting = startOwned(agentId, session, options, setup) + disarm and dispose sentinel + return await starting + finally: + release both ids + settle the sentinel transaction +``` -Detaching the session before the asynchronous scope unwind keeps registry and store rollback synchronous on construction failures. Scoped listeners remain installed through the stop-and-drain phase, so they hear the final flush before the session detaches. +If `ownerDisposed` wins, the load promise may continue inside the backend, but it has no path back to publication. -Cordis disposers are single-shot but a second call does not necessarily await a first call already in progress. The agent lifecycle therefore owns a shared completion promise in addition to the raw disposer. Tool cleanup, parent teardown, explicit `AgentHandle.dispose()`, and owner-fiber unload all await the same fully quiescent result. +### Setup composes an unpublished world -Every registry returns its exact effect disposer so the composite lifecycle can preserve this order even when the whole owner fiber unloads. Returning a wrapper would leave the inner registration as a concurrently disposed sibling and could emit `agent/disposed` while the final turn was still draining. +The optional `setup(agentCtx)` callback receives the new agent context and may synchronously register contributions or await child-plugin activation. During setup, neither the session nor agent is visible through its global registry, but `agentCtx.agent` exposes the unpublished agent to the code composing it. + +Setup may register scoped tools, prompt sections, variables, restrictions, listeners, protections, or child plugins. If it throws or rejects, the scope unwinds without publishing either object, and the reserved IDs become reusable. If the owner unloads during an await, the preinstalled teardown skeleton marks the transaction inactive; late setup completion cannot publish. + +After setup settles, the factory yields one microtask checkpoint and rechecks the lifecycle flag, owner-fiber state, and owning agent's disposed state. Cordis begins owner unload synchronously but may run nested effect disposers in the next microtask; the explicit owner checks and checkpoint let a same-turn unload win instead of allowing an immediately fulfilled setup to publish an already-doomed agent. + +Setup composes but does not drive. The concrete agent rejects `send`, `steer`, `inject`, and `cancel` until publication reaches the session-start boundary, keeps its inbox in a JavaScript native-private field, and allows only one concrete driver to claim a session. Driver startup is absent from the package surface: the package exports neither its loop/inbox internals nor source subpaths, and only instance-bound controls held by the factory can enable and start the driver. JavaScript or a type cast therefore cannot bypass the lock by calling a public `start()` or writing directly into the queue. These boundaries prevent a turn from opening before lifecycle listeners know the session exists. + +The common create/resume tail makes the unpublished boundary explicit: + +```text +startOwned(snapshot, preparedSession): + world = prepareLifecycle(snapshot, preparedSession) + # world now owns agent.ctx and the complete rollback/teardown skeleton + + try: + await firstOf(snapshot.setup(world.agent.ctx), world.deactivated) + await oneMicrotask() + require world.lifecycleActive + require world.ownerFiberActive + require world.ownerAgentNotDisposed + + world.publish(snapshot.source) + return handle(world.agent, world.dispose) + catch error: + await world.dispose() + throw error +``` + +`setup` can await arbitrary plugin activation, but every exit still passes through the already-installed disposer. + +### Publication is ordered and rollback-covered + +After setup succeeds, the factory publishes in one synchronous sequence with no `await` between steps: + +1. Enter the session store and capture its scope carrier. +2. Enter the agent registry without announcing it. +3. Emit `session/created`. +4. Emit `agent/created`. +5. Enable driving. +6. Emit `agent/session-start`. +7. Start the driver loop. + +The implementation keeps publication synchronous and leaves rollback to the surrounding owned transaction: + +```text +publish(world): + world.detachSession = world.agent.ctx.sessions.enter(world.session) + world.detachAgent = app.agents.enter(world.agent) + app.sessions.announce(world.session) + app.agents.announce(world.agent) + world.driver.enableDrivingVerbs() + emitNonVetoing(agent/session-start) + world.stopDriver = world.driver.start() +``` + +Both registry entries exist before the first creation listener runs, and setup-installed listeners receive both announcements. Driving opens immediately before `agent/session-start`, so that event remains the first supported place for a listener to inject or queue startup work. + +The sequence is not described as atomic because observers run between its steps. If a `session/created` or `agent/created` listener throws, the transaction rolls the registry entries and scope back, but effects already performed by an earlier listener cannot be retracted. An announced agent is paired with its disposal notification during rollback. `agent/session-start` is a non-vetoing notification: listener failures are logged and contained so the loop still starts. + +### Teardown stops work before revoking its world + +Every owner path uses the same reverse order: stop the loop and await its actual exit, remove the agent from the registry, detach the session, then unwind the scope. The final session events and durability flush therefore happen while the session and scoped listeners are still live. + +```text +disposeOwnedAgent(world): + world.stopDriver() + await world.agent.done # waits for any in-flight turn close and flush + world.detachAgent() # emits agent/disposed when announced + world.detachSession() + await world.scope.dispose() +``` + +The actual Cordis generator yields these disposers in reverse so its last-in-first-out teardown executes in the order shown. + +`agent/disposed` means the driver is quiescent and the agent has left the registry; session detachment and scope unwind may still be completing after that notification. `AgentHandle.dispose()` is memoized so concurrent owners await the same full transaction, and `Scope.dispose()` provides the corresponding shared boundary for direct scope disposal and raw-disposer races. + +Parent-owned subagents use explicit ownership rather than capability inheritance. The driver creates one run-owner fiber under `parent.ctx` and invokes the child factory through that fiber, so lifecycle ownership exists before setup or publication begins; disposing a parent reaches its descendants even if a delegating tool never reaches its own `finally`. The child still receives a newly minted scope and resolves only global plus child-scoped capabilities. + +## Owner-final policy boundaries + +Cooperative waterfalls remain the general extension mechanism, but an invariant belongs after the last transformable point. The design adds four narrow boundaries, each owned by the service that can define what “final” means. + +### Prompt protection restores named canonical contributions + +`systemPrompt.protect({ sections, tools })` declares that selected names must match the canonical registry/provider assembly after the complete `system-prompt/assemble` waterfall. Protections registered globally and for the current scope compose by set union, so callback order cannot weaken them. Protection finalizes a returned assembly rather than recovering from listener failure; if the waterfall throws, assembly still fails. + +For each protected name, the service restores the canonical presence and definition. If the canonical assembly omitted the name, protection removes a listener-fabricated entry; this makes mode-dependent absence enforceable as well as presence. + +A global section protection also reserves the registry name against scoped shadowing. Registering a scoped section under an already protected global name throws, and adding global protection throws if any scoped shadow already exists. Section registration copies `name`, `order`, and the text value or callback before the check and stores that record, so later mutation of the caller's object cannot rename a safe section into a reserved one. This check must happen before assembly: otherwise the ordinary scoped-over-global merge would make the shadow itself look canonical, leaving post-waterfall restoration with the wrong owner's value. Tool-schema protection does not impose a blanket schema-name reservation because providers are additive and may deliberately contribute unrelated executable schemas. + +Restoration is intentionally not a whole-assembly reset. The service first removes protected names from the waterfall result, then reinserts protected canonical entries in their canonical order immediately before the first surviving later unprotected canonical neighbor, or at the end when no such neighbor survives. Unprotected entries keep the ordering and definitions chosen by the waterfall. This anchor rule preserves the protected contribution's meaningful local placement without claiming that protection restores every global relative position after arbitrary listener reordering. + +```text +registerSection(input, scope): + stored = copy(input.name, input.order, input.text) + if scope exists and stored.name is globally protected: + fail before registration + sectionLayer(scope).add(stored) + +assemble(context): + canonical = assemble registries for context.scope + transformed = await systemPromptAssembleWaterfall(clone(canonical)) + + for each protected name: + remove every transformed entry with that name + if canonical contains the name: + if a later unprotected canonical neighbor survived: + insert the canonical entry before that neighbor + else: + append the canonical entry + + return transformed +``` + +This algorithm restores a protected entry's definition, presence or absence, and useful local anchor without erasing unrelated listener output. + +Code Mode uses global protection for the `tools:sdk` section and reserved `run_code` schema. Structured output adds scoped protection for its instruction and capture schema. These are named guarantees: unrelated listeners may still contribute unrelated sections or tools. + +### Tool executions have stable identity + +`ctx.tools.execute(input)` accepts a caller-owned `ToolExecutionInput` and snapshots it into a distinct pipeline-owned `ToolExecution`. The registry requires `arguments` to be losslessly JSON-serializable, validates before cloning and again after cloning to contain unstable accessors, then deep-freezes the detached value. A cloneable but mutable exotic such as `Map` is rejected before policy rather than smuggled through an apparently frozen wrapper. Invalid input still produces one normalized final error notification. + +The registry assigns each pipeline trip a frozen, property-free `ToolExecutionToken`; callers cannot choose that token. The execution's `token`, `callId`, `name`, `agent`, optional opaque `parent` token, and detached `arguments` are non-writable and non-configurable from the first policy listener onward. `signal` is the only operational field: an around-dispatch wrapper may add, replace, or remove it, and the registry freezes the complete execution before outcome observation. + +Stable identity prevents a listener from changing which capability or scope was authorized after policy ran. It also gives commit-style observers a safe `WeakMap` key even when an adapter reuses a model call ID. + +For a nested transport dispatch, `parent` carries only the enclosing execution's opaque token rather than its live object. Code Mode sets an SDK sub-call's `parent` to the outer `run_code` execution's `token`, so an observer can correlate the two outcomes without receiving a reference that could mutate the still-running outer wrapper. + +The input-to-execution conversion is intentionally one-way: + +```text +prepareExecution(input): + require input.parent is absent or a registry-minted token + require input.arguments is lossless JSON + detachedArguments = clone(input.arguments) + require detachedArguments is still lossless JSON + + execution = { + token: new frozen property-free object, + callId: input.callId, + name: input.name, + arguments: deepFreeze(detachedArguments), + agent: input.agent, + parent: input.parent, + signal: input.signal + } + + make every field except signal non-writable and non-configurable + return execution +``` + +### Tool guards can deny but never re-allow + +`ctx.tools.guard()` installs a synchronous global or scope-specific guard after the extensible `tools/pre-execute` waterfall and before dispatch. A guard returns a denial reason or `undefined`; it has no allow result. + +This one-way result makes the boundary monotonic. Pre-execution hooks can still compose ordinary allow, deny, and ask decisions, but no listener ordering can convert a guard denial back into dispatched work. A denied call still continues through result transformation and final observation as an error outcome. + +### `tools/result` observes the authoritative live outcome + +The complete live pipeline is `tools/pre-execute` → monotonic guards → `tools/execute` → `tools/post-execute` → `tools/result`. The first three named events are transformable waterfalls; `tools/result` is an awaited, observe-only notification after all transforms and the registry's outer error normalization. Immediately before that boundary, the registry validates that the entire authoritative result can round-trip losslessly through JSON; an invalid tool or listener result becomes a normal JSON-safe `isError` outcome instead of reaching observers as apparent success and failing later at the session log. + +Every `tools/result` listener receives the same frozen execution and deep-frozen result snapshot. Listener failures are contained independently, so they cannot change the caller's result or starve peer observers. Scope filtering derives from `execution.agent`. + +`tools/result` is not the durable session event `tool/result`. The live notification belongs to the registry and also fires for direct programmatic executions; the agent loop subsequently appends `tool/result` to the session log for replay, UI reconstruction, and model history. A policy that needs the final in-process verdict uses the former, while a consumer that needs persisted transcript state uses the latter. + +The entire registry method reads like one authority ladder: + +```text +execute(input): + try: + execution = prepareExecution(input) + catch invalidInput: + execution = frozen identity shell with arguments = undefined + result = errorResult(invalidInput) + await tools/result observers with independent failure containment + return result + + try: + ordinaryDecision = await tools/pre-execute(execution) + if ordinaryDecision allows: + denial = firstRegisteredGuardDenial(execution) + else: + denial = ordinaryDecision.denial + + if denial exists: + result = errorResult(denial) + else: + result = await tools/execute(execution, next = dispatchRegisteredTool) + result = requireValidExecutionResult(result) + + result = await tools/post-execute(execution, result) + result = requireLosslessJson(result) + catch pipelineFailure: + result = errorResult(pipelineFailure) + + freeze(execution) + frozenResult = deepFreeze(clone(result)) + await every tools/result observer independently, containing each failure + return result +``` + +Waterfalls can transform only at their named stages. Guards can only deny, and the final observers can only observe. + +### `agent/turn-stop` makes a composed continuation terminal + +Ordinary continuation remains extensible. The loop computes a default, runs the `agent/turn-continuation` waterfall, records any force-continue reason as steering, and folds pending steering into the decision because steering normally demands another model step. + +The scoped serial `agent/turn-stop` checkpoint runs after that folding. Its strict serial helper consults listeners in order until one returns a non-`undefined` value; a listener returns `{ action: 'stop' }` or abstains with `undefined`. The dedicated helper exists because ordinary Cordis serial dispatch treats `null` and `false` as framework abstentions, while this public contract has exactly one abstention value. A stop is terminal, so later listeners and pending steering cannot restore continuation. A malformed result, including `null` or `false`, or a throwing policy closes the current turn with an error while leaving the driver available for later work. + +Terminal stop deliberately discards steering while preserving ordinary queued prompts. Its terminal state remains in force through `turn/end` and the durability flush, so steering added by continuation, turn-close, or flush listeners cannot escape through the loop's late-steering fallback into another step or turn. This is the explicit exception to the normal rule that leftover steering becomes input for another turn. The authority is reserved for protocols, such as a completed structured child, where further model work would violate the result contract. + +```text +afterSuccessfulStep(turn): + decision = await agent/turn-continuation(defaultDecision) + record decision.reason as steering when present + if steering is pending: decision = continue + + terminal = await strictSerial(agent/turn-stop) + # undefined means abstain; null, false, malformed values, and throws are errors + if terminal == stop: + discard steering + terminalStopped = true + decision = stop + + append turn/end + await session/flush + + if terminalStopped: + discard steering added by turn/end or flush listeners + else: + move leftover steering to the next-turn queue +``` + +The queued-prompt FIFO is separate and is never drained by terminal stop. ## Subagent composition -The subagent seam demonstrates why agent scoping exists: a provider can compose a child-specific world with ordinary registrations and let the agent lifecycle own it. +In-process subagents demonstrate how the scope, lifecycle, and final-policy pieces compose. A provider builds the child's world during unpublished setup, then lets the ordinary agent lifecycle own it. -### Persona and tool filters become real capabilities +### Inputs and ownership are fixed before asynchronous creation -The in-process spawn and fork providers advertise persona and tool-filter support because their child setup can register a shadowing `deployment:persona` section and a tool restriction through the child's context. ACP remains honest about not supporting those capabilities because it delegates to a separate process whose registration context is not locally available. +Starting a run snapshots every accepted field before asynchronous owner setup. The parent and abort signal are retained as identity capabilities but never reread from the mutable request record; tool filters, seed events, agent options, output schema, and prompt are detached. The schema is validated before cloning, while the prompt must pass the same lossless-JSON check before and after cloning that the session log requires. Later caller mutation therefore cannot change lifecycle scope, configuration, the schema enforced by the capture tool, or the prompt eventually logged and sent. -Omitted configuration stays absent. This matters for schema-driven configuration: a materialized empty `allow` list means “allow nothing,” which is not equivalent to an omitted list, and an empty filter is not equivalent to no filter. The configuration schema preserves those distinctions before setup calls `restrict`. +The driver first installs provider ownership. Only after that succeeds does it attach the request's abort listener and create one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves neither a child nor an orphaned listener. The child factory runs through the owner fiber. Parent teardown, provider teardown, and manual run disposal all dispose this same node; moving it out of the active state synchronously prevents an unpublished setup from publishing afterward, while all three paths follow one quiescence promise. This structured ownership does not change the child's flat capability view. -### Parent disposal owns the subtree +```text +startInProcessRun(providerContext, acceptedRequest): + snapshot all request data, including parent identity -After creating a child, the in-process driver registers the child's memoized disposer as an effect on the parent scope. Disposing a parent therefore reaches the whole descendant tree even if the delegating tool's `finally` block never runs. + providerLink = providerContext.effect(onDispose => disposeRunOwner()) + attach snapshot.abortSignal listener + runOwner = mount no-op plugin under snapshot.parent.ctx -This is structured concurrency expressed through ownership rather than through scope inheritance. The child still has a flat capability view—global plus child-only registrations—while its lifetime is linked explicitly to the parent. + returnedRun.dispose = () =>: + dispose providerLink + await disposeRunOwner() -### Structured output becomes per-child state + childHandle = await runOwner.ctx.agents.create({ + fresh ids and lineage, + cloned options and optional seed, + setup(childCtx) => install persona, tool restriction, structured runtime + }) +``` -A structured child registers a real-schema `structured_output` tool and its instruction section through its own context. Concurrent children can carry different schemas because each resolves only its own tool definition; no placeholder global schema, reference count, or strip-for-other-agents pass is needed. +Parent teardown reaches `runOwner` by nesting; the provider and returned run handle reach the same node through their explicit disposers. -Four scoped listeners enforce the terminal protocol: +### Persona, filtering, and lifetime use ordinary registrations -1. An outer prompt-assembly listener reasserts the exact tool schema and instruction after downstream listeners have run. It replaces an existing tool in place, appends it when absent, removes duplicates, and restores the instruction to its order-190 section band. -2. A tool pre-execution listener denies calls after a value has been captured, preventing later side effects in the same model response. -3. A tool post-execution listener commits a staged value only if the final pipeline decision accepts that same execution. -4. A turn-continuation listener stops the child after capture instead of spending another model step merely because a tool ran. +A child persona is a scoped `deployment:persona` section that shadows the deployment-wide section. A child tool filter is a scoped restriction over global end capabilities. Omitted filters remain omitted; a materialized empty `allow` list means “allow nothing” and is not confused with absence. -Staging is keyed by the `ToolExecution` object's identity in a `WeakMap`, not by the model or adapter's call ID. Only the pipeline trip whose tool body staged a value can commit it; a blocked trip cannot leave state that a later call with a reused ID accidentally promotes. The weak key also allows an abandoned stage to be reclaimed without global cleanup bookkeeping. +The child's persona, filter, and structured runtime are installed inside factory setup. The common run-owner fiber gives structured-concurrency-style teardown without importing the parent's capability layer into the child. + +### Structured output is a child-owned terminal protocol + +A structured child registers a real-schema `structured_output` tool and its instruction through its own context. Concurrent children can use different schemas because each scope resolves its own definition, with no global placeholder, reference count, or remove-for-everyone-else pass. + +Presentation mode changes where the model invokes the capture capability, but not which child owns it: + +| Tool mode | Registry's canonical wire contribution | Generated SDK | Structured-output guarantee | +|---|---|---|---| +| `native` | Visible end-capability schemas, including scoped `structured_output` | None | Protection restores the capture schema and instruction | +| `code` | Reserved `run_code` transport | Visible end-capability bindings, including `structured_output` | Protection keeps `run_code` and the SDK present, keeps native `structured_output` absent from the wire, and restores the instruction | +| `both` | Visible native schemas plus reserved `run_code` | Visible end-capability bindings, including `structured_output` | The model may call the protected capture capability natively or through the protected transport | + +The table describes the registry's named canonical contribution. An unrelated assembly listener may deliberately add another schema; protection does not erase unrelated names. + +### Capture uses stage, final commit, monotonic denial, and terminal stop + +The capture tool validates its arguments and stages the cloned value in a JavaScript `WeakMap` keyed by the immutable `ToolExecution`. This is an object-identity table whose key does not keep an abandoned execution alive. Validation failure becomes the ordinary `INVALID_ARGS` error that the model can correct within the turn. + +The scoped `tools/result` observer commits a direct native capture only when that exact execution's authoritative final result succeeds. A later call with a reused string call ID cannot reach the weak-keyed stage, and a post-execution block cannot promote it. + +```text +# Native structured-output call +structured_output.body(value, execution): + validate value against this child's schema + staged[execution] = clone(value) + return ordinary success + +on tools/result(execution, finalResult): + if execution is staged: + value = staged.remove(execution) + if finalResult succeeded: + captured = value +``` + +For a Code Mode SDK call, successful inner observation records a pending value against the child execution's opaque `parent` token instead of committing immediately. When the enclosing `run_code` reaches its own `tools/result`, the observer compares that pending token with the outer execution's `token` and commits only on success. A program error or outer post-policy block discards the pending value. This extra boundary is necessary because an inner side effect can succeed while the transport that is supposed to deliver the structured answer still fails. + +```text +# Code Mode adds an outer transport commit +on tools/result(innerStructuredCall, innerResult): + if innerStructuredCall is staged: + value = staged.remove(innerStructuredCall) + if innerResult succeeded: + pending = { outerToken: innerStructuredCall.parent, value } + +on tools/result(outerRunCodeCall, outerResult): + if pending.outerToken == outerRunCodeCall.token: + value = pending.value + pending = none + if outerResult succeeded: + captured = value +``` + +The native path has one final-result commit; Code Mode has two because the inner capability and outer transport can fail independently. + +Once a value is captured or pending on its outer transport, the scoped `ToolGuard` denies later calls in the same response. After a committed capture, the scoped `agent/turn-stop` ends the turn after ordinary continuation and steering have been folded. Together these boundaries prevent post-capture side effects and prevent a successful tool call from purchasing an otherwise automatic extra model step. + +The provider does not re-prompt a child that finishes without a committed capture. Such a run returns an error result with no `structured` value; requesting an output schema creates a requirement, not a guarantee that a failed child produces a value. ## Correctness enforcement -Scoping errors are dangerous because a missed carrier silently restores global delivery. The implementation therefore makes the safe path short and checks it at type, runtime, test, and documentation boundaries. +Scope mistakes are fail-open if they merely omit a carrier, so the implementation checks the contract at API, type, runtime, and repository-gate boundaries. None of these checks substitutes for using the correct runtime carrier. -### Compile-time and API shaping +### API shape couples subjects that must agree -Scoped event declarations require the `Scoped` carrier marker. `agentEvents` couples carrier creation to the agent argument, `assembleContextFor` couples agent prompt facts to the scope selector, and session flush is a service method that owns carrier lookup. +`agentEvents(context, agent)` couples the dispatch carrier to the agent argument, `assembleContextFor(agent)` couples prompt facts to the scope selector, and `SessionStore.flush(session)` owns lookup of the carrier captured when the session entered the store. These helpers make a mismatched subject harder to express than the correct spelling. -These TypeScript checks improve authoring but are not treated as a security boundary: JavaScript callers, casts, and hand-written dispatches can bypass them. +### Type markers cover every scoped event declaration -### Development-time invariants +Scoped agent, tool, prompt, session, and subagent lifecycle events declare a `Scoped` receiver. TypeScript therefore rejects a bare subject at typed dispatch sites, including the `subagent/start` and `subagent/end` paths whose scope is the delegating parent. -The invariants plugin observes Cordis's internal dispatch seam. For every scope-filtered event it verifies that a carrier exists and, where the subject is present in the arguments, that the carrier key is the same object. Session and subagent lifecycle payloads do not expose the owner key, so their runtime check proves carrier presence only; the session store and subagent service centralize the dispatch spelling that selects the key. The plugin also rejects an assembly context whose `agent` and `scope` fields disagree and a setup callback that opens a turn before `agent/session-start`. +The marker is compile-time only. JavaScript callers, casts, and direct use of Cordis's dispatch APIs can bypass it, which is why the runtime checks remain necessary. -The invariant checks run before listener delivery, so a violation points at the dispatching call site instead of appearing later as cross-agent behavior. +### Development invariants check actual dispatch -### Drift gates and focused tests +The invariants plugin observes Cordis's internal dispatch path before listener delivery. For each scope-filtered event it requires a marked carrier and, where the event arguments expose the subject, verifies that the carrier key is the same object. -`verify-scoped-dispatch` compares the runtime invariant table with the event declarations marked as scope-filtered. The generated event matrix also rejects a declared event with no recognized dispatcher, preventing helper-shaped calls from disappearing silently from the architecture documentation. +Session and subagent payloads do not expose the owner key directly, so their invariant proves carrier presence while their service centralizes how the correct key is chosen. Additional invariants reject an assembly whose `agent` and `scope` disagree and a turn opened before `agent/session-start`. -Focused tests cover scoped visibility, shadowing, restrictions, carrier transparency, setup rollback, teardown order, shared quiescence, session delivery, structured-output tamper recovery, stale-stage isolation, Code Mode bindings, and parent-child disposal. The [events catalog](../../../cordis-catalog/events.md) remains the exhaustive event contract rather than being duplicated here. +### Repository gates keep declarations and dispatchers aligned + +`verify-scoped-dispatch` compares the declared scoped events with the runtime invariant table, and the generated event matrix requires every declaration to have a recognized dispatcher. Source JSDoc is regenerated into the [event catalog](../../../cordis-catalog/events.md), keeping the exhaustive signature and mode reference in one place. ## Alternatives considered -The alternatives below solve only part of the problem or separate visibility from ownership, which would make safe composition harder to reason about. +The rejected designs either split visibility from ownership, isolate the wrong boundary, or depend on extension ordering for correctness. -### Pass an agent or scope option to every registration +### Pass an agent option to every registration -An API such as `tools.register(definition, { agent })` makes global registration the leak-by-omission default and requires every registry to invent parallel scope plumbing. It also lets visibility point at one agent while cleanup belongs to an unrelated context. +An API such as `tools.register(definition, { agent })` leaves global registration as the leak-by-omission default and requires parallel scope plumbing in every registry. It also allows “visible to agent A, disposed with unrelated plugin B,” which the scoped context makes unrepresentable. -The chosen design leaves existing registry signatures unchanged and uses the calling context as the single source of both facts. +### Create one isolated service graph per agent -### Create an isolated service instance per agent +Service isolation chooses one registry instance for a context, while agent composition needs a merged view of deployment-global contributions plus one agent's additions. Per-agent graphs would duplicate shared adapters and force infrastructure such as persistence and UI bridges to discover every new instance. -Cordis isolation selects one service instance for a context. Agent composition needs a merged view—deployment-global tools plus one agent's additions—not a choice between two independent registries. +Isolation remains appropriate for independent applications. It is too coarse for collaborating agents inside one deployment. -Per-agent service instances would require delegating merge registries for every scoped service and would force single-subscription infrastructure such as persistence and ACP to discover and subscribe to each new instance. Isolation remains the right bulkhead for co-hosting independent applications, not for agents collaborating inside one application. +### Inherit the parent's scope into a child -### Filter events but keep registries global +Hierarchical capability inheritance makes lifetime convenient but silently grants every child the parent's scoped tools and policies. A flat view plus an explicit parent-owned disposer separates the two questions: the parent owns the child without conferring its authority. -Listener filtering prevents a hook from intercepting the wrong agent, but it does not scope the model-visible tool schemas, prompt sections, variables, or executable tool lookup. Persona, tool filtering, and concurrent structured-output schemas would remain impossible or require ad hoc mutation. +### Publish the agent before running setup -### Add first-class scope support to vendored Cordis +Early publication lets setup resolve the agent from global registries, but observers can see and act on a partially configured world. Rollback can remove entries but cannot retract external effects from already-run listeners. -Cordis already exposes the primitives needed for this design: derived contexts, effect-owning plugin fibers, and listener filtering through the dispatch receiver. Modifying the vendored framework would add synchronization and maintenance cost without providing an additional harness capability. +The unpublished callback already receives both the agent context and its `ctx.agent` association, so early global lookup is unnecessary. -### Give each subsystem a separate per-agent API +### Allow only synchronous setup -Tool filters, prompt profiles, listener predicates, and session routing can each be implemented independently. That approach multiplies concepts, cleanup paths, and opportunities for the views to disagree. +Synchronous setup is simpler but cannot honestly compose a child plugin whose activation is asynchronous. In TypeScript, a callback returning a promise can also be assigned to a void-returning callback type, so declaring setup as synchronous would not reliably prevent accidental escape from the rollback boundary. -The shared scope primitive gives every subsystem the same answer to “which agent sees this?” and “when does it go away?” while letting each registry retain its own domain-specific resolution rules. +Awaited setup makes the transaction explicit and keeps the first assembly behind it. + +### Enforce invariants with prepended waterfall listeners + +A prepended listener is not necessarily outermost: another plugin can prepend later, a short-circuit can skip inner work, and an outer wrapper can replace the result after delegation. The same issue appears in prompt assembly, tool authorization, result commit, and turn continuation. + +The owner-final APIs express the actual strength required by each rule: restore named canonical data, deny monotonically, observe the immutable final outcome, or stop after all ordinary continuation inputs are folded. + +### Filter events while keeping registries global + +Listener filtering prevents a hook from intercepting the wrong agent but does not scope tool schemas, executable lookup, prompt sections, variables, or Code Mode bindings. Persona, tool filtering, and concurrent structured schemas would still require global mutation. + +### Add scope semantics to vendored Cordis + +Cordis already provides derived contexts, effect-owning fibers, and receiver-based listener filtering. The harness-level primitive combines those mechanisms without adding a framework fork whose synchronization cost would outlive this feature. ## Consequences -The design adds one central concept and some low-level implementation machinery. In return, it makes per-agent composition ordinary, leak-resistant, and lifecycle-safe. +The design makes per-agent composition ordinary and lifecycle-safe at the cost of a small scope runtime and several deliberately narrow final-policy APIs. The complexity is concentrated in services and dispatch helpers rather than repeated in every plugin. ### Benefits -- Plugin authors use the same registration APIs globally or per agent; only the context changes. -- An agent's prompt, executable tools, Code Mode bindings, policy listeners, and UI definitions resolve from the same scoped view. -- Agent disposal revokes its registrations automatically, including failure and hot-reload paths. -- Subagent persona, tool filtering, structured output, and parent-owned teardown compose without global mutation. -- Existing unscoped plugins remain global observers and contributors, preserving the deployment-wide extension model. +The main benefit is one composition model across data, behavior, and lifetime: registrations follow their context, while service-owned finalizers protect only the invariants that require stronger ordering. + +- Plugin authors use the same registration APIs globally and per agent; only the context changes. +- Registry-owned prompt schemas, executable lookup, Code Mode bindings, policy listeners, and UI presentation resolve from the same agent view. +- Create and resume expose no partially configured registry entry during awaited setup. +- Agent disposal revokes scoped contributions after the driver and final session flush have settled. +- Structured output composes per child without global mutation or listener-order assumptions. +- Existing unscoped plugins remain deployment-wide contributors and observers. ### Costs and constraints -- Every agent-subject event dispatcher must carry the correct scope; types, fused helpers, invariants, and gates exist because omission would otherwise fail open to global delivery. -- `agent.ctx` is a capability-bearing context. The loop plugin's injected service set determines what holders can reach. -- Scoped registry layers consume memory for the agent lifetime and add a two-layer resolution step, then disappear on scope disposal. -- The dispatch carrier is proxy-shaped and not identity-equal to its subject, even though property access and method calls are transparent. -- Restrictions are flat and apply only to the global tool layer; scoped registrations are deliberate grants, and parent scopes do not confer capabilities on children. -- The generic `Scope.dispose()` and `ScopeHost.dispose()` normalize Cordis's single-shot disposer but do not give racing callers a shared quiescence promise; the agent lifecycle adds that stronger boundary itself. -- Setup is a synchronous contract, but its current `(Context) => void` TypeScript shape accepts an `async` function and the runtime does not inspect the returned promise; asynchronous setup can escape rollback and race the first assembly. +The costs are concentrated in dispatch discipline, per-scope registry state, and explicit authority boundaries that are intentionally stronger than ordinary middleware. + +- Every scoped event dispatcher must carry the correct receiver; fused helpers, type markers, invariants, and gates exist because omission would otherwise deliver only to global listeners. +- `agent.ctx` is capability-bearing. Its available services come from the agent loop's injected context, so holders receive that deliberate service surface. +- Registries maintain per-scope maps and perform a global-plus-one-layer merge for the agent lifetime. +- The dispatch carrier is proxy-shaped and not identity-equal to its subject, even though method calls and property access behave like the subject. +- Flat scopes do not inherit parent capabilities; a desired child capability must be global or explicitly registered for the child. +- `run_code` is protected transport infrastructure rather than a filterable end capability, so a policy that must forbid programs denies execution at the tool-policy layer instead of removing the transport from a Code Mode prompt. +- Prompt protection restores named canonical contributions and their anchor placement, not the entire assembly; unprotected output remains extensible, while a globally protected section name is deliberately unavailable for scoped shadowing. +- Terminal turn stopping has authority to discard pending steering. That power is appropriate for owner-enforced terminal protocols and too strong for ordinary cooperative continuation policy. +- Programmatic `ctx.agents.create()` and `ctx.agents.resume()` are asynchronous because they await setup. The config-only `ctx.agentLoop.create()` path has no setup callback and remains synchronous. +- Ordered composition requires both an exact raw scope disposer and a shared public quiescence promise; the dual surface reflects two distinct Cordis lifecycle requirements. ### Deliberate boundaries -The primitive is general, but this decision scopes only the surfaces needed for coherent agent composition. Per-agent filesystem policy, LLM adapter selection, named profile registries, and background subagents can build on the same context without changing the core scope model. +The scope primitive is generic, but this decision applies it only where one agent needs a coherent registration view: tools, prompt state, scoped events, sessions, and in-process subagent composition. `agent.ctx` does not automatically scope every service call; filesystem policy, LLM interception, background subagent state, and future registries retain their existing seams until their own designs explicitly adopt the context rule. diff --git a/docs/rfc/implemented/feature/2026-06-15-code-mode.md b/docs/rfc/implemented/feature/2026-06-15-code-mode.md index 4cc373c65c..f97e7abf50 100644 --- a/docs/rfc/implemented/feature/2026-06-15-code-mode.md +++ b/docs/rfc/implemented/feature/2026-06-15-code-mode.md @@ -10,39 +10,39 @@ For multi-step tool work this is token-heavy and serial. The model cannot compos Cloudflare's [Code Mode](https://blog.cloudflare.com/code-mode/) proposes an alternative grounded in a simple observation: LLMs are better at writing code than at emitting tool calls, because they have seen millions of lines of real code and comparatively few contrived tool-calling traces. Instead of one tool call per step, the model writes a TypeScript program against a generated API over the tools, the program executes in a sandboxed runtime, and the model curates what comes back — only what it prints or returns — instead of every intermediate result. -An earlier draft of this RFC designed Code Mode as an add-on consumer plugin with zero core changes, deferring the execution substrate to a follow-up. Both constraints are dropped here, deliberately. First, the harness is pre-release and optimizes for the correct foundation over blast radius: tool presentation is the registry's own concern, and bolting a second presentation onto it from outside means transforming the registry's contribution after the fact — a waterfall listener whose correctness depends on listener ordering, which fights the [reconstructable-requests](../../implemented/architecture/2026-07-05-reconstructable-requests.md) design instead of riding it (that refactor removed request mutation from `agent/request`, the seam the old draft relied on). Second, the substrate question is answerable now: a Node `worker_threads` runtime gives real containment — separate isolate, empty environment, heap caps, and a `terminate()` that reliably stops a hot synchronous loop — where the old draft's `node:vm` stub had none of those, and it fits the harness's existing trust model (§Trust posture) without a hardening follow-up. +Tool presentation belongs to the registry that owns tool visibility: implementing a second presentation as an after-the-fact waterfall transform would make correctness depend on listener order and fight [reconstructable requests](../../implemented/architecture/2026-07-05-reconstructable-requests.md). The execution substrate is also part of the foundation rather than a placeholder: Node `worker_threads` provides a separate isolate, an empty environment, heap caps, and termination of a hot synchronous loop, while fitting the harness's existing trust model (§Trust posture). ## Decision Three decisions, each elaborated in its own section below: -1. **Code Mode is a first-class presentation mode of `ToolRegistry`** (`dsh-tools`), selected by a validated `mode` config: `'native'` (today's behavior, the default), `'code'` (the wire carries exactly one tool, `run_code`, plus a generated SDK `.d.ts` in the system prompt), or `'both'` (native schemas *and* `run_code` + SDK). The registry's existing tool-schema provider contributes whatever the mode dictates, so the wire tool list is shaped at its source — no interception, no listener-ordering caveats — and the logged request header records it for free. -2. **Code execution is a capability seam** — a new group `packages/code-runtime/` with the interface package `@deepseek-ai/dsh-code-runtime` owning `ctx.codeRuntime` ([capability seams](../../implemented/architecture/2026-06-13-capability-seams.md); consumer = `dsh-tools`, with core-consumes-a-seam precedent in `agent-loop` → `dsh-llm`). The runtime knows nothing about tools: it is handed a program and named async bindings, runs the program, and reports `{ value, logs, error? }`. Language and substrate are backend properties, so a future Python or container backend is a new implementation package, not a redesign. +1. **Code Mode is a first-class presentation mode of `ToolRegistry`** (`dsh-tools`), selected by a validated `mode` config: `'native'` (the default, contributing the visible capability schemas), `'code'` (the registry contributes only its reserved `run_code` transport plus a generated SDK `.d.ts` in the system prompt), or `'both'` (native schemas and the transport + SDK). The registry shapes its wire contribution at the source and protects the transport pair through final assembly, so the logged request header records the same presentation the model receives. +2. **Code execution is a capability seam** — `packages/code-runtime/` contains the interface package `@deepseek-ai/dsh-code-runtime`, which owns `ctx.codeRuntime` ([capability seams](../../implemented/architecture/2026-06-13-capability-seams.md); consumer = `dsh-tools`, with core-consumes-a-seam precedent in `agent-loop` → `dsh-llm`). The runtime knows nothing about tools: it is handed a program and named async bindings, runs the program, and reports `{ value, logs, error? }`. Language and substrate are backend properties, so a future Python or container backend is another implementation package, not a redesign. 3. **The shipped implementation is `@deepseek-ai/dsh-code-runtime-worker`**: one fresh Node worker thread per run, executing the model's TypeScript after type-strip, with bindings bridged over the message port, an empty environment, configurable heap/output/time caps, and hard termination. Its trust posture is bash-equivalent by design — no unsafe-acknowledgement flags — because the harness already ships `dsh-bash-local`, which executes arbitrary model-written shell commands with strictly *more* ambient authority. ### The registry owns the mode `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 already feeds the assembly through `ctx.systemPrompt.tools(() => this.schemas())`; the provider becomes mode-aware: `'native'` contributes all schemas (unchanged), `'code'` contributes only `run_code`'s schema, `'both'` contributes all schemas plus `run_code`. Because [`PromptAssembly.tools` is the single source the loop's request header snapshots](../../../../packages/core/system-prompt/src/index.ts), the collapse is automatically logged and reconstructable — model-visible ⟺ logged holds with zero new mechanism. Scope of the guarantee, stated honestly: the mode governs the **registry's** contribution, and the registry is the only shipped schema source — but `systemPrompt.tools()` is a public multi-provider API and the `system-prompt/assemble` waterfall may transform the assembly, so a deployment that wires a second direct provider (or a mutating listener) owns what it adds, exactly as in native mode. Those are deliberate acts; what the design eliminates is the *accidental* leak the old draft worried about — a listener-ordering race around an after-the-fact collapse — and the shipped-configuration invariant (`'code'` ⇒ assembled tools exactly `[run_code]`) is pinned by tests and, like every request, by the logged header. +**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. -**Interaction with `toolOrder`, stated up front:** a configured `systemPrompt.toolOrder` naming native tools rejects every assembly under `mode: 'code'` (those names are no longer contributed), by the existing fail-loud rule for unlisted names. This is correct behavior, not a bug: a deployment switching modes updates its order config or drops it. +**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, at each assembly, a TypeScript declaration of every registered tool except `run_code` itself, plus fixed usage instructions. The thunk reads the live store and emits tools in lexicographic name order, so its output is deterministic and stable across steps — an unchanged tool set produces byte-identical text (prefix-cache-friendly; a mid-session registration surfaces as one logged header delta, exactly like a native-mode tool change). +**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. **Codegen.** A pure `jsonSchemaToTs(schema)` module inside `dsh-tools` (sibling of `json-schema.ts` — `schemas()` and the SDK are two projections of the same store) maps the JSON-Schema subset the `defineTool` DSL emits (object/string/number/boolean/array, `properties`, `required`, string `enum` → literal union, nested objects, array `items`, `description` → JSDoc) to a TS type literal. It is **total**: any construct outside that subset (`$ref`, `oneOf`/`anyOf`, `integer`, future MCP shapes, …) degrades to `unknown` without throwing. Because `ToolSchema.name` is an arbitrary string, the SDK is declared as one object constant — `declare const tools: { "some-mcp-tool"(args: …): Promise; bash(args: …): Promise; … }` — quoted keys make every name reachable with no sanitization or alias-collision logic. Typing is advisory (the runtime executes type-stripped JS); the instructions say so. ### The run_code tool and the dispatch bridge -Under `'code'` and `'both'` the registry registers `run_code` in itself as an ordinary tool — one required parameter `{ code: string }` — so the unchanged loop dispatches it through the normal pipeline and `tools/pre-execute` / `tools/post-execute` gate it like any other call (a permission plugin can inspect the program text before it runs). Its `execute(args, exec)`: +Under `'code'` and `'both'` the registry owns `run_code` as a reserved presentation transport with one required parameter, `{ code: string }`. It is represented by a normal `ToolDefinition` for dispatch but stays outside the filterable capability layers, so restrictions cannot accidentally remove Code Mode's only entry point. Calls traverse the complete tool pipeline — `tools/pre-execute` → monotonic guards → `tools/execute` around dispatch → `tools/post-execute` → immutable `tools/result` notification — exactly like native calls; a permission plugin can inspect the program text before it runs, and final-result observers see the normalized outer outcome. Its `execute(args, exec)`: -1. **Builds the bindings**: the bridge owns a **run-scoped `AbortController`** whose signal follows `exec.signal` (an outer cancel propagates in) and which the bridge itself fires the moment the run settles for any reason — completion, program exception, `computeMs`/`maxWallMs` expiry, worker exit. For every registered tool except `run_code`, the binding is an async function that (a) checks the run signal before and after (throwing stops the program — necessary because `ctx.tools.execute()` converts errors to `isError` data), (b) **JSON-normalizes the argument** — a `JSON.parse(JSON.stringify(args))` round-trip, rejecting that one call with a descriptive `Error` when the value does not survive (`BigInt`, circular structures) — because the seam's structured-clone boundary is wider than JSON while the session log accepts only JSON: normalizing BEFORE dispatch makes the dispatched form and the logged form the same JSON value by construction, so an executed sub-call can never fail at logging time, (c) awaits its turn on the **per-run serialization queue** (below), (d) calls `this.execute({ callId, name, arguments, agent: exec.agent, signal: runSignal })` with a deterministic sub-id `` CallId(`${exec.callId}:code:${n}`) `` — the run signal, not the bare outer one, so a budget expiry aborts an in-flight sub-tool (`bash-local` kills on its spec signal) instead of orphaning it, (e) appends a `tool/code-dispatch` session event, and (f) maps the result: success → the text-block contents joined as a `string` (non-text blocks become placeholders, an MVP limitation), `isError` → **the binding rejects** with an `Error` carrying the result text. Rejection is the deliberate model-facing contract — real code signals failure by throwing, `try/catch` and `Promise.all` short-circuiting behave as every model has seen them behave — where the old draft's `{ output, isError }` envelope made error handling a bespoke convention. +1. **Builds the bindings**: the bridge owns a **run-scoped `AbortController`** whose signal follows `exec.signal` (an outer cancel propagates in) and which the bridge itself fires the moment the run settles for any reason — completion, program exception, `computeMs`/`maxWallMs` expiry, worker exit. For every visible capability tool, the binding is an async function that (a) checks the run signal before and after, (b) **JSON-normalizes the argument** — a `JSON.parse(JSON.stringify(args))` round-trip, rejecting that one call with a descriptive `Error` when the value does not survive (`BigInt`, circular structures) — because the seam's structured-clone boundary is wider than JSON while the session log accepts only JSON, (c) awaits its turn on the **per-run serialization queue** (below), (d) calls `this.execute({ callId, name, arguments, agent: exec.agent, parent: exec.token, signal: runSignal })` with a deterministic sub-id `` CallId(`${exec.callId}:code:${n}`) ``, (e) appends a `tool/code-dispatch` session event, and (f) maps the result: success → the text-block contents joined as a `string` (non-text blocks become placeholders), `isError` → **the binding rejects** with an `Error` carrying the result text. The child's readonly `parent` is only the outer execution's frozen, property-free token, so commit-style observers can correlate outcomes without receiving a mutation path into the live `run_code` wrapper. Every sub-call still traverses the full pipeline under its own immutable identity and registry-assigned token. The run signal, rather than the bare outer one, lets budget expiry abort an in-flight sub-tool instead of orphaning it. Rejection gives programs ordinary `try/catch` and `Promise.all` failure semantics rather than a bespoke result envelope. 2. **Runs the program**: `ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: exec.signal })`. 3. **Surfaces the outcome — after reaching quiescence.** When `ctx.codeRuntime.run()` resolves, the bridge fires the run-scoped abort (cancelling any in-flight sub-dispatch and abandoning queued-unstarted ones), then **awaits the dispatch queue's drain before returning**, per the dispose-to-quiescence rule in [defensive patterns](../../../defensive-patterns.md): an aborted in-flight sub-call still settles and logs its `isError` `tool/code-dispatch` event *inside* the open turn, and nothing can append after `run_code` returns. A successful run then returns one text block — the captured console/stdout output followed by the rendered return value (if any) — plus a `meta` payload (capped logs, dispatch count) for presentation. A run with `result.error` throws a `CodeRunFailedError extends HarnessError` (`code: 'CODE_RUN_FAILED'`, message = the error kind and text plus captured logs so the model can self-correct); the registry's existing catch turns it into a structured `isError` result. **Sub-call `additionalContext` is suppressed, deliberately.** A `tools/post-execute` hook may attach `additionalContext` to a call; for loop-dispatched calls the loop buffers those and appends each as a `context/message` only after the step's `tool/result`s, preserving call/result adjacency. A sub-dispatch result's `additionalContext` has no such safe outlet from inside a running `run_code`: injecting immediately would land a `context/message` between the parent's `tool/call` and its `tool/result` (breaking the adjacency the buffering exists to protect), and `PostToolDecision.additionalContext` is singular where a program may produce many. The MVP therefore drops sub-call `additionalContext`, pinned by a test and stated in the hooks bridge's docs; the follow-up (a plural context channel or loop-level sub-dispatch buffering) is deferred until a real hook needs it through Code Mode. -**Concurrency: serialized, enforced by the binding.** The bindings are async, so a model writing `Promise.all([tools.a(…), tools.b(…)])` starts both immediately — concurrent dispatch would be the *default*, while the tool contract still carries no concurrency-safety metadata (the open parallel-execution TODO). Each `run_code` invocation therefore owns a dispatch queue and every binding call chains onto it, so even `Promise.all` executes the underlying `ctx.tools.execute()` calls one at a time in submission order; when the run settles, queued-but-unstarted dispatches are abandoned. Lifting this per-tool once tools can declare themselves concurrency-safe is deferred work, same as before. +**Concurrency: serialized, enforced by the binding.** The bindings are async, so a model writing `Promise.all([tools.a(…), tools.b(…)])` starts both immediately — concurrent dispatch would be the default, while the tool contract carries no concurrency-safety metadata (the open parallel-execution TODO). Each `run_code` invocation therefore owns a dispatch queue and every binding call chains onto it, so even `Promise.all` executes the underlying `ctx.tools.execute()` calls one at a time in submission order; when the run settles, queued-but-unstarted dispatches are abandoned. Lifting this per tool remains tied to tools declaring themselves concurrency-safe. **Presentation.** `run_code`'s render intent is decided here per the [render-intent RFC](../../implemented/architecture/2026-07-02-tool-render-intent-union.md): `presentCall` → a `generic` card, `kind: 'execute'`, title `Run code`, `rawInput` = the program text; `presentResult` → a `generic` card whose content is the captured output (from `meta`). Not a `terminal` card: that card's semantics are "a shell command in a working directory", which a program is not. @@ -61,7 +61,7 @@ Each sub-dispatch appends one session event, declared by `dsh-tools` via `Sessio - `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'; message: string }` — orthogonal outcomes reported independently per [defensive patterns](../../../defensive-patterns.md); a timed-out run is not an exception, an abort is not a timeout. - Two readonly backend descriptors, informational not gating: `language` (what the program must be written in — `'typescript'` for the shipped backend; a Python backend would say so, and pair with its own SDK generator on the presentation side) and `isolation` (`'worker-thread'` for the shipped backend; `'process'`, `'container'`, … for future ones). `dsh-tools` requires `language === 'typescript'` in the MVP — its codegen emits TS — and fails the assembly loudly otherwise, the same misconfiguration idiom as `toolOrder` violations (as when `mode` is non-native with no `ctx.codeRuntime` loaded at all). -Per explicit-over-implicit at seams, the request spells out everything the runtime acts on; defaulting (timeouts, caps) is the implementation's validated config, never a hidden `??` inside `run()`. Consumption uses the loop's established optional-backend idiom: cordis has no optional injection — every `inject` entry gates activation — so a static `inject` on the registry would hold `ctx.tools` (and every tool plugin behind it) hostage to a code runtime existing even under `mode: 'native'`; instead the registry reads `ctx.get('codeRuntime')` at use time, exactly as `agent-loop` consumes `sessionPersistence`, with absence failing loud in the provider thunk as above. The seam split is justified by real planned divergence on both axes — substrate (worker now; container/microVM later) and language (the Python/AssemblyScript direction sketched in the earlier draft survives as future work) — not by speculation: `dsh-tools` consumes the interface today and tests against a trivial in-repo fake, exactly the interface/implementation/consumer shape of the bash template. +Per explicit-over-implicit at seams, the request spells out everything the runtime acts on; defaulting (timeouts, caps) is the implementation's validated config, never a hidden `??` inside `run()`. Consumption uses the loop's optional-backend idiom: Cordis has no optional injection — every `inject` entry gates activation — so a static `inject` on the registry would hold `ctx.tools` (and every tool plugin behind it) hostage to a code runtime existing even under `mode: 'native'`; instead the registry reads `ctx.get('codeRuntime')` at use time, exactly as `agent-loop` consumes `sessionPersistence`, with absence failing loud in the provider thunk. The seam has concrete divergence on both axes: the worker-thread substrate can be replaced by a container or microVM implementation, and the TypeScript language contract can be paired with a language-specific SDK and runtime. `dsh-tools` consumes only the interface and tests against a trivial in-repo fake, exactly the interface/implementation/consumer shape of the bash template. ### The worker-thread runtime @@ -76,7 +76,7 @@ Per explicit-over-implicit at seams, the request spells out everything the runti ### Trust posture -The worker runtime is **containment, not a security boundary**, and the RFC says so without ceremony. Model code in the worker can reach Node globals — `fetch`, `process` (with an empty env), dynamic `import()` of built-ins — so a deliberately adversarial program has ambient authority comparable to what the harness's own `bash` tool already grants every model turn: `dsh-bash-local` runs arbitrary model-written commands with the host filesystem, network, and a scrubbed-but-populated environment. One asymmetry runs the other way and is stated plainly: `worker.terminate()` ends the thread, not OS processes a program may have spawned via `node:child_process` — weaker than `bash-local`'s process-group kill for direct children (equivalent for double-forked daemons, which survive both); the wall-clock ceiling bounds the worker itself, and orphan cleanup is the same deployment-level concern it already is for bash. Code Mode is gated where bash is gated — `tools/pre-execute`, where permission/sandbox plugins veto or approve the program before it runs — and adds containment bash does not have: empty env, heap caps, hard termination of the program itself, a separate isolate. The earlier draft's two-flag unsafe ceremony (`{ unsafe: true }` constructor + `allowUnsafeRuntime`) existed for a `node:vm` stub with *no* containment and is dropped with it; demanding scarier flags for the better-contained executor than for bash would be posture theater. A deployment that needs a hard boundary (untrusted multi-tenant input) needs it for bash too; that is a future `isolation: 'container'` backend, and the `isolation` descriptor exists so such a deployment can tell backends apart. +The worker runtime is **containment, not a security boundary**. Model code in the worker can reach Node globals — `fetch`, `process` (with an empty env), dynamic `import()` of built-ins — so a deliberately adversarial program has ambient authority comparable to what the harness's own `bash` tool grants every model turn: `dsh-bash-local` runs arbitrary model-written commands with the host filesystem, network, and a scrubbed-but-populated environment. One asymmetry runs the other way: `worker.terminate()` ends the thread, not OS processes a program may have spawned via `node:child_process` — weaker than `bash-local`'s process-group kill for direct children (equivalent for double-forked daemons, which survive both); the wall-clock ceiling bounds the worker itself, and orphan cleanup is the same deployment-level concern it is for bash. Code Mode is gated where bash is gated — `tools/pre-execute`, where permission/sandbox plugins veto or approve the program — and adds containment bash does not have: empty env, heap caps, hard termination of the program itself, and a separate isolate. A `node:vm` executor with no containment would need explicit unsafe acknowledgement; imposing that ceremony on the better-contained worker while bash needs none would be posture theater. A deployment that needs a hard boundary (untrusted multi-tenant input) needs it for bash too; that is a future `isolation: 'container'` backend, and the `isolation` descriptor lets deployments distinguish backends. ### What the model sees @@ -84,37 +84,37 @@ The `tools:sdk` section carries the `.d.ts` plus fixed instructions: the program ## Consequences -The design shipped as four stacked changes — this RFC, the `dsh-code-runtime` interface package, the `dsh-code-runtime-worker` backend, and the `dsh-tools` integration — each gates-green with docs in the same change; review fixes landed on the change that introduced them and merged down. +The design consists of the `dsh-code-runtime` interface package, the `dsh-code-runtime-worker` backend, and the `dsh-tools` presentation and dispatch integration. -What exists now: +Shipped surface: - **The seam**: `packages/code-runtime/` — `@deepseek-ai/dsh-code-runtime` (abstract `CodeRuntime`, the vocabulary above, `ctx.codeRuntime`) and `@deepseek-ai/dsh-code-runtime-worker` (the worker-thread backend, every cap a validated config field). Rows in the service map, capability-seams graph, config catalog, and cordis catalog. -- **The registry surface**: `ToolRegistry`'s first config (`mode`), the mode-aware wire contribution, the `tools:sdk` section, `jsonSchemaToTs`/`renderToolsSdk` (exported), `run_code` + the dispatch bridge + `CodeRunFailedError`, and the `tool/code-dispatch` log event (declaration-merged into `SessionEventMap`, regenerated into the persistence catalog; `run_code` in the tool catalog). -- **The composed surface**: the `tools` config forwards through `agent-core` and both app packages (`stdio-agent`, `acp-agent`); `demo:code-mode` boots each UI example's `code-mode.cordis.yml` overlay (the worker runtime + `mode: 'code'` over the base tree); the adding-a-tool cookbook states that a registered tool is reachable from programs for free, and the tool-pipeline doc shows sub-dispatches re-entering both waterfalls. -- **Interactions inherited by deployments**: a `toolOrder` naming native tools rejects every assembly under `'code'` (update or drop the order config when switching modes); sub-call `additionalContext` is dropped by the bridge (a plural context channel is deferred until a real hook needs it through Code Mode); sub-dispatch stays serialized until tools can declare concurrency safety — the same metadata the native parallel-dispatch TODO waits on. +- **The registry surface**: `ToolRegistry`'s `mode` config, mode-aware wire contribution, protected `tools:sdk` section and reserved `run_code` transport, `jsonSchemaToTs`/`renderToolsSdk` (exported), the dispatch bridge and `CodeRunFailedError`, and the `tool/code-dispatch` log event (declaration-merged into `SessionEventMap`, regenerated into the persistence catalog; `run_code` in the tool catalog). +- **The composed surface**: the `tools` config forwards through `agent-core` and both app packages (`stdio-agent`, `acp-agent`); `demo:code-mode` boots each UI example's `code-mode.cordis.yml` overlay (the worker runtime + `mode: 'code'` over the base tree); every program sub-dispatch resolves the same scoped capability view and re-enters the complete tool pipeline with an immutable link to its enclosing transport execution. +- **Interactions inherited by deployments**: a `toolOrder` naming native tools rejects every assembly under `'code'` (update or drop the order config when switching modes); restrictions can hide end capabilities but cannot remove the presentation transport; sub-call `additionalContext` is dropped by the bridge (a plural context channel is deferred until a real hook needs it through Code Mode); sub-dispatch stays serialized until tools can declare concurrency safety — the same metadata the native parallel-dispatch TODO waits on. ## Testing What the suites pin, per tier: - **Unit — worker runtime** (real workers, no mocks): output/value capture and log-source attribution; error kinds (exception incl. non-erasable syntax, abort, worker-exit under OOM); the two budgets from both sides (a hot loop behind an un-awaited pending dispatch dies at `computeMs` busy time; a program idling on a slow binding outlives `computeMs` and dies only at `maxWallMs`); binding-bridge hostility (junk/forged port traffic incl. non-object messages and forged `log`/`done` cap bypass attempts, unknown names, duplicate ids, post-settlement replies, `__proto__`/`constructor`/`toString` binding names); structured-clone fallback and cap truncation; `env` emptiness verified from inside a program; dispose-awaits-exit. A real-load-path e2e runs the BUILT package under plain `node` so the worker entry resolves both unbuilt (tsx) and built — the published-artifact guard from [docs/testing.md](../../../testing.md). -- **Unit — registry integration**: the codegen table (DSL subset, quoted names, `unknown` degradation, byte-identical determinism); provider contribution per mode (`'native'` unchanged, `'code'` exactly `[run_code]`, `'both'` all + `run_code`); `toolOrder × mode` rejection; missing-runtime / wrong-language loud failures; serialization non-overlap (a probe tool records enter/exit under `Promise.all`); abort aborting the in-flight sub-dispatch and abandoning queued ones; binding rejection on `isError` and on JSON-unrepresentable arguments; `CodeRunFailedError` → structured `isError` carrying kind + logs; `tool/code-dispatch` payloads (JSON-normalized arguments identical to what dispatched); `deriveMessages()` ignoring the event; sub-call `additionalContext` suppression; HMR safety (disposing the registry removes the tool and the section). +- **Unit — registry integration**: the codegen table (DSL subset, quoted names, `unknown` degradation, byte-identical determinism); provider contribution per mode (`'native'` capabilities, `'code'` exactly `[run_code]`, `'both'` capabilities + `run_code`); reserved-name, restriction, shadow, assembly-protection, and `toolOrder × mode` invariants; missing-runtime / wrong-language loud failures; full-pipeline and opaque parent-token behavior for sub-dispatches; serialization non-overlap (a probe tool records enter/exit under `Promise.all`); abort aborting the in-flight sub-dispatch and abandoning queued ones; binding rejection on `isError` and on JSON-unrepresentable arguments; `CodeRunFailedError` → structured `isError` carrying kind + logs; `tool/code-dispatch` payloads (JSON-normalized arguments identical to what dispatched); `deriveMessages()` ignoring the event; sub-call `additionalContext` suppression; HMR safety. - **e2e (with-key, self-skips)**: a real model under `mode: 'code'` composes two bash calls in one program (`examples/coding-agent/tests/code-mode.e2e.ts`) — every logged `request/header` carries exactly `[run_code]`, the dispatch events land under the parent call, the file the program wrote exists, and the final answer is the curated output. - **Snapshot (keyless replay)**: goldens for a `run_code` turn under `'code'` and `'both'` (`code-mode-turn`, `both-mode-turn`), each its own header-pinning class — the SDK section text, the collapsed header tool list, the dispatch events, and the result card are committed and replayed. ## Alternatives considered -**An add-on consumer plugin, zero core changes (the previous draft of this RFC).** Rejected on both halves. The wire-collapse half aged out from under it: it targeted the `agent/request` waterfall, which [reconstructable requests](../../implemented/architecture/2026-07-05-reconstructable-requests.md) has since re-typed to call-config-only, and the surviving alternative — transforming the assembly a waterfall listener receives — is strictly worse than contributing the right list in the first place (transformation must undo `toolOrder` canonicalization it cannot see the config for, and its correctness depends on where it sits in a listener chain). The deeper reason is ownership: which tools the model is offered, in which representation, is the registry's single concern — `schemas()` for function calling and the SDK for Code Mode are two projections of one store, and splitting the second projection into a satellite package would preserve a boundary the domain does not have. +**An add-on consumer plugin with zero core changes.** Rejected because `agent/request` is call-config-only under [reconstructable requests](../../implemented/architecture/2026-07-05-reconstructable-requests.md), while transforming an assembled tool list would have to undo `toolOrder` canonicalization without owning its config and would depend on listener order. Which tools the model is offered, and in which representation, is the registry's single concern: native schemas and the SDK are two projections of one visible store. -**`node:vm` as the reference runtime, hardening deferred (also the previous draft).** Rejected: `node:vm` is not isolation (prototype-chain escapes reach the host realm), cannot interrupt a hot loop, and forced the draft into a two-flag unsafe ceremony plus a mandatory follow-up RFC. The worker thread delivers the missing properties now — separate isolate, empty env, `resourceLimits`, reliable `terminate()` (all verified by probe before this revision) — at bash-equivalent trust, so the reference implementation and the production one are the same package and the ceremony dissolves. +**`node:vm` as the reference runtime, with hardening deferred.** Rejected: `node:vm` is not isolation (prototype-chain escapes reach the host realm) and cannot interrupt a hot loop. A worker thread provides a separate isolate, empty environment, `resourceLimits`, and reliable `terminate()` at bash-equivalent trust, so the reference and production implementation are one package without an unsafe-acknowledgement ceremony. -**Result elision / summarization over native tool-calling.** Addresses only the context-bloat half of the problem: trimming old `tool-result`s (now cheap to add as a logged surface replace, per the reconstructable-requests consequences) still pays one model round-trip per call and cannot express loops, branches, or joins. Complementary, not competing; it can layer under Code Mode for residual native calls. +**Result elision / summarization over native tool-calling.** Addresses only the context-bloat half of the problem: trimming old `tool-result`s is cheap to add as a logged surface replacement under reconstructable requests, but still pays one model round-trip per call and cannot express loops, branches, or joins. Complementary, not competing; it can layer under Code Mode for residual native calls. **Parallel native dispatch in the loop.** The other answer to round-trip cost; still valid future work (the open TODO), still blocked on concurrency-safety metadata, and still no composition — it parallelizes calls the model already decided on in one step. Code Mode's serialized-queue decision keeps the two compatible: when the metadata lands, both native parallel dispatch and per-tool binding parallelism unlock together. **Always-exclusive (Cloudflare-faithful, no mode).** Rejected for this SDK's primary consumer: a coding agent's bread-and-butter single calls (`bash`, `read`, `edit`) are already ideal as native calls, and forcing every edit through a program taxes the common case. The mode config keeps the faithful form (`'code'`) one line away without imposing it. -**Per-tool visibility tiers (this tool native, that tool code-only).** Deferred again, knowingly: it needs per-tool metadata and a presentation split that `'native' | 'code' | 'both'` does not, and every learning it depends on (how models actually split usage under `'both'`) arrives only after this ships. +**Per-tool visibility tiers (this tool native, that tool code-only).** Deferred: it needs per-tool metadata and a presentation split that `'native' | 'code' | 'both'` does not, and its design depends on evidence about how models split usage under `'both'`. **Sanitized identifier aliases in the SDK** (`my-tool` → `my_tool`, Cloudflare's approach). Rejected: quoted keys on a `declare const` make every name reachable with zero alias-collision logic; models handle `tools["my-tool"](…)` fine. diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md index 7795b044f6..454f21df93 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md @@ -6,21 +6,31 @@ Status: implemented The harness needs a hooks subsystem: users extend or gate the agent at lifecycle points the way Claude Code (CC) and Codex do. The key reframe driving this design is that **"native hooks" are not a package** — a native hook is just an ordinary Cordis plugin subscribing to the canonical lifecycle events. So the real product is a *powerful, well-typed canonical event surface*; the CC/Codex bridges (the `dsh-hooks-claude` / `dsh-hooks-codex` packages) are merely translators that map an external shell-hook protocol onto that same surface. Anything a bridge can do, a plain plugin can do directly — more powerfully (no serialization boundary, full `ctx`, typed returns). -Before this change the interception surface was incomplete and inconsistent for that goal: there was no per-prompt seam (CC's `UserPromptSubmit`), no session-start signal (CC's `SessionStart`), the single `tools/execute` waterfall conflated the pre-gate and post-inspect phases (CC splits `PreToolUse`/`PostToolUse`), and `agent/turn-continuation` returned a bare `boolean` with no room for a force-continue *reason*. The [event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md) pinned down the three-domain rule and the typed-Decision idiom as the interception convention; this RFC builds the actual seams on top of it. +The surface needs distinct contracts for per-prompt policy (CC's `UserPromptSubmit`), session-start observation (CC's `SessionStart`), pre-tool policy, around-dispatch control, post-tool transformation, final-result observation, and continuation with a model-facing reason. Conflating those phases gives plugins mutation channels they do not need and makes finality depend on listener ordering. The [event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md) supplies the three-domain rule and the typed-Decision idiom; this RFC applies them to the lifecycle seams. ## Decision -Add/​reshape the interception seams so every one returns a small, seam-specific **typed Decision union**, and the set covers the hook points in scope (`session-start`, `prompt-submit`, `pre-tool`, `post-tool`, `stop`-via-continuation). +The canonical surface separates transformable policy, around-dispatch control, and observe-only notification. Policy waterfalls return small seam-specific **typed Decision unions**; wrappers return normalized results; notifications receive immutable snapshots and cannot affect the outcome. The set covers the hook points in scope (`session-start`, `prompt-submit`, `pre-tool`, `post-tool`, `stop`-via-continuation) while leaving non-hook execution policy independently composable. -**New `agent/*` events** (`dsh-agent`): +**Agent events** (`dsh-agent`): - `agent/session-start(agent, source)` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`. - `agent/prompt-submit(agent, content, source, next) → PromptDecision` — waterfall, fired per drained queued message inside the open turn, before the `user/message` append. `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block` (dropping the prompt; the loop appends a durable `prompt/blocked` in its place — see the dispatch note below). -**Reshaped** `agent/turn-continuation` from `(…, defaultDecision: boolean) → boolean` to `(…, defaultDecision: ContinuationDecision) → ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing context recorded as next-step steering in the same turn — the typed twin of the existing `/goal` step-end-steer pattern. +**`agent/turn-continuation`** receives and returns a `ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing context recorded as next-step steering in the same turn — the typed twin of the `/goal` step-end-steer pattern. -**Split** the single `tools/execute` waterfall into `tools/pre-execute` (→ `PreToolDecision` allow/deny/ask gate) and `tools/post-execute` (→ `PostToolDecision` accept/block, optionally replacing content or attaching `additionalContext`). Core dispatch sits between them as plain code inside `ToolRegistry.execute`'s outer try/catch, and the tool body keeps its own inner try/catch so a thrown tool still becomes an `isError` result that `post-execute` listeners can inspect. +### The tool pipeline gives each phase one kind of authority -**New `TurnEndReason` variant** `rejected` (`dsh-session`): a turn whose entire prompt batch was blocked by `prompt-submit`. +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. + +- **`tools/pre-execute`** is the extensible waterfall gate. Its `PreToolDecision` allows, denies, or asks; deny/ask skips `tools/execute` and core dispatch but still produces a normalized result for 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. + +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. + +**`TurnEndReason.rejected`** (`dsh-session`): a turn whose entire prompt batch was blocked by `prompt-submit`. ### Three load-bearing loop decisions @@ -30,13 +40,13 @@ Add/​reshape the interception seams so every one returns a small, seam-specifi 3. **A forced `continue` `reason` is enqueued through the steering channel**, so the next step's top-of-loop drain records it as steering for the continued turn — next-*step* steering within the SAME turn, not a next-*turn* prompt (matching the existing `hasSteering` force-continue override). -### Pre-tool INPUT rewrite is DEFERRED (the over-reach signal) +### Pre-tool input rewrite is a separate consistency decision -`PreToolDecision` is allow/deny/ask only — **no `arguments` rewrite**. Output replacement (`PostToolDecision.accept.content`) is safe because `tool/result` is logged AFTER execution (one source of truth). Input rewrite is NOT safe today: `assistant/message` (the model-history source) and `tool/call` (the audit record) are both logged BEFORE execution, and live consumers READ `tool/call.arguments` for presentation (the ACP bridge remembers them for `presentResult`; `dsh-tool-bash` derives the title/cwd/terminal-vs-background from them). A rewrite that changed only execution would make the UI show one command while another RAN. Designing that consistently (rewriting the audit + history + presentation as one unit) is a real consistency-design problem CC itself warns is racy — so it gets its own [proposed RFC](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md), and `TODO(pre-tool-input-rewrite)` anchors it at the loop's pre-execute call site. This does not regress any production consumer (no production `tools/execute` listener mutated `exec.arguments`). The low-level capability to mutate `exec` in a `pre-execute` listener still exists (unadvertised — a test shim uses it to thread a generated id), but it is not a first-class advertised contract. +`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)`. -### What this PR does NOT do +### Boundaries -It does **not** declare `hook/*` SessionEvents (the durable hook-invocation log) — those belong to the `dsh-hook-protocol` library, because a native plugin can already use the typed Decisions without a durable hook log. A worked native-plugin example/test in this PR (`packages/core/agent-loop/tests/interception.spec.ts`) proves all the seams compose end-to-end through the REAL loop with NO `hook/*` involved — the concrete proof that "native hooks are just a plugin". Compaction (`PreCompact`/`PostCompact`), the Notification hook, Codex `PermissionRequest`, the permission/`ask` system, and the Stop loop-guard remain deferred (`FIXME(permissions)` marks the `ask`→deny degrade). +The seam package does **not** declare `hook/*` session events (the durable hook-invocation log); those belong to `dsh-hook-protocol`, because a native plugin uses typed decisions without an external hook log. The native-plugin integration test (`packages/core/agent-loop/tests/interception.spec.ts`) composes the seams through the real loop with no `hook/*` protocol. Compaction (`PreCompact`/`PostCompact`), Notification, Codex `PermissionRequest`, and the permission/`ask` interaction remain outside this decision (`FIXME(permissions)` marks the current `ask`→deny degradation). ## Alternatives considered @@ -45,4 +55,4 @@ It does **not** declare `hook/*` SessionEvents (the durable hook-invocation log) ## Consequences -The canonical interception surface is now complete and uniformly typed: a native plugin returns typed decisions directly, and a CC/Codex bridge maps its protocol fields onto the same unions. The loop gained four firing points (session-start emit, prompt-submit waterfall, the post-tool context buffer, the continuation reshape) and the `dsh-tools` registry runs a two-waterfall pipeline; both are documented in [architecture.md](../../../architecture.md) and the package READMEs, and the decision types in [core-data-structures](../../../core-data-structures/core.md#interception-decisions) + [tools.md](../../../core-data-structures/tools.md). All existing `tools/execute` and `turn-continuation` listeners (tests, docs) migrated to the new seams. The ACP bridge maps the new `rejected` reason to `cancelled` (its codec). A pure internal change with no editor-visible transcript shift for the existing scenarios — the new behavior only fires when a hook is registered — so the snapshot goldens are unchanged; a hook-driven snapshot scenario lands with the `dsh-hooks-claude` bridge, which is what makes a hook observable end-to-end through ACP. +The canonical interception surface is uniformly typed without giving every extension the same power: hooks return decisions, execution wrappers wrap, terminal guards only deny, and final observers only observe. The loop owns session-start, prompt-submit, post-tool context buffering, and continuation; `dsh-tools` owns identity sealing and the five-phase execution pipeline. Their contracts are documented in [architecture.md](../../../architecture.md), package READMEs, [core interception decisions](../../../core-data-structures/core.md#interception-decisions), and [tool structures](../../../core-data-structures/tools.md). The ACP bridge maps `rejected` turns to its `cancelled` codec value, while hook-driven snapshots verify the observable bridge behavior end to end. diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md index 2f711651f6..a5f294f76d 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -36,7 +36,12 @@ A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, awai ### The foundation: structured output on the subagent seam -`agent({schema})` needs `SubagentStartRequest.outputSchema` to actually work; it was vocabulary without an implementation (`outputSchema: false` everywhere). Implemented in `dsh-subagent-inprocess` for both in-process backends: a globally registered `structured_output` capture tool whose per-child schema is enforced by a `prepend: true` `system-prompt/assemble` listener doing FINAL-ASSEMBLY enforcement (post-processing `await next()` — cooperative mutation would not survive a downstream listener returning a replacement assembly; the calling instruction rides as a trailing prompt section, since `AgentOptions` carries no per-agent prompt field, and the loop logs the result as the step's `request/header`, keeping the injection reconstructable), a `prepend: true` `agent/turn-continuation` veto after capture (no wasted extra model step) plus a `tools/pre-execute` deny for calls arriving after the capture (terminal within the step, not only at its end), and validation-retry in-turn via `ToolArgsError`. The schema is `structuredClone`d at `start()` (caller mutation cannot drift enforcement). Deliberately NO re-prompt: a child that finishes cleanly without calling the tool settles `error` to the parent. Lifetime is refcounted by backends (plugin lifetime) AND live runs (start → settle). The seam's `outputSchema` type became the raw JSON-Schema SUBSET (`StructuredOutputSchema` in dsh-tools: single-string `type`, `properties`/`required`/`additionalProperties`, `items`, scalar `enum`/`const`; anything unenforced is rejected loud) — the schema travels verbatim to the model as the forced tool's parameters, so the wire format, not the author DSL, is the right vocabulary. +`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. ## Deferred (documented non-goals of this cut) @@ -51,11 +56,11 @@ A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, awai ## Alternatives considered -- **Hostile-value containment in the host** (trap-free proxy rejection, accessor-never-invoked descriptor walks, realm-side pre-rendering of thrown values, realm-built promises/arrays/error clones with structural fatal recognition): an earlier revision built all of it, and review showed the cost was real while the threat model was not — every one of those defenses guards against an author the premise already trusts. Removed in favor of the plain boundary above; the thread boundary makes such machinery redundant anyway (serialization by construction). -- **In-process `node:vm` execution** (the first cut of this RFC shipped it): mechanically simplest — no RPC, no thread — but `start()` blocks the caller for the script's initial synchronous slice, a synchronous spin past the first await cannot be killed in-process (the vm `timeout` covers only that first slice), and `dispose()` could only ABANDON an unsettling script, leaving the spin on the host loop. Superseded by the worker-thread engine, which keeps the same vm-context script surface while unblocking the host and making termination real. +- **Hostile-value containment in the host** (trap-free proxy rejection, accessor-never-invoked descriptor walks, realm-side pre-rendering of thrown values, realm-built promises/arrays/error clones with structural fatal recognition): rejected because every defense targets an author the trust premise accepts, while the thread's serialization boundary already makes cross-realm values total by construction. +- **In-process `node:vm` execution**: mechanically simplest — no RPC, no thread — but `start()` blocks the caller for the script's initial synchronous slice, a synchronous spin past the first await cannot be killed in-process (the vm `timeout` covers only that first slice), and `dispose()` could only abandon an unsettling script on the host loop. The worker-thread engine keeps the same vm-context script surface while unblocking the host and making termination real. - **Background execution as the default** (CC's shape): deferred; foreground-synchronous matches `dsh-tool-subagent`'s cut, and background semantics should be designed ONCE across bash/subagent/workflow rather than per-tool. - **Workflow-layer JSON parsing for `agent({schema})`**: duplicating a seam concern at one consumer while the seam's capability flag stayed dishonestly `false`. -- **Meta embedded in the script as `export const meta = {...}`** (CC's exact format; the first cut shipped it): keeps scripts self-contained and CC scripts drop-in, but obtaining meta means evaluating model-written text on the HOST — the shipped extractor ran the literal in an empty timed vm context, yet reading the RESULT still executed script-controlled getters on the host stack outside any timeout, re-opening the host-spin hole the worker thread exists to close. A JSON parameter deletes the scanner, the evaluation, and the hole outright; the cost is that a CC script's meta header must move into the parameter (the body stays drop-in). +- **Meta embedded in the script as `export const meta = {...}`** (CC's exact format): keeps scripts self-contained and CC scripts drop-in, but obtaining meta requires evaluating model-written text on the host. Even an empty timed vm context cannot bound script-controlled getters when the host reads the resulting object. A JSON parameter removes the scanner, evaluation, and host-spin hole; the cost is that a CC script's meta header must move into the parameter (the body stays drop-in). - **`SchemaSpec` as the outputSchema type**: the author-facing DSL cannot express what arrives as data and cannot be validated against without conversion loss. - **A schema-object library (zod, or the repo's schemastery) for the structured-output subset**: the schema is wire data — plain JSON that crosses the vm realm boundary in `agent({schema})` and lands verbatim in the forced tool's parameters — exactly where live schema objects cannot sit; consuming raw JSON Schema at runtime would need a third-party converter on top (zod core only emits JSON Schema, not the reverse), and it would put a second schema language beside schemastery's config role. - **ajv for value validation**: it validates FULL JSON Schema, so the subset gate — the module's actual point, since every accepted keyword must be one the harness enforces — would remain hand-written regardless; it compiles validators through `new Function`; and it would be dsh-tools' first runtime dependency, all to replace the ~70-line value walker while the path-qualified, every-violation error reporting stays custom either way. @@ -63,4 +68,4 @@ A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, awai ## Consequences -The harness gains CC-compatible script orchestration: fan-out plans live in a rerunnable artifact instead of the parent context, and the structured-output half of the subagent seam is now real (the vocabulary stopped lying about `outputSchema`). What it cost, all bounded by the trust premise: a worker thread per run (~tens-of-ms spin-up), every hook crossing a message port as RPC, and a termination-path `agentsStarted` that degrades to the host-observed count; in exchange `start()` never blocks the host, a post-cancel grace ends in a real `worker.terminate()`, and the value boundary is serialization by construction. A worker thread is still NOT a security boundary — scripts share the model's trust level, and actual sandboxing names its exit (the isolated-vm/separate-process engine swap behind the seam). The fatal-vs-null strictness divergence from CC means a CC-authored script that RELIES on option typos dissolving to `null` behaves differently here — judged worth it to keep the repo's no-accepted-then-ignored rule. Consumers must hold the run handle for control (`cancel`/`dispose`); observers get data snapshots only, so no listener can extend a run's lifetime or corrupt another's view. +The harness gains CC-compatible script orchestration: fan-out plans live in a rerunnable artifact instead of the parent context, and `outputSchema` yields an authoritative structured child result across native and Code Mode presentation. The cost, bounded by the trust premise, is a worker thread per run (~tens-of-ms spin-up), every hook crossing a message port as RPC, and a termination-path `agentsStarted` that degrades to the host-observed count; in exchange `start()` never blocks the host, a post-cancel grace ends in a real `worker.terminate()`, and the value boundary is serialization by construction. A worker thread is still not a security boundary — scripts share the model's trust level, and actual sandboxing requires an isolated-vm/separate-process engine behind the seam. The fatal-vs-null strictness divergence from CC means a CC-authored script that relies on option typos dissolving to `null` behaves differently, preserving the repo's no-accepted-then-ignored rule. Consumers must hold the run handle for control (`cancel`/`dispose`); observers get data snapshots only, so no listener can extend a run's lifetime or corrupt another's view. diff --git a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md index c321c56d53..6c7cbaa5ed 100644 --- a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md +++ b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md @@ -14,7 +14,7 @@ This RFC has a hard prerequisite on [session persistence](../../implemented/arch ## Proposal -A new plugin package `@deepseek-ai/dsh-acp` — a client-driver / UI plugin, the structured analogue of `stdio-chat`. It is NOT a change to the loop and NOT an [capability seams](../../implemented/architecture/2026-06-13-capability-seams.md) interface/implementation/consumer capability split; it consumes the existing `agent/*` event taxonomy and the `tools/pre-execute`/`tools/post-execute` waterfalls. +A new plugin package `@deepseek-ai/dsh-acp` — a client-driver / UI plugin, the structured analogue of `stdio-chat`. It is NOT a change to the loop and NOT a [capability seams](../../implemented/architecture/2026-06-13-capability-seams.md) interface/implementation/consumer capability split; it consumes the existing `agent/*` event taxonomy and the tool registry's guarded pre/around/post/final-result pipeline. It depends on the official `@agentclientprotocol/sdk` (the `AgentSideConnection` class) — Apache-2.0, actively versioned. The SDK declares a `zod` peer dependency and imports `zod/v4` at runtime, so `packages/ui/acp` must declare `zod` itself (per the workspace dependency constraints). This is the renamed successor to `@zed-industries/agent-client-protocol`, which is now deprecated on npm. diff --git a/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md b/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md index b3e6ab3cd7..9657512082 100644 --- a/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md +++ b/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md @@ -4,7 +4,7 @@ Status: proposed ## Problem -The [interception-seams RFC](../../implemented/feature/2026-06-30-interception-seams.md) added `tools/pre-execute` returning a `PreToolDecision` (allow/deny/ask) — but deliberately NOT input rewrite (a hook changing a tool call's `arguments` before it runs). Claude Code's `PreToolUse` hook offers an `updatedInput`, so a faithful CC bridge wants the same. This RFC designs that, separately, because doing it consistently is a real problem — not a field to bolt onto the allow decision. +The [interception-seams RFC](../../implemented/feature/2026-06-30-interception-seams.md) defines `tools/pre-execute` as an allow/deny/ask gate over an execution whose identity is already protected and whose arguments are deeply frozen. Claude Code's `PreToolUse` hook also offers `updatedInput`, so a faithful bridge needs an explicit rewrite mechanism. A rewrite cannot be a mutation escape hatch on the existing execution object: it must keep the durable history, audit record, presentation, and executed value consistent. ## The problem: three readers of pre-execution arguments @@ -14,37 +14,38 @@ In the loop, a tool call's arguments are committed to the log and read by live c 2. **`tool/call`** is the durable AUDIT record, appended before `ctx.tools.execute()`. 3. **Live presentation reads `tool/call.arguments`**: the ACP bridge remembers them and passes them to `presentResult`; `dsh-tool-bash` derives the card title, the rawInput, the cwd, and the terminal-vs-background treatment from them. -So an "input rewrite" that changes ONLY what executes would make the UI show one command while another RAN, and render result state against the wrong arguments — a real inconsistency, not a documentable gap. (The existing low-level capability to mutate `exec.arguments` in a listener has exactly this latent inconsistency; it is unadvertised precisely because of this — yet not unused: a tool-bash integration test rewrites a scripted call's arguments through it (`packages/bash/tool-bash/tests/integration.spec.ts`), so this design must either sanction that path with the consistency unit below or seal it — `readonly` arguments at the seam, with the test shim moved onto a behavior-level helper.) +An execution-only rewrite would make the UI show one command while another ran and render the result against the wrong arguments. The registry prevents that failure mode today: it structured-clones and deep-freezes `arguments`, makes the execution identity properties non-writable, and exposes no test shim or listener path that can replace them. The rewrite design must preserve that protected-identity boundary rather than weaken it. ## Proposal -A sketch, to validate against the code when built. Treat input rewrite as a consistency unit: when a `pre-execute` hook supplies `updatedInput`, the rewrite must be reflected in ALL three readers, atomically, before execution: +A rewrite is a pre-identity consistency transaction. When a hook supplies `updatedInput`, the effective value must be chosen before the registry constructs its immutable `ToolExecution`, and it must be reflected in all three readers atomically: - The `tool/call` audit event records the REWRITTEN arguments (with the original retained in a sidecar field for the audit trail — a hook changed the call, and both the original and the effective arguments are facts worth keeping). - The `assistant/message` in derived history must agree with what executed — options to evaluate: rewrite the assistant message's tool-call block in place (changes what the model "sees it said"), or record a separate correction the next request carries. The CC model is that the model sees the rewrite took effect. - Presentation (`presentCall`/`presentResult`) reads the rewritten arguments, so the UI shows what actually ran. -The shape would extend `PreToolDecision` with an allow-variant `arguments` (or a dedicated `{kind:'rewrite', arguments}`), and the loop would thread the rewrite through the three readers above rather than only into `ctx.tools.execute()`. +Extending `PreToolDecision` at its current firing point is insufficient: both durable records already exist by then, and the execution identity is protected. The implementation must either move the relevant decision before the log commit or add a dedicated earlier rewrite decision over the pending model call. After the loop commits the effective arguments to history and audit, it constructs the ordinary immutable execution and runs the existing allow/deny/ask and tool pipeline unchanged. ## Alternatives considered -### Why not now +### Why not mutate the execution object? -The interception-seams RFC notes input rewrite "fought the code across two review rounds" — the signal AGENTS.md names for an over-reaching change. Shipping allow/deny/ask first keeps the seam honest (no advertised contract that silently desyncs the UI), and a CC/Codex bridge that receives an `updatedInput` logs it and surfaces a faithful-but-degraded warning (like `ask`→deny) until this lands. This RFC is the home for the consistent design; `TODO(pre-tool-input-rewrite)` in the loop's pre-execute call site anchors it. +Allowing a pre-execute listener to assign `exec.arguments` would provide only an execution rewrite, leaving model history, audit, and presentation unchanged. Keeping the identity protected makes such partial behavior unrepresentable. Until the consistency transaction exists, a CC/Codex bridge logs and warns about `updatedInput` rather than claiming it was honored; `TODO(pre-tool-input-rewrite)` at the loop dispatch site anchors the missing earlier phase. ## Acceptance criteria -- A `pre-execute` rewrite is reflected in all three readers atomically before execution: the `tool/call` audit records the rewritten arguments (the original retained in a sidecar field), derived history agrees with what executed, and presentation renders the rewritten arguments. -- The unadvertised `exec.arguments` mutation path is either sanctioned by this consistency unit or sealed (`readonly` arguments at the seam, the test shim moved onto a behavior-level helper). +- A requested rewrite is resolved before `ToolExecution` identity is created and reflected in all three readers atomically: the `tool/call` audit records the rewritten arguments (the original retained in a sidecar field), derived history agrees with what executed, and presentation renders the rewritten arguments. +- The effective `ToolExecution.arguments` remains deeply frozen and non-writable throughout pre-policy, guards, dispatch, post-policy, and final observation; no mutation shim is introduced. - The CC/Codex bridges honor `updatedInput` instead of logging the faithful-but-degraded warning. ## Risks - Rewriting the `assistant/message` tool-call block changes what the model "sees it said"; whether any provider rejects that on replay is the open question that must be settled empirically before the decision shape freezes. -- Until this lands, the unadvertised mutation path keeps its latent UI-desync inconsistency. +- An earlier rewrite phase changes the ordering relationship among `assistant/message`, `tool/call`, hook audit events, and execution; the design must pin that ordering without weakening turn enclosure or call/result adjacency. ## Open questions - Does rewriting the `assistant/message` tool-call block corrupt any provider's expectation on replay, or is a separate correction safer? - Should the original arguments be preserved on the `tool/call` event (audit) and, if so, under what field? +- Does the rewrite decision move before the log commit or become a dedicated earlier seam, and how do existing pre-tool allow/deny hooks avoid running twice? - How does this interact with a future permission `ask` flow (a user approving a rewritten call)? diff --git a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md index a6f932b1ed..c6bfbe967c 100644 --- a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md +++ b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md @@ -8,13 +8,13 @@ Three pieces of public spine surface share one defect class: their only possible 1. **`SurfaceManager.invalidate()`** (`packages/core/session/src/surface.ts`). Its documented trigger — "the log has been replaced wholesale (e.g. after Session seed)" — is structurally unreachable: seeding happens inside the `Session` constructor, `_surface` is created lazily on first access, and the log reference is never reassigned afterward, so no constructed `SurfaceManager` ever observes a wholesale replacement. Sole caller: its own unit test. A rollback primitive protecting a scenario the implementation cannot produce. 2. **The `runLoop`, `Inbox`, and `InboxMessage` exports** (`packages/core/agent-loop/src/index.ts`). `runLoop` has no importer outside the package — the only callers are the package's own internals (the agent constructs its loop with it), so the public re-export has zero consumers; `Inbox`/`InboxMessage` likewise reach outside code only through the package's own inbox spec (switchable to the source module). The exports contradict the package's own docs — the inbox module doc says the public surface is `Agent.send()`/`Agent.steer()` — and the [architecture dependency rule](../../../architecture.md): nothing programs against `dsh-agent-loop`; a replacement loop is a different bundle built on `dsh-agent`, not a consumer of this package's internals. `ReactLoopAgent` stays exported (cross-package tests construct it by package name). -3. **`ToolExecutionResult.callId`** (`packages/core/tools/src/index.ts`; the *input* `ToolExecution.callId` stays). Zero readers — and no listener can even construct a result: `tools/pre-execute`/`tools/post-execute` listeners return Decisions, the registry builds every result itself and always sets `callId` to the input `exec.callId`, and the post-execute dispatch snapshots the outcome before the waterfall precisely so a listener mutating the shared result reference cannot corrupt the id. The loop independently ignores `result.callId` in favor of its own `call.id`, and two regression tests exist solely to prove the field cannot matter (the loop's ignores-result-callId test and the registry's mutation guard). A field that is by construction a copy of its input, defended by snapshot machinery, and pinned by tests proving it is ignored is pure liability surface; the ACP bridge correlates via the session event's `data.callId`, never via the execution result. +3. **`ToolExecutionResult.callId`** (`packages/core/tools/src/index.ts`; the input `ToolExecution.callId` stays). Zero consumers read it. A `tools/execute` wrapper may construct or replace a result, but the registry rejects any `callId` that differs from the immutable execution identity and rebuilds later outcomes from protected snapshots; `tools/post-execute` receives that same execution beside the result, and the observe-only `tools/result` notification receives both as immutable values. The loop independently correlates with its model call's `call.id`, while ACP correlates through the session event's `data.callId`. The result field is therefore a compulsory copy of information already present at every extension point, plus validation and regression tests whose only job is to prove the copy cannot disagree. ## Proposal -Delete the method and its test; delete the three export lines and their `packages/core/agent-loop/README.md` rows, pointing the inbox spec at the source module; drop the result field from the type, the registry's construction sites (the deny result, the dispatch result, `toolErrorResult`, and the post-execute snapshot's `callId` leg), the loop's ignore-comment, the proves-ignored regression test, and the mutation guard's `callId` assertions — the hazard they all pin disappears with the field, while the result's `additionalContext` ferry (a consumed post-execute channel) stays untouched. Update the `ToolExecutionResult` paste in [tools.md](../../../core-data-structures/tools.md) (and its `scripts/type-equiv.manifest.json` row) and the result-shape row in `packages/core/tools/README.md`; for the `invalidate()` removal, amend the [session-surface RFC](../../implemented/architecture/2026-06-18-session-surface.md)'s full-rebuild-after-wholesale-replacement sentence per [implemented/AGENTS.md](../../implemented/AGENTS.md). +Delete the method and its test; delete the three export lines and their `packages/core/agent-loop/README.md` rows, pointing the inbox spec at the source module; drop the result field from the type, the registry's construction sites (deny, dispatch, `toolErrorResult`, post-execute snapshots), its around-wrapper mismatch validation, the loop's ignore-comment, and the tests that prove the duplicate id cannot matter. The result's consumed `additionalContext` ferry and the execution object's authoritative `callId` stay untouched. Update the `ToolExecutionResult` paste in [tools.md](../../../core-data-structures/tools.md) (and its `scripts/type-equiv.manifest.json` row) and the result-shape row in `packages/core/tools/README.md`; for the `invalidate()` removal, amend the [session-surface RFC](../../implemented/architecture/2026-06-18-session-surface.md)'s full-rebuild-after-wholesale-replacement sentence per [implemented/AGENTS.md](../../implemented/AGENTS.md). -Sequencing: the in-flight surface-cache work (tool-pairing balance caching) neither uses nor touches `invalidate`, so that removal lands after or alongside it mechanically. The execute pipeline is `tools/pre-execute` → dispatch → `tools/post-execute`, and post-execute listeners receive the execution object alongside the result — nothing needs the result's own id. +Sequencing: the surface-cache work (tool-pairing balance caching) neither uses nor touches `invalidate`, so that removal can land after or alongside it mechanically. The full execution pipeline carries the immutable execution object through pre-policy, guards, around-dispatch wrappers, post-policy, and final result observation; nothing needs the result to repeat its id. ## Alternatives considered @@ -25,7 +25,7 @@ A future consumer that swaps a session's log in place would want a reset primiti ## Acceptance criteria - `invalidate()` and the result `callId` appear only in this RFC; `runLoop`/`Inbox`/`InboxMessage` remain package-internal only — no re-export from the package index and no outside-package importer; the agent-loop README lists only the consumed public surface; the inbox spec imports the source module. -- The pre-/post-execute pipeline contract tests pass with the shrunk result type; the mutation-guard and proves-ignored tests shed their `callId` legs with the hazard they pin. +- The complete tool-pipeline contract tests pass with the shrunk result type; the around-wrapper mismatch test, mutation-guard id assertions, and proves-ignored loop test disappear with the duplicate field. ## Risks diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 6d97017f1d..04ac0b9d26 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -16,7 +16,7 @@ This table connects model-visible tool names to the plugin package and service s | Tool package | Model-visible names | Requires | Writes / affects | Shipped aliases | Deployment note | | --- | --- | --- | --- | --- | --- | | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userInteraction` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. | -| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch per bridged sub-call`, `tool/result` | - | Registered by the tool registry itself under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the ONLY wire tool; the other registered tools are declared to the model as a generated TypeScript SDK prompt section instead, and a program calls them through port-bridged bindings that dispatch through the ordinary tools/pre-execute → tools/post-execute pipeline, one at a time. | +| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the registry's only canonical wire contribution; the other visible capabilities are declared in a protected TypeScript SDK section, and a program calls them through serialized bindings that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | | `@deepseek-ai/dsh-tool-bash` | `bash`, `bash_kill`, `bash_output` | `ctx.tools`, `ctx.bash` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; the request-header ToolsDelta logs those tool-set changes. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | @@ -118,7 +118,7 @@ Execute a TypeScript program against the available tools. Write the BODY of an a Source: [`packages/core/tools/src/code-mode.ts`](../packages/core/tools/src/code-mode.ts) -Registered by the tool registry itself under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the ONLY wire tool; the other registered tools are declared to the model as a generated TypeScript SDK prompt section instead, and a program calls them through port-bridged bindings that dispatch through the ordinary tools/pre-execute → tools/post-execute pipeline, one at a time. +Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the registry's only canonical wire contribution; the other visible capabilities are declared in a protected TypeScript SDK section, and a program calls them through serialized bindings that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. ## `@deepseek-ai/dsh-tool-bash` diff --git a/docs/tool-execution-pipeline.md b/docs/tool-execution-pipeline.md index 09a863badc..ef6715dccb 100644 --- a/docs/tool-execution-pipeline.md +++ b/docs/tool-execution-pipeline.md @@ -3,7 +3,7 @@ # Tool Execution Pipeline -This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, and UI rendering fit without changing the loop. The key extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls. +This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering fit without changing the loop. The transformable extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls; monotonic guards and `tools/result` are the owner-enforced boundaries around them. ```mermaid flowchart TD @@ -11,19 +11,24 @@ flowchart TD toolCall["Session event: tool/call
logged before execution"] presentCall["UI pending card
presentCall(args)"] pre["tools/pre-execute waterfall
hooks, permission, sandbox"] + guards["Registered monotonic guards
deny or abstain; identity protected"] denied["deny or ask
tool body skipped"] around["tools/execute waterfall
timeout, retry, metrics (around dispatch)"] toolBody["Registered tool execute() body"] fsGate["fs/write-intent or fs/edit-intent
tool-fs mutations only"] owned["Tool-owned session events
todo/write, fs/observed, hook/invoked, hook/result, tool/code-dispatch"] post["tools/post-execute waterfall
accept, block, replace, add context"] + final["tools/result parallel notification
frozen authoritative outcome"] context["Buffered additionalContext
context/message after all tool results"] toolResult["Session event: tool/result
single model-facing outcome"] + allResults["All calls in the step settled
and tool/result events recorded"] presentResult["UI completed card
presentResult(args, result)"] model --> toolCall toolCall --> presentCall toolCall --> pre - pre -->|allow| around + pre -->|allow| guards + guards -->|allow| around + guards -->|deny| denied around --> toolBody pre -->|deny or ask| denied denied --> post @@ -32,11 +37,13 @@ flowchart TD toolBody --> owned toolBody --> around around --> post - post --> context - post --> toolResult + post --> final + final --> toolResult toolResult --> presentResult + toolResult --> allResults + allResults --> context ``` -Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and future permission prompts live on the generic pre/post tool waterfalls; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service. Code Mode rides the same pipeline twice over: `run_code` is itself a registered tool body, and each tool call its program makes re-enters `ctx.tools.execute()` through BOTH waterfalls — serialized one at a time, logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call's `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency). +Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and future permission prompts live on the generic pre/post tool waterfalls; owner policy that must not be reordered uses registered guards; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. The awaited `tools/result` notification observes the immutable final outcome after every transform, lossless-JSON validation, and outer error normalization. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service. Code Mode rides the whole pipeline twice over: `run_code` is the reserved registry-owned transport whose body enters the pipeline, and each tool call its program makes re-enters `ctx.tools.execute()` — serialized one at a time, carrying the outer execution's opaque token for correlation, and logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call's `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency). Maintenance mode: curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs. diff --git a/examples/README.md b/examples/README.md index e8a41a366b..f500f25e9e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -19,7 +19,7 @@ A REPL agent demo: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + th Run with: `pnpm run demo:repl` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [coding-agent/README.md](coding-agent/README.md) for details. -Its `code-mode.cordis.yml` overlay flips the same tree to **Code Mode**: the worker-thread code runtime is loaded and the tool registry runs `mode: code`, so the model gets exactly one wire tool — `run_code` — plus a generated TypeScript SDK section, and composes the other tools by writing a program whose output it curates. Run with: `pnpm run demo:code-mode` (the REPL is the default UI; `acp` as the argument serves the acp-agent example's same-shaped overlay instead) — see the [Code Mode section](coding-agent/README.md#code-mode) for what to try. +Its `code-mode.cordis.yml` overlay flips the same tree to **Code Mode**: the worker-thread code runtime is loaded and the tool registry runs `mode: code`, so its canonical wire contribution is the protected `run_code` transport plus a protected generated TypeScript SDK section, and the model composes the other tools by writing a program whose output it curates. Run with: `pnpm run demo:code-mode` (the REPL is the default UI; `acp` as the argument serves the acp-agent example's same-shaped overlay instead) — see the [Code Mode section](coding-agent/README.md#code-mode) for what to try. ## cordis-agent diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md index 7731aa8f09..37a43481e5 100644 --- a/examples/coding-agent/README.md +++ b/examples/coding-agent/README.md @@ -33,7 +33,7 @@ The id is wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_ ## Code Mode -[`code-mode.cordis.yml`](code-mode.cordis.yml) is this same tree flipped to [Code Mode](../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): an include overlay over `./cordis.yml` whose two patches insert the worker-thread code runtime (`@deepseek-ai/dsh-code-runtime-worker`, registering `ctx.codeRuntime`) and set `tools: { mode: code }` on the app. The model is then offered exactly ONE wire tool — `run_code` — plus a generated TypeScript SDK section declaring every other registered tool; it composes them by writing a program, each program tool call bridges back through the ordinary `tools/pre-execute`/`post-execute` pipeline one at a time and is logged as a `tool/code-dispatch` session event, and ONLY what the program prints or returns re-enters its context. (Flip the mode to `both` to offer native calls AND `run_code` side by side.) +[`code-mode.cordis.yml`](code-mode.cordis.yml) is this same tree flipped to [Code Mode](../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): an include overlay over `./cordis.yml` whose two patches insert the worker-thread code runtime (`@deepseek-ai/dsh-code-runtime-worker`, registering `ctx.codeRuntime`) and set `tools: { mode: code }` on the app. The registry contributes exactly one protected wire transport — reserved `run_code` — plus a protected TypeScript SDK section declaring the visible end-capability tools. The model composes those capabilities by writing a program; each program call carries an immutable link to its enclosing transport, bridges back through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation one at a time, and is logged as a `tool/code-dispatch` session event. Only what the program prints or returns re-enters model context. (Flip the mode to `both` to offer native calls and `run_code` side by side.) ```sh pnpm run demo:code-mode # this overlay under the REPL (default UI) diff --git a/packages/core/README.md b/packages/core/README.md index 4e61293034..fa6ffc5a5a 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -7,7 +7,7 @@ The packages every harness build is assembled from: the session log, the system- | `scope/` | Scoped-context registration primitive (scope tags, scope-filtered dispatch) | (library — no ctx key) | | `session/` | Event-sourced session log + in-memory store | `ctx.sessions` | | `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` | -| `tools/` | Tool registry + `tools/pre-execute`/`tools/post-execute` pipeline | `ctx.tools` | +| `tools/` | Scoped tool registry + pre-policy, guards, around-dispatch, post-policy, and final-result observation | `ctx.tools` | | `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` | | `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` | | `agent-core/` | Bundle plugin: the providerless/executor-less/UI-less spine as code | (loads the spine) | diff --git a/packages/core/agent-core/README.md b/packages/core/agent-core/README.md index 3f2ff08c0e..6753472005 100644 --- a/packages/core/agent-core/README.md +++ b/packages/core/agent-core/README.md @@ -13,7 +13,7 @@ This is the package to read to see **the whole plugin tree at once** — the tea @deepseek-ai/dsh-llm abstract LLM service + content-block vocabulary @deepseek-ai/dsh-session event-sourced session log + store @deepseek-ai/dsh-system-prompt prompt-section + tool-schema assembly -@deepseek-ai/dsh-tools tool registry + tools/pre-execute/post-execute +@deepseek-ai/dsh-tools registry + guarded pre/around/post/final-result pipeline @deepseek-ai/dsh-agent agent registry + agent/* event vocabulary @deepseek-ai/dsh-invariants dev-mode event-contract assertions @deepseek-ai/dsh-tool-bash the model-facing bash/bash_output/bash_kill schemas diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index b5eb717dc8..a9fd8376f7 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -8,14 +8,14 @@ This is the only package in the harness that contains concrete loop logic. Every ### Public API -Lifecycle (scoped): the composite creation effect mints the agent's scope (`agent.ctx`), enters the session through it (the session's dispatch carrier), registers the agent, runs `CreateAgentOptions.setup`, emits `agent/session-start`, then starts the loop; teardown runs stop/drain → unregister → detach session → unwind scope, keeping store/registry rollback synchronous on every failure path. All `agent/*` dispatches go through `agentEvents(ctx, agent)`; per-step assembly through `assembleContextFor(agent)`; the turn-end durability checkpoint through `ctx.sessions.flush(session)`. +Lifecycle (scoped): programmatic creation and resume snapshot caller-owned identity/configuration data, reserve both IDs, mint `agent.ctx`, and install the ordered teardown skeleton before awaiting optional `setup`. Resume installs an owner-liveness sentinel before persistence load, then hands ownership directly to the full lifecycle. After setup resolves, the factory checks its lifecycle flag, owner-fiber state, and owning agent status around one microtask checkpoint so a same-turn Cordis unload wins before publication. Successful setup inserts both session and agent before announcing either, enables driving immediately before `agent/session-start`, then starts the loop. Setup calls to `send`/`steer`/`inject`/`cancel` reject structurally; load/setup rejection or owner unload publishes nothing. Teardown runs stop/drain → unregister → detach session → unwind scope. All `agent/*` dispatches go through `agentEvents(ctx, agent)`; per-step assembly through `assembleContextFor(agent)`; the turn-end durability checkpoint through `ctx.sessions.flush(session)`. - `ctx.agentLoop.create(id: string, options?: AgentOptions): ReactLoopAgent` — config-driven create: an agent on a fresh per-run session id `${id}-session-` (no cwd). Used for `cordis.yml`-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber. `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): -- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions? }): AgentHandle` — programmatic create on a caller-supplied `sessionId` (e.g. an ACP-generated id), NOT `${id}-session`; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix. Returns an [`AgentHandle`](../agent/README.md) — the owner disposes it to tear down exactly this agent (stop loop + await quiescence + unregister + remove session). -- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`. +- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup? }): Promise` — programmatic create on a caller-supplied `sessionId`, NOT `${id}-session`. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown. +- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions?, setup? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`. The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle) — only the programmatic factory callers (the ACP bridge and in-process subagent backends) hold a handle and own per-agent teardown. @@ -30,20 +30,22 @@ interface Config { agents: Array<{ id: string // required model?: string + resumeSessionId?: string // load this persisted session instead of creating one }> } ``` Agents listed in config are auto-created at startup. (There is no per-agent persona: the deployment persona is `dsh-system-prompt`'s own `persona` config, shared by every agent in the context.) The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from the `assemble({ agent })` context — runtime facts of the agents THIS loop drives, unlike the `harness:identity`/`deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin. -### Classes +### Exported concrete class -- `ReactLoopAgent` — the concrete `Agent` implementation. Owns the inbox (`Inbox`), the per-step `AbortController`, and the loop driver. Everything observable happens through session events and the `agent/*` event taxonomy. -- `Inbox` — per-agent queued + steering FIFOs (`enqueue`, `steer`, `drainQueued`, `drainSteering`, `waitForQueued`). +- `ReactLoopAgent` — the concrete `Agent` implementation. Its inbox is a JavaScript native-private field, and one prepared session can be claimed by only one concrete driver. Everything observable happens through session events and the `agent/*` event taxonomy. + +`Inbox`, `runLoop`, and the instance-bound enable/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. ### Loop lifecycle (`loop.ts`) -One invocation of `runLoop()` drives one agent for its whole lifetime: +The internal loop driver runs one agent for its whole lifetime: ``` create agent → emit agent/session-start(source) ⟵ once, before turn 1 @@ -69,29 +71,33 @@ forever: message = waterfall agent/step-result session('assistant/message') each tool-call: session('tool/call') - → tools.execute() [waterfall tools/pre-execute → dispatch → tools/post-execute] + → tools.execute() [pre waterfall → monotonic guards → around dispatch → post waterfall → final notification] → session('tool/result') append buffered post-execute additionalContext as session('context/message')(s) drain steering → session('steering/message') cont = waterfall agent/turn-continuation → ContinuationDecision ({action:'continue', reason?} records reason as next-step steering) - if action==stop (and no pending steering): break + pending steering can override an ordinary stop + terminal = serial agent/turn-stop → ContinuationStop | undefined + (after ordinary decision/reason/steering folding) + if terminal stop, or ordinary action==stop with no pending steering: break session('turn/end') await session/flush - re-enqueue leftover steering as queued + terminal turn: discard steering added before/during close and flush; keep ordinary queued sends + ordinary turn: re-enqueue leftover steering as queued idle unless more queued ``` -Error containment: a throwing plugin ends the **turn**, never the loop. Dispose mid-turn emits `agent/status('disposed')` and ends with reason `disposed`. A step that hits the model's output-token ceiling makes the turn end `max-tokens` (the rule: any `max-tokens` step in the turn surfaces as `max-tokens`; `disposed`/`aborted`/`error` still take precedence) — distinct from a clean `completed` stop. +Error containment: a throwing plugin ends the **turn**, never the loop. A malformed or throwing `agent/turn-stop` policy likewise fails the turn closed. A successful terminal stop stays authoritative through `turn/end` and `session/flush`, preventing their listeners from resurrecting steering through the late fallback. Dispose mid-turn emits `agent/status('disposed')` and ends with reason `disposed`. A step that hits the model's output-token ceiling makes the turn end `max-tokens` (the rule: any `max-tokens` step in the turn surfaces as `max-tokens`; `disposed`/`aborted`/`error` still take precedence) — distinct from a clean `completed` stop. Cancellation: `agent.cancel()` is the single public stop primitive — it clears the queued + steering FIFOs, aborts the in-flight step, and drives a turn-scoped marker the driver checks at every point a turn could start or continue (right after the idle wait, after the `running` flip, before each step, and at the continuation gate) so a turn about to start is dropped. A cancelled turn ends `aborted`; a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. The marker is reset once per loop iteration, so a cancel governs exactly one turn and never leaks onto a later prompt. (The loop still aborts its own per-step `AbortController` directly on disposal and from `cancel()`; that controller is loop-internal, not a public verb.) ### What is NOT here Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy: -- Hooks: `agent/session-start`, `agent/prompt-submit`, `agent/pre-step`, `agent/request`, `agent/session-prefix`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation` +- Hooks and policy: the relevant `agent/*` checkpoints plus the guarded `tools/pre-execute` → `tools/execute` → `tools/post-execute` → `tools/result` pipeline; exact signatures and modes live in the [generated event catalog](../../../docs/cordis-catalog/events.md) - Compaction: `agent/pre-step` -- Sandbox, permission, plan mode: `tools/pre-execute` (deny/ask gate), `tools/post-execute` +- Sandbox, permission, plan mode: `tools/pre-execute` for extensible deny/ask, `tools.guard()` for monotonic owner policy, `tools/post-execute` for result decisions, and `tools/result` for final observation - Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while child streaming/progress and background/poll collection remain deferred. - Persistence: `session/event` + `session/flush` - UI: `session/event` (assistant token stream, boundaries, tool activity) + `agent/*` control events (`agent/status`, `agent/created`/`agent/disposed`) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index e857676b0f..035332704e 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -8,56 +8,32 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i ### Public API -The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher every agent-subject event goes through (carrier + injected subject in one move); `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` composes a child's scoped world at creation — setup registers, it never drives. +The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher every agent-subject event goes through (carrier + injected subject in one move); `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while the factory keeps the agent and session unpublished; creation awaits setup and a same-turn owner-unload checkpoint before either creation notification or the first assembly. Setup composes, it never drives: the concrete loop rejects driving verbs until the `agent/session-start` boundary. - `ctx.agents.register(agent: Agent): () => Promise | void` — record an **already-constructed** agent. Disposed with the calling fiber. +- Advanced ordered lifecycle: `enter(agent): () => void` inserts without announcing, and `announce(agent)` emits `agent/created` only for that exact live entry. The async factory uses this split after setup; ordinary plugins use `register()`. - `ctx.agents.get(id: AgentId): Agent | undefined` - `ctx.agents.list(): Agent[]` #### Factory seam (creation) -Agent *creation* is provided by whichever plugin implements `AgentFactory` (phase 1: `dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. +Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. - `ctx.agents.setFactory(factory: AgentFactory): () => Promise | void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose. -- `ctx.agents.create(options: CreateAgentOptions): AgentHandle` — construct, start, AND register a new agent on a caller-supplied `sessionId` (with optional `meta.cwd`/`meta.parentSession`/`meta.seedLength` and optional `seed` events for forked children). Distinct from `register` (which only records). Throws if no factory is registered. -- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. Async; rejects if no factory is registered, or if the factory finds session persistence unconfigured. +- `ctx.agents.create(options: CreateAgentOptions): Promise` — snapshot caller-owned IDs/options/metadata/seed, construct and await optional setup while unpublished, insert and announce both session and agent, open the `agent/session-start` driving boundary, then start a new loop on the caller-supplied `sessionId`. Agent/session IDs are reserved across setup; setup rejection or owner unload publishes nothing. Publication is rollback-covered: if a creation listener throws, entries and scope unwind but effects of already-delivered notifications remain observable; an agent whose announcement began emits `agent/disposed` during that rollback. Rejects if no factory is registered. +- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — snapshot caller-owned IDs/options, load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh agent scope, await optional setup while unpublished, then follow the same insert → announce → session-start → loop-start boundary. The IDs are reserved across persistence load and setup; load/setup rejection or owner unload publishes nothing. Rejects if no factory is registered or session persistence is unconfigured. -`AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit (quiescence — NOT just the `disposed` status flip), unregisters the agent, and removes its session from the store, in an order that captures the loop's final `session/flush` before the session is detached. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge and in-process subagent backends are production consumers; config-created agents are owned by the loop fiber and never need a handle. +`AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit (quiescence — NOT just the `disposed` status flip), unregisters the agent, removes its session from the store, and finally unwinds its scoped world. This order captures the loop's final `session/flush` before the session is detached and keeps scoped listeners alive through that flush. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge and in-process subagent backends are production consumers; config-created agents are owned by the loop fiber and never need a handle. -### Events +### Live events -The full `agent/*` event taxonomy is declared via declaration merging in `dsh-agent` (not `dsh-agent-loop`), so plugins depend only on this package. +`dsh-agent` declares the live `agent/*` coordination vocabulary so plugins do not depend on the concrete loop. Exact signatures, dispatch modes, scope-filtering rules, and payload contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the [architecture turn flow](../../../docs/architecture.md#turn-flow) shows their order relative to durable session events. -#### Lifecycle (emit) +The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist, but concrete driving remains locked until the immediately following `agent/session-start`; that non-vetoing notification is the first supported startup injection point. `agent/disposed` runs after the driver is quiescent and the agent leaves the registry, while ordered teardown may still be detaching its session and unwinding its scope. -- `agent/created`, `agent/disposed` — registration/deregistration -- `agent/status` — idle / running / disposed transition -- `agent/queued` — message entered inbox (source-resolved, steering flag) -- `agent/session-start` — the session lifecycle began (once, before turn 1), carrying a `SessionStartSource` (`startup` for a fresh or forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it cannot block startup; a listener seeds context via `agent.inject()` (a `context/message` the first request sees). +Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` is a serial surface-mutation checkpoint, while `agent/turn-stop` is the owner-final exception: it runs after ordinary continuation and steering folding, and its terminal state remains through turn close and flush so steering from those later listeners cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale is in [the agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#owner-final-policy-boundaries). -#### Boundaries are durable session events, not `agent/*` emits - -Turn and step boundaries are NOT mirrored as `agent/*` emits: a consumer that needs them reads the durable `turn/start`/`turn/end`/`step/start`/`step/end` events off the `session/event` feed (the session log is the live boundary feed, carrying the `Session` — the turn/step numbers and reasons ride on the event data). See [the event-domain-semantics RFC](../../../docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md) and [the remove-boundary-mirror-events RFC](../../../docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md). - -#### Interception seams - -`agent/pre-step` is a **serial** surface-mutation checkpoint; the rest are **waterfalls** that return a small, seam-specific typed **Decision** union (the unified idiom across the taxonomy — a CC/Codex bridge maps its `permissionDecision`/`decision`/`continue` fields onto these, a native plugin returns them directly): - -- `agent/session-start` (emit) — fired once before the first turn; a listener seeds context via `agent.inject()` (it cannot veto startup). -- `agent/prompt-submit` — decide what happens to one drained queued message before it becomes a `user/message`: `PromptDecision` = `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block` (drop it; a batch whose every prompt is blocked opens a zero-step turn that ends `rejected`). Maps onto Claude Code's `UserPromptSubmit`. -- `agent/pre-step` (serial) — mutate the session surface before the step opens and history is derived (compaction). Fires after `turn/start` and before `step/start`, so a listener's appended events land outside the step; carries the assembled system prompt and the instance's composed session prefix so a token-pressure gate counts everything the request will carry. -- `agent/request` — shape the call config before the model call: a frozen `LlmCallConfig` seed in, a replacement out (model switching, sampling overrides). Content is not shapeable here — every request is a pure function of the session log ([reconstructability RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)); the loop logs whatever config the request actually uses as a `request/header*` event -- `agent/session-prefix` — compose the session prefix: request-only messages placed in front of the ENTIRE derived history on every request. Fired ONCE per loop instance, lazily before its first pre-step (so pressure gates see this instance's real prefix, never a previous instance's logged one); the composed result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the anchoring `request/header` snapshot, and reused verbatim afterwards — the prefix cannot change mid-session, so the provider prefix cache holds by construction (resume = a new instance = a recompose, attributably anchored by its `'resume'` snapshot). The home for session-stable openers that must not become durable history (a skills catalog, an AGENTS.md digest); `deriveMessages()` never returns it. Content that CHANGES mid-session belongs in the append-only history channels instead — `agent.inject()`, `tools/post-execute` `additionalContext`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter -- `agent/step-result` — post-process the assembled assistant message before tool dispatch (validates what the log records) -- `agent/turn-continuation` — override the continue/stop decision via `ContinuationDecision` = `{action:'stop'}` or `{action:'continue', reason?}` (a `continue` `reason` is recorded as next-step steering in the same turn — the typed `/goal` pattern). Force-continue `/loop`, force-stop budget guard. - -Tool interception is the `tools/pre-execute` / `tools/post-execute` pair in [`dsh-tools`](../tools/README.md) (`PreToolDecision` allow/deny/ask, `PostToolDecision` accept/block) — same typed-Decision idiom, owned there because it is the tool registry's seam. - -#### Error notifications (emit) - -- `agent/error` — step/turn error - -The model's token stream is NOT an `agent/*` event: read it off the durable `session/event` feed as `assistant/chunk` (the same feed persistence and the ACP bridge use). +Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md). ### Agent interface (`types.ts`) diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md index 0063ab8b2f..e57f475816 100644 --- a/packages/core/scope/README.md +++ b/packages/core/scope/README.md @@ -7,11 +7,12 @@ Scoped-context registration primitive. `createScope(ctx, key)` mints a Cordis co - `createScope(ctx: Context, key: ScopeKey): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). Throws on a primitive key, or when `ctx`'s fiber is disposing (`INACTIVE_EFFECT`). - `Scope.ctx` The tagged context: registrations through it are scope-visible AND scope-lifetime. Derived contexts (an `extend`, a fiber mounted under it) inherit the tag; nested scopes shadow (nearest tag wins). - `Scope.rawDispose` The EXACT Cordis disposer for the backing fiber — a composite (generator) effect yields THIS function to nest the scope's teardown at that yield position (Cordis dedupes nested effects by function identity; yielding a wrapper leaves the scope disposing as a concurrent sibling). -- `Scope.dispose(): Promise` Idempotent, always-awaitable teardown of every registration made through the scope. +- `Scope.dispose(): Promise` Idempotent, shared quiescence boundary for every registration made through the scope. Racing/repeat calls await the same teardown, including when `rawDispose` invoked the underlying single-shot Cordis disposer first. - `scopeOf(ctx: Context): ScopeKey | undefined` The tag a context (or any context derived from it) carries; `undefined` = context-global. - `scopeTarget(base: T, key?: ScopeKey): Scoped` Build the dispatch `thisArg` for a scope-filtered event: composes `base`'s own `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). Listener `this` stays `base`-shaped. `{ global: true }` listeners bypass filtering (Cordis semantics). - `Scoped` The compile-time carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. - `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name. +- `scopeHost(ctx, services)` Test/tooling host whose shared `dispose()` waits for both the host fiber and every minted scope, including a child already tearing down through `rawDispose`. ## Design contract diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 629cef5aec..094c3073df 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -9,7 +9,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Public API - `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage, seed boundary) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` and persisted `seedLength` to preserve them. Disposed with the calling fiber. -- `ctx.sessions.flush(session: Session): Promise` Dispatch the awaited `session/flush` durability checkpoint with the carrier captured at enter — THE flush entry point (the loop's turn-end checkpoint and idle injection call it; never dispatch a raw `ctx.parallel`). +- `ctx.sessions.flush(session: Session): Promise` Dispatch the awaited `session/flush` durability checkpoint with the carrier captured at enter — THE flush entry point (the loop's turn-end checkpoint and idle injection call it; never dispatch a raw `ctx.parallel`). Rejects a prepared, detached, or stale same-id object instead of inventing a subject-less carrier. - `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata. - `ctx.sessions.get(id: SessionId): Session | undefined` - `ctx.sessions.list(): Session[]` @@ -19,18 +19,14 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall `create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before `onAppend` detaches — `create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect: - `ctx.sessions.prepare(id?, options?): Session` — validate the id/cwd and construct the `Session`, WITHOUT entering it into the store. Same options as `create`. -- `ctx.sessions.enter(session): () => void` — wire `onAppend` → `session/event` and add the session to the store; returns the DETACH disposer. Does NOT emit `session/created` (the caller yields the disposer first, then calls `announce`, so a throwing listener rolls the attach back). The id was already validated by `prepare`, which runs in the same synchronous sequence, so `enter` does not re-check. +- `ctx.sessions.enter(session): () => void` — wire `onAppend` → `session/event`, capture its scope carrier, and add the session to the store; returns the idempotent DETACH disposer, which clears both notification and carrier state. Does NOT emit `session/created` (the caller installs the disposer first, then calls `announce`, so a throwing listener rolls the attach back). It re-checks the id because public `prepare`/`enter` calls may be interleaved; a stale prepared object must not overwrite a live same-id session. - `ctx.sessions.announce(session): void` — emit `session/created` for an entered session. -`dsh-agent-loop`'s `AgentLoop.start` is the canonical consumer: it yields `enter`'s detach disposer, the registry unregister, and the loop-stop disposer into ONE composite effect, so teardown stops + awaits the loop (final flush captured) BEFORE detaching the session — whether the trigger is the `AgentHandle`'s `dispose()` or a fiber unload. +`dsh-agent-loop` is the canonical consumer: after unpublished agent setup it enters both session and agent before announcing either, then nests loop stop, agent removal, session detach, and scope unwind in one ordered lifecycle. The final flush therefore settles before this package detaches the session, whether teardown starts from an `AgentHandle` or owner-fiber unload. -### Events +### Live service events -| Event | Mode | Purpose | -|---|---|---| -| `session/created` | emit | A session was created | -| `session/event` | emit (scope-filtered by the owning session's scope) | An event was appended (sync, fire-and-forget) | -| `session/flush` | parallel | Awaited durability checkpoint (persistence plugins drain buffers here) | +The store announces creation, publishes each append, and provides an awaited durability checkpoint. Exact `session/*` signatures, modes, and scope-carrier behavior live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the append-only payload vocabulary is separately generated into the [persistence catalog](../../../docs/persistence-catalog.md). Persistence consumers write behind from the append notification and drain on the store-owned flush entry point rather than dispatching the event directly. ### Class: `Session` diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index bf8c8bc4cc..d712cd4b5b 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -1,6 +1,6 @@ # dsh-system-prompt -System prompt assembly registry. Plugins contribute ordered text sections, tool-schema providers, and named prompt variables; the agent loop calls `assemble(context)` once per step, and `renderPrompt(assembly)` is the full system prompt the model sees. The plugin registers the harness-owned openers itself — the static `harness:identity` section and the deployment's `deployment:persona` section — so they exist for every agent regardless of which loop plugin drives it. +System prompt assembly registry. Plugins contribute ordered text sections, tool-schema providers, named prompt variables, and authoritative named protections; the agent loop calls `assemble(context)` once per step, and `renderPrompt(assembly)` is the full system prompt the model sees. The plugin registers the harness-owned openers itself — the static `harness:identity` section and the deployment's `deployment:persona` section — so they exist for every agent regardless of which loop plugin drives it. ## Config @@ -13,23 +13,22 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool- ### Public API -- `ctx.systemPrompt.section(section: PromptSection): () => Promise | void` Contribute a section. The layer is the CALLING context's scope: `agent.ctx` contributes to that agent alone, SHADOWING a same-named global section there (the per-agent persona mechanism — a scoped `deployment:persona`). Duplicate names within one layer throw. Disposed with the calling fiber. +- `ctx.systemPrompt.section(section: PromptSection): () => Promise | void` Contribute a section. The registry snapshots `name`, `order`, and the text value/callback, so later caller-object mutation cannot rename a stored section. The layer is the CALLING context's scope: `agent.ctx` contributes to that agent alone, SHADOWING a same-named global section there (the per-agent persona mechanism — a scoped `deployment:persona`). Duplicate names within one layer throw, and a globally protected section name cannot be shadowed. Disposed with the calling fiber. - `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise | void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set for `context.scope`; `knownNames` (defaulting to the schemas' names) is the pre-restriction universe `toolOrder` validates against. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber. - `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => Promise | void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables (via `agent.ctx`) shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber. -- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer (scoped shadows global). Runs through the `system-prompt/assemble` waterfall (scope-filtered by `context.scope`). Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe (a restricted-away KNOWN tool is a normal absence), or when a provider returns the reserved rest-entry name. +- `ctx.systemPrompt.protect(protection: PromptProtection): () => Promise | void` Make named section/tool contributions authoritative after the assembly waterfall. Protection restores canonical registry/provider presence and definition; restored entries keep canonical order with one another and anchor before their first surviving later unprotected canonical neighbor (or at the end), without undoing listener reordering of unprotected entries. Canonical absence is authoritative too, so a mode-hidden tool cannot be fabricated by a listener. Calling through `agent.ctx` protects only that agent's assemblies. A global section protection additionally reserves its name against scoped shadows; registering either side of that conflict fails loudly instead of treating the shadow as canonical. Inputs are snapshotted, empty protections throw, and disposal removes the protection. +- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer (scoped shadows global). Runs through the scope-filtered `system-prompt/assemble` waterfall, then restores protected contributions from the pre-waterfall canonical assembly. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe (a restricted-away KNOWN tool is a normal absence), or when a provider returns the reserved rest-entry name. -### Events +### Live events -| Event | Mode | Purpose | -|---|---|---| -| `system-prompt/assemble` | waterfall | Mutate/extend the assembly (with the caller's context) before it reaches the model | -| `system-prompt/change` | emit | A section, tool provider, or variable was registered or unregistered (possibly for one scope); deliberately unfiltered | +Prompt assembly is the scope-filtered transformable seam; registry change is the deliberately unfiltered notification that an assembly input changed, possibly for one scope. Exact signatures, dispatch modes, and filtering contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md). Named protections apply only after a successful assembly waterfall returns and are owned by the service rather than represented as another event listener. ### Key types - `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) here, and `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent)`). Providers must tolerate absent fields (a bare `assemble()` carries an empty, scope-less context). - `PromptSection` — `{ name, order, text: string | ((context) => string) }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona (both registered by this plugin), tool guidance uses `100–199`; other negative orders also render before the persona. - `PromptAssembly` — `{ sections: AssembledSection[], tools: ToolSchema[], variables: Record }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field. +- `PromptProtection` — `{ sections?: readonly string[], tools?: readonly string[] }`. Named contributions whose canonical pre-waterfall state is restored after all listeners; protections compose by set union rather than callback order, and global section names are reserved against scoped shadows. - `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference (`Object.hasOwn` lookup — prototype names like `{{constructor}}` are unknown), a registered-but-valueless reference, a malformed complete `{{…}}` group, or a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`) throws — fail loud beats shipping a malformed prompt. A lone `{{` with no `}}` anywhere after it passes through verbatim; substituted values are never re-scanned. Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `AssembleContext` via declaration merging. @@ -40,6 +39,7 @@ Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `Asse - Variable providers: the agent loop registers `model` and `cwd`; any plugin can register the facts it owns (a future `date`, git state, …). - Tool schema providers: `ToolRegistry` registers itself as a tool provider automatically. - The `system-prompt/assemble` waterfall: mutate or replace the assembly per caller (dynamic tool filtering, extra variables). +- `systemPrompt.protect()`: reserve canonical section/tool contributions for invariants that ordinary waterfall listeners must not be able to remove or replace. ### What is NOT here diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 2c423a61d4..41c40332f0 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -1,6 +1,6 @@ # dsh-tools -Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the allow/deny gate) → `tools/execute` (an around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the result, attach context). The registry also owns HOW its tools are presented to the model — its `mode` config selects native function calling, [Code Mode](#code-mode), or both. +Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the extensible allow/deny gate) → monotonic registered guards → `tools/execute` (an around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the result, attach context) → the observe-only `tools/result` notification. The registry also owns HOW its tools are presented to the model — its `mode` config selects native function calling, [Code Mode](#code-mode), or both. ## Service: `ToolRegistry` (ctx key: `tools`) @@ -11,44 +11,43 @@ tools: mode: native # native (default) | code | both ``` -`native` contributes every registered tool as a wire function definition — the default, byte-for-byte the pre-config behavior. `code` contributes exactly ONE wire tool, `run_code`, plus the generated `tools:sdk` prompt section (see [Code Mode](#code-mode)). `both` contributes every native definition AND `run_code` + the SDK section. In non-native modes `run_code` is reserved presentation infrastructure rather than a filterable capability: allow/deny restrictions cannot remove it, and registering, shadowing, or explicitly filtering that name fails loudly. Non-native modes require a loaded `ctx.codeRuntime` with `language: 'typescript'`; a missing or mismatched runtime rejects every prompt assembly with an actionable error, and a `systemPrompt.toolOrder` naming tools the mode no longer contributes rejects the assembly the same way. +`native` contributes the calling agent's visible end capabilities as wire function definitions. Under `code`, this registry's canonical contribution is the reserved `run_code` transport plus the generated `tools:sdk` prompt section (see [Code Mode](#code-mode)); an assembly listener may still deliberately add unrelated schemas. `both` contributes the visible native definitions, `run_code`, and the SDK section. In non-native modes both infrastructure pieces are protected rather than filterable capabilities: restrictions and assembly listeners cannot remove them, a scoped section cannot shadow the globally protected `tools:sdk`, and registering, shadowing, or explicitly filtering `run_code` fails loudly. Non-native modes require a loaded `ctx.codeRuntime` with `language: 'typescript'`; a missing or mismatched runtime rejects every prompt assembly with an actionable error, and a `systemPrompt.toolOrder` naming tools the mode no longer contributes rejects the assembly the same way. ### Public API -- `ctx.tools.register(definition: ToolDefinition): () => Promise | void` Register a tool. The layer is the CALLING context's scope (`dsh-scope`): a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, SHADOWING a same-named global tool there (per-agent tool variants). Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Disposed with the calling fiber (= the agent, for scoped registrations). +- `ctx.tools.register(definition: ToolDefinition): () => Promise | void` Register a tool as a frozen snapshot. Parameters must survive lossless-JSON validation before and after cloning; scalar fields are copied, and execute/presentation callbacks are bound once to the original definition as their method receiver, so later callback-property replacement cannot change dispatch. The layer is the CALLING context's scope (`dsh-scope`): a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, SHADOWING a same-named global tool there (per-agent tool variants). Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Disposed with the calling fiber (= the agent, for scoped registrations). - `ctx.tools.restrict(filter: ToolRestriction): () => Promise | void` Scoped-only (throws on a plain context): mask the GLOBAL end-capability surface for the calling agent — `allow` keeps only the listed tools, `deny` removes them; multiple restrictions intersect; scoped registrations bypass restriction as explicit grants. The reserved `run_code` transport remains available automatically and cannot be named explicitly. Snapshot-at-registration, loud unknown-name validation, `restrict({})` rejects (the materialized-empty-config trap). -- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. -- `ctx.tools.visible(scope?: ScopeKey): ToolDefinition[]` THE visibility function — restricted global layer ∪ the scope's own layer, plus the reserved transport in non-native modes — feeding prompt assembly, `get`, and `execute`, so what the model sees and what dispatches can never disagree. +- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. Returned definitions are the registry's frozen snapshots. +- `ctx.tools.visible(scope?: ScopeKey): ToolDefinition[]` The canonical executable view — restricted global layer ∪ the scope's own layer, plus the reserved transport in non-native modes — feeding prompt assembly, `get`, and `execute`, so presentation and dispatch resolve the same frozen definitions. - `ctx.tools.knownNames(scope?: ScopeKey): string[]` The PRE-restriction end-capability name universe `restrict` validates against: a typo fails loud while a restricted-away tool stays a normal absence. Presentation providers add reserved transport names separately when validating `toolOrder`. - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)). -- `ctx.tools.execute(exec: ToolExecution): Promise` Execute one tool call through the `tools/pre-execute` → `tools/execute` → `tools/post-execute` pipeline. +- `ctx.tools.guard(guard: ToolGuard): () => Promise | void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber. +- `ctx.tools.execute(exec: ToolExecutionInput): Promise` Snapshot one single-use call input into a pipeline-owned execution, assign its opaque correlation token, require `arguments` to be losslessly JSON-serializable before and after cloning, deep-freeze the detached arguments, and protect its identity before running `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute`; optional `signal` is the only operational field an around-dispatch wrapper may add, replace, or remove. Validate the final result as losslessly JSON-serializable and freeze the complete execution before `tools/result` observers run. Invalid or unstable input—including cloneable mutable exotics—and malformed or non-JSON listener/tool results normalize to `isError` outcomes rather than bypassing policy or failing later at the session log. ### Injected services `SystemPrompt` — the registry automatically feeds its tool schemas into the system-prompt assembly via `ctx.systemPrompt.tools()`. -### Events +### Live events -| Event | Mode | Purpose | -|---|---|---| -| `tools/pre-execute` | waterfall | Allow/deny gate BEFORE a tool runs (sandbox, permission, hooks); returns `PreToolDecision`. Scope-filtered by `exec.agent`: an `agent.ctx` listener gates only its own agent | -| `tools/execute` | waterfall | Around-dispatch wrapper (timeout, retry, metrics): `(exec, next)` → the dispatched `ToolExecutionResult`; `next()` is dispatch-with-normalization (resolving through the caller's visible view). Scope-filtered by `exec.agent` like the gate | -| `tools/post-execute` | waterfall | Inspect/replace the result AFTER a tool runs, attach context; returns `PostToolDecision` | -| `tools/change` | emit | A tool or restriction was registered or unregistered (possibly for one scope); deliberately unfiltered | +The live registry pipeline has three transformable waterfalls followed by the owner-final `tools/result` observation boundary; registry changes are deliberately unfiltered shared-state notifications. Exact signatures, dispatch modes, scope filtering, and failure-containment contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md), while the complete ordering is visualized in the generated [tool execution pipeline](../../../docs/tool-execution-pipeline.md). `tools/result` is live and observe-only; the similarly named `tool/result` is the durable session event the agent loop appends afterwards. ### Key types -- `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). It also carries an optional cooperative timeout budget `timeoutMs?: number` (ms) enforced by `@deepseek-ai/dsh-timeout-policy`, never sent to the model. -- `ToolExecution` — one pending tool call: `{ callId, name, arguments, agent?, signal? }`. -- `ToolExecutionResult` — outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). `additionalContext` (a `HookContext`) ferries any `tools/post-execute` context up to the loop, which buffers it and appends it as a `context/message` after all `tool/result`s in the step. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards it onto the `tool/result` session event for result-card rendering. +- `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). It also carries an optional cooperative timeout budget `timeoutMs?: number` (ms) enforced by `@deepseek-ai/dsh-timeout-policy`, never sent to the model. Registration stores a frozen snapshot with detached JSON parameters and once-bound callback identities. +- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; `arguments` must be losslessly JSON-serializable, and callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token. +- `ToolExecutionToken` — a frozen, property-free identity value assigned by the registry. It supports equality correlation only and exposes no live outer execution state. +- `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object. +- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. The registry validates the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). `additionalContext` (a `HookContext`) ferries any `tools/post-execute` context up to the loop, which buffers it and appends it as a `context/message` after all `tool/result`s in the step. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards it onto the `tool/result` session event for result-card rendering. - `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite (changing `arguments`) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC); `ask` degrades to `deny` until the permission system lands. - `PostToolDecision` — `{kind:'accept', content?, additionalContext?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContext?}` (turn it into an `isError` whose content is the corrective feedback). Output replacement is clean because `tool/result` is logged AFTER `execute()` returns. +- `ToolGuard` — `(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch. - `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged render intents a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation"). ### Extension points - Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically. -- `tools/pre-execute` is the allow/deny gate (sandbox, permission, hooks): listeners receive `(exec, next)` and call `next()` to delegate to the default (allow) or return a `PreToolDecision` to short-circuit; a `deny`/`ask` skips dispatch and yields an `isError` result. `tools/execute` is the around-dispatch seam (timeout, retry, metrics): listeners receive `(exec, next)` and call `next()` to delegate to core dispatch (returning its `ToolExecutionResult`, optionally wrapped), or return a replacement result to short-circuit dispatch; the base `next()` IS dispatch-with-normalization, so `await next()` already yields an `isError` result for a thrown/unknown tool (never a raw throw). A wrapper mutates `exec` in place before `next()` — e.g. replacing `exec.signal` with a per-call deadline — because cordis `next()` ignores passed arguments. `tools/post-execute` is the inspect/transform seam: `(exec, result, next)` → a `PostToolDecision` that can replace content, block with feedback, or attach `additionalContext`. Core dispatch is the base of the `tools/execute` waterfall; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. All follow the typed-Decision idiom shared with the `agent/*` interception seams (see [`dsh-agent`](../agent/README.md)); `@deepseek-ai/dsh-timeout-policy` is the reference `tools/execute` wrapper. +- `tools/pre-execute` is the reorderable allow/deny gate (sandbox, permission, hooks): listeners receive `(exec, next)` and call `next()` to delegate to the default (allow) or return a `PreToolDecision` to short-circuit; a `deny`/`ask` skips dispatch and yields an `isError` result. `ctx.tools.guard()` installs scope-aware monotonic policy after that waterfall when a denial must not be overridable by listener ordering. `tools/execute` is the around-dispatch seam (timeout, retry, metrics): listeners receive `(exec, next)` and call `next()` to delegate to core dispatch (returning its `ToolExecutionResult`, optionally wrapped), or return a replacement result to short-circuit dispatch; the base `next()` is dispatch-with-normalization, so `await next()` already yields an `isError` result for a thrown or unknown tool. A wrapper may change only `exec.signal` before `next()`—adding a per-call deadline, replacing a caller signal, or restoring absence afterwards—because call identity is protected before policy begins. `tools/post-execute` is the inspect/transform seam: `(exec, result, next)` → a `PostToolDecision` that can replace content, block with feedback, or attach `additionalContext`. Core dispatch is the base of the `tools/execute` waterfall; the tool body keeps its own error boundary so a thrown tool still reaches `post-execute` as an `isError`. Finally, `tools/result` observes the immutable authoritative result after every transform and error boundary. All follow the typed-decision idiom shared with the `agent/*` seams (see [`dsh-agent`](../agent/README.md)); `@deepseek-ai/dsh-timeout-policy` is the reference `tools/execute` wrapper. - MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas. ### Typed tool parameter schemas @@ -135,13 +134,13 @@ const bash = defineTool({ Under `mode: code` (or `both`) the registry turns the tool surface into a programming API, per the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): the model writes a TypeScript program (the body of an async function) and passes it to the reserved wire transport `run_code`; the program runs in `ctx.codeRuntime` (the [code-execution seam](../../code-runtime/README.md) — the shipped backend is a worker thread) with one async binding per visible end-capability tool (`await tools.bash({...})`), and ONLY what it prints or returns re-enters the model's context. Scope restrictions change those SDK bindings but cannot remove or replace the transport itself. -- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of every registered tool except `run_code` (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is TOTAL: constructs outside the `defineTool` subset degrade to `unknown`, never throw. -- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized BEFORE dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and the logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes the underlying `ctx.tools.execute()` calls one at a time in submission order — the tool contract carries no concurrency-safety metadata yet), gated by `tools/pre-execute`/`tools/post-execute` like any native call (a deny reaches the program as a binding rejection), and logged as one `tool/code-dispatch` session event (log-only: `deriveMessages()` never surfaces it) with the deterministic sub-id `:code:`. A failed sub-call REJECTS the program-side promise with the tool's error text — real code error handling, no bespoke envelope. A sub-call's `additionalContext` is deliberately DROPPED (no safe outlet mid-run without breaking tool-call/result adjacency; deferred until a real hook needs it through Code Mode). +- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. The registry protects this section and the `run_code` wire schema after the assembly waterfall, so Code Mode cannot silently lose either half of its transport. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is total: constructs outside the `defineTool` subset degrade to `unknown`, never throw. +- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized before dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A denial reaches the program as a binding rejection, and each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `:code:`; `deriveMessages()` does not surface that event. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. A sub-call's `additionalContext` is deliberately dropped because inserting it inside a running parent call would break tool-call/result adjacency. - **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from. -The wire collapse is the registry's own contribution (`systemPrompt.tools()` is mode-aware), so the logged `request/header` records it for free — under `code`, the assembled tool list is exactly `[run_code]`, pinned by tests and the snapshot goldens. Try it: `pnpm run demo:code-mode` ([the coding-agent example's Code Mode overlay](../../../examples/coding-agent/README.md#code-mode)); `pnpm run demo:code-mode acp` serves the same mode over ACP instead of the REPL. +The wire collapse is the registry's own contribution (`systemPrompt.tools()` is mode-aware), so the logged `request/header` records it for free. With no deliberate schema-adding assembly listener, `code` assembles exactly `[run_code]`, pinned by tests and the snapshot goldens; protection guarantees that `run_code` and `tools:sdk` remain present, not that unrelated listener additions are erased. Try it: `pnpm run demo:code-mode` ([the coding-agent example's Code Mode overlay](../../../examples/coding-agent/README.md#code-mode)); `pnpm run demo:code-mode acp` serves the same mode over ACP instead of the REPL. ### What is NOT here (TODO) -- **Tool shapes review** — when real tools land (e.g. a concurrency-safety hint for parallel execution); phase 1 executes tool calls sequentially. -- **Parallel execution** — the loop currently iterates tool calls sequentially. +- **Concurrency metadata** — tool definitions do not declare whether executions are safe to overlap. +- **Parallel execution** — the loop and Code Mode bridge execute tool calls sequentially until that metadata exists. diff --git a/packages/subagent/README.md b/packages/subagent/README.md index 87930167e9..62de4ffb08 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -5,13 +5,13 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | Package | Role | ctx key | |---|---|---| | `subagent/` | Abstract subagent seam: named-provider registry + vocabulary | `ctx.subagents` | -| `subagent-inprocess/` | Shared in-process run driver (pure lib; registers nothing) | — | +| `subagent-inprocess/` | Shared in-process run driver (no provider; one cleanup effect per run) | — | | `subagent-spawn/` | In-process backend: a fresh child agent | (registers on `ctx.subagents`) | | `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) | | `subagent-subprocess/` | Shared out-of-process machinery: env scrub, dispose ladder, isolated config dirs (pure lib; registers nothing) | — | | `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP | (registers on `ctx.subagents`) | | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | -The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a pure library — both depend on it, neither on the other), the out-of-process `subagent-acp` backend builds on the `subagent-subprocess` library (the credential env scrub, the dispose ladder, isolated config dirs) and ships alongside them here; the test-only `dsh-subagent-mock` (in [support](../support/README.md)) is separate. All **product** packages except the mock. +The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), the out-of-process `subagent-acp` backend builds on the `subagent-subprocess` library (the credential env scrub, the dispose ladder, isolated config dirs) and ships alongside them here; the test-only `dsh-subagent-mock` (in [support](../support/README.md)) is separate. All **product** packages except the mock. The proposal and design rationale: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md). diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 8e88505366..1c5aa13374 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-subagent-inprocess -The shared **in-process subagent run driver**. A pure library (no provider, no registration) that the in-process backends — [spawn](../subagent-spawn/README.md) (a fresh child) and [fork](../subagent-fork/README.md) (a child seeded with a prefix of the parent's log) — both build on. The backends are thin shells that differ ONLY in the session seed they pass; everything downstream lives here, so neither backend depends on the other. +The shared **in-process subagent run driver**. A library with no provider or import-time registration that the in-process backends — [spawn](../subagent-spawn/README.md) (a fresh child) and [fork](../subagent-fork/README.md) (a child seeded with a prefix of the parent's log) — both build on. Each accepted run installs one provider-owned cleanup effect. The backends are thin shells that differ ONLY in the session seed they pass; everything downstream lives here, so neither backend depends on the other. ## What it exports @@ -8,16 +8,16 @@ The shared **in-process subagent run driver**. A pure library (no provider, no r Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`): -1. computes child depth = `depthOf(parent) + 1`; if `request.maxDepth` is set and exceeded, throws `SubagentDepthError` (the `depthLimit` capability); a `request.outputSchema` is asserted against the supported subset (`assertSupportedOutputSchema` from [dsh-tools](../../core/tools/README.md)) and then snapshotted with `structuredClone` before any child exists — assertion first so a hostile value fails as `OutputSchemaError` (never a raw clone error), the snapshot so a post-`start()` caller mutation cannot drift the enforced schema; -2. creates a child via `ctx.agents.create` with a fresh `AgentId`/`SessionId`, the parent's `cwd` + `parentSession` lineage, the optional `options.seed` (fork's completed-turn prefix; omitted for a fresh child), and `agentOptions` (the child inherits the **parent's model** by default — a child with no model can't run — overridable via `request.agentOptions.model`; the deployment persona needs no inheritance — it is a context-wide prompt section); +1. snapshots the accepted request before asynchronous owner setup: the parent and signal remain identity capabilities but are never reread from the caller-owned record; tool filter, seed, agent options, output schema, and prompt are detached. It computes child depth = `depthOf(parent) + 1` and rejects `request.maxDepth` overflow with `SubagentDepthError`; `outputSchema` is asserted before cloning so a hostile value fails as `OutputSchemaError`, while the prompt passes the session log's lossless-JSON check before and after cloning; +2. first installs provider ownership, then attaches the request abort listener and creates one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves no child or orphaned listener. Async child creation goes through that fiber's `ctx.agents` service with fresh IDs, lineage/seed, inherited model, and an unpublished setup transaction for persona, tool restriction, and structured output. Parent teardown, provider teardown, and manual `run.dispose()` all dispose this exact node, preventing publication after it becomes inactive and awaiting the same quiescence boundary. `startInProcessRun` still returns its `SubagentRun` immediately, and cancellation during creation is recorded and applied when a child exists; 3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts); there is deliberately NO re-prompt for a structured child that finished cleanly without calling `structured_output` — the shortfall maps to an `error` result for the parent; 4. reads the result, scoped to the child's OWN events (everything at or after `seedLength`, so a seeded child that produced no message of its own never returns the seeded parent's last message): the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. A structured run surfaces the captured value as `result.structured`; a structured child that finished cleanly WITHOUT ever capturing settles `error` (a clean finish without the demanded result is a failure, not a success with a missing field). -`dispose()` delegates to `AgentHandle.dispose()` (stop loop → await quiescence → remove session); `cancel()` cancels the child's in-flight turn. A cancel landing before any `turn/end` (the pre-turn window) still settles `aborted`, honoring the cancel contract rather than the generic no-turn `error`. +`dispose()` awaits creation or rollback and then delegates to `AgentHandle.dispose()` (stop and drain → remove agent → detach session → unwind scope); `cancel()` records its request even before publication and cancels the child immediately once available. A cancel landing before any `turn/end` still settles `aborted`, honoring the cancel contract rather than the generic no-turn `error`. ### `InProcessRunOptions` -`{ providerName: string; seed?: SessionEvent[] }` — the per-backend inputs: the provider name (for error context) and the optional child-session seed. +`{ seed?: SessionEvent[] }` — the optional child-session seed: absent for spawn, or the parent's balanced completed-turn prefix for fork. ### Structured output (package-internal runtime) @@ -25,10 +25,10 @@ Runs a child as a child [`Agent`](../../core/agent) on the same cordis context ( - the `structured_output` capture tool with the run's REAL schema as its registered `parameters`, validating each call (`validateStructuredValue`) — violations become an `INVALID_ARGS` isError the model retries in-turn; a valid call STAGES the value in a `WeakMap` keyed by that call's `ToolExecution` object; - the calling instruction as an ordinary order-190 scoped prompt section (the demand travels with the tool, as prompt state of exactly one agent); -- a scoped `system-prompt/assemble` re-assert (`prepend: true`) that post-processes its downstream chain, replacing conflicting entries with the child's capture tool and instruction — the loop logs the rendered assembly as the step's `request/header`, so the demand is reconstructable log state; -- a scoped `tools/post-execute` COMMIT (`prepend: true`): the staged value becomes the run's result when that same execution's downstream post-execute decision accepts it. Execution-object identity prevents an orphaned stage from matching a later call even when an adapter reuses the call id; -- a scoped `tools/pre-execute` deny for any call arriving after the capture — terminal means terminal WITHIN the step; -- a scoped `agent/turn-continuation` veto (`prepend: true`) stopping the child's turn once its output is captured, so a successful capture doesn't buy a wasted extra model step. +- a scoped `systemPrompt.protect()` registration making the capture instruction and schema canonical after the complete assembly waterfall. Canonical absence is protected too: pure Code Mode removes `structured_output` from the wire and declares it through the SDK. The tool registry separately owns and protects its `tools:sdk` section and reserved `run_code` transport; protection guarantees those named contributions, while unrelated listener-added schemas remain the listener's responsibility. The loop logs the finalized assembly as the step's `request/header`, so the demand remains reconstructable; +- a scoped `tools/result` observer as the commit point: it promotes a staged value only when that same execution's immutable, JSON-safe authoritative result after the complete pre-execute → guards → execute → post-execute pipeline succeeds. For a Code Mode SDK sub-dispatch, the child's opaque `parent` token matches the enclosing `run_code` execution's registry-assigned `token`, so promotion waits for that outer final result without exposing its live object; a runtime failure or post-policy block discards the value. Execution-object identity prevents call-id reuse or another execution from reaching the stage; +- a scoped monotonic `tools.guard()` denial for every call arriving after capture. Guards run after the extensible pre-execute waterfall and cannot return allow, so terminal means terminal within the step regardless of listener order; +- a scoped `agent/turn-stop` terminal policy stopping the child's turn once its output is captured. It runs after ordinary continuation and steering folding, and its terminal state survives turn close and flush, so later listeners cannot leak steering into another step or turn; ordinary queued prompts remain intact. ### `depthOf(agent): number` diff --git a/packages/subagent/subagent-spawn/README.md b/packages/subagent/subagent-spawn/README.md index 132fd4d732..e133cb1e5b 100644 --- a/packages/subagent/subagent-spawn/README.md +++ b/packages/subagent/subagent-spawn/README.md @@ -6,11 +6,11 @@ The run mechanics live in the shared [`@deepseek-ai/dsh-subagent-inprocess`](../ ## What it does -`start(request)` delegates to `startInProcessRun(ctx, request, { providerName })` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose). +`start(request)` delegates to `startInProcessRun(ctx, request, {})` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. The driver creates one run-owner fiber under `parent.ctx`; parent teardown, this provider's teardown, and manual disposal all converge there before child publication. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose). ## Capabilities -`{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`. It constructs the child, so it enforces a recursion cap and composes the child's persona, global-tool restriction, and [structured runtime](../subagent-inprocess/README.md) inside the agent-creation setup window. This backend registers nothing at apply. +`{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`. It constructs the child, so it enforces a recursion cap and composes the child's persona, global-tool restriction, and [structured runtime](../subagent-inprocess/README.md) inside the agent-creation setup window. At apply it registers this one named provider on `ctx.subagents`; per-run contributions belong to each child's scope. ## Config From 172005e0e6de86dca7649cad0c188298af48339c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:22:57 +0800 Subject: [PATCH 31/64] fix(scope): align merged agent event contracts --- .../2026-07-08-agent-scope-contexts.md | 18 +++++++---- .../core/agent-core/tests/agent-core.spec.ts | 9 +++--- .../agent-loop/tests/interception.spec.ts | 30 +++++++++++++++++++ .../skill/tool-skill/tests/tool-skill.spec.ts | 10 ++++--- packages/ui/acp-agent/package.json | 1 + packages/ui/acp-agent/tests/acp-agent.spec.ts | 8 +++-- packages/ui/acp-agent/tsconfig.json | 3 ++ .../ui/stdio-agent/tests/stdio-agent.spec.ts | 9 +++--- pnpm-lock.yaml | 3 ++ 9 files changed, 70 insertions(+), 21 deletions(-) diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md index f5a4cb4b24..b92896c2b8 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -191,7 +191,7 @@ Scoped registration is incomplete unless behavior follows the same boundary. An The dispatch receiver carries the operation's scope key. Its filter admits an unscoped listener or a listener registered through the matching scoped context, while a subject-less dispatch admits unscoped listeners only. Cordis's explicit `{ global: true }` listener option remains the intentional bypass for infrastructure that must observe every dispatch. -Registry-membership notifications remain unfiltered. Events such as `tools/change`, `system-prompt/change`, and `subagent/provider-*` describe shared registry state rather than one agent's activity, so a scoped subscriber still observes those global changes. +Registry-membership notifications remain unfiltered. Events such as `tools/change`, `system-prompt/change`, `skill/provider-*`, and `subagent/provider-*` describe shared registry state rather than one agent's activity, so a scoped subscriber still observes those global changes. ### Each event family derives its key from its real subject @@ -200,6 +200,7 @@ The operation being described determines the key; callers cannot attach an unrel | Event family | Scope source | |---|---| | `agent/*`, including `agent/turn-stop` | The event's agent | +| `approval/request` | `ApprovalRequest.agent` | | `tools/pre-execute`, `tools/execute`, `tools/post-execute`, `tools/result` | `ToolExecution.agent`, or no key for an agent-less call | | `system-prompt/assemble` | `AssembleContext.scope` | | `session/created`, `session/event`, `session/flush` | The owner scope captured when the session enters the store | @@ -427,7 +428,7 @@ prepareExecution(input): `ctx.tools.guard()` installs a synchronous global or scope-specific guard after the extensible `tools/pre-execute` waterfall and before dispatch. A guard returns a denial reason or `undefined`; it has no allow result. -This one-way result makes the boundary monotonic. Pre-execution hooks can still compose ordinary allow, deny, and ask decisions, but no listener ordering can convert a guard denial back into dispatched work. A denied call still continues through result transformation and final observation as an error outcome. +This one-way result makes the boundary monotonic. Pre-execution hooks can still compose ordinary allow, deny, and ask decisions; an ask resolves through the optional `ctx.approval` seam, where only `allowed-once` becomes allow and an absent channel or any non-grant becomes deny before guards run. No listener ordering can convert a guard denial back into dispatched work. A denied call still continues through result transformation and final observation as an error outcome. ### `tools/result` observes the authoritative live outcome @@ -450,11 +451,16 @@ execute(input): return result try: - ordinaryDecision = await tools/pre-execute(execution) - if ordinaryDecision allows: + gate = await tools/pre-execute(execution) + decision = gate + if gate asks: + decision = await resolveWithApproval(gate, execution.agent) + # approval absence and every non-grant resolve to deny + + if decision allows: denial = firstRegisteredGuardDenial(execution) else: - denial = ordinaryDecision.denial + denial = decision.denial if denial exists: result = errorResult(denial) @@ -612,7 +618,7 @@ Scope mistakes are fail-open if they merely omit a carrier, so the implementatio ### Type markers cover every scoped event declaration -Scoped agent, tool, prompt, session, and subagent lifecycle events declare a `Scoped` receiver. TypeScript therefore rejects a bare subject at typed dispatch sites, including the `subagent/start` and `subagent/end` paths whose scope is the delegating parent. +Scoped agent, approval, tool, prompt, session, and subagent lifecycle events declare a `Scoped` receiver. TypeScript therefore rejects a bare subject at typed dispatch sites, including the `subagent/start` and `subagent/end` paths whose scope is the delegating parent. The marker is compile-time only. JavaScript callers, casts, and direct use of Cordis's dispatch APIs can bypass it, which is why the runtime checks remain necessary. diff --git a/packages/core/agent-core/tests/agent-core.spec.ts b/packages/core/agent-core/tests/agent-core.spec.ts index fb1bc7e1bd..855bb28d49 100644 --- a/packages/core/agent-core/tests/agent-core.spec.ts +++ b/packages/core/agent-core/tests/agent-core.spec.ts @@ -6,14 +6,15 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import * as agentCore from '../src/index.ts' -import { AgentId } from '@deepseek-ai/dsh-agent' +import { AgentId, agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import type { Message } from '@deepseek-ai/dsh-llm' async function composePrefix(ctx: Context, cwd: string): Promise { + const agent = { session: { header: { cwd } } } as unknown as Agent const empty: Message[] = [] - return await ctx.waterfall( - 'agent/session-prefix', { session: { header: { cwd } } } as never, - empty, new AbortController().signal, () => Promise.resolve(empty), + return await agentEvents(ctx, agent).waterfall( + 'agent/session-prefix', empty, new AbortController().signal, + () => Promise.resolve(empty), ) } diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index bab2ae6ea1..d4ec6312ba 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -311,6 +311,36 @@ describe('agent/session-start', () => { }) describe('agent/session-prefix', () => { + it('dispatches to global and matching agent-scope listeners only', async () => { + const adapter = new MockAdapter([textResponse('a done'), textResponse('b done')]) + const ctx = await harness(adapter) + const agentA = ctx.agentLoop.create(AgentId('prefix-a'), { model: 'mock' }) + const agentB = ctx.agentLoop.create(AgentId('prefix-b'), { model: 'mock' }) + const seen: string[] = [] + ctx.on('agent/session-prefix', async (agent, _prefix, _signal, next) => { + seen.push(`global:${agent.id}`) + return next() + }) + agentA.ctx.on('agent/session-prefix', async (agent, _prefix, _signal, next) => { + seen.push(`a:${agent.id}`) + return next() + }) + agentB.ctx.on('agent/session-prefix', async (agent, _prefix, _signal, next) => { + seen.push(`b:${agent.id}`) + return next() + }) + + send(agentA, 'run a') + await waitForIdle(ctx, agentA) + send(agentB, 'run b') + await waitForIdle(ctx, agentB) + + expect(seen).toEqual([ + 'global:prefix-a', 'a:prefix-a', + 'global:prefix-b', 'b:prefix-b', + ]) + }) + it('composes once per loop instance and fronts every request; the header records it; history stays untouched', async () => { const adapter = new MockAdapter([ toolCallResponse('c1', 'echo', { text: 'ping' }), diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index c7b843258c..ccfd795144 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -6,6 +6,7 @@ import { Context } from 'cordis' import { CallId, type Message } from '@deepseek-ai/dsh-llm' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' +import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import SkillService from '@deepseek-ai/dsh-skill' import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import * as toolSkill from '@deepseek-ai/dsh-tool-skill' @@ -30,14 +31,15 @@ async function setup(home: string, config: toolSkill.Config = {}): Promise { + const agent = agentForCwd(cwd) const empty: Message[] = [] - return await ctx.waterfall( - 'agent/session-prefix', agentForCwd(cwd), empty, signal, + return await agentEvents(ctx, agent).waterfall( + 'agent/session-prefix', empty, signal, () => Promise.resolve(empty), ) } diff --git a/packages/ui/acp-agent/package.json b/packages/ui/acp-agent/package.json index b6d59c9467..987ddb6c13 100644 --- a/packages/ui/acp-agent/package.json +++ b/packages/ui/acp-agent/package.json @@ -46,6 +46,7 @@ "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-core": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/ui/acp-agent/tests/acp-agent.spec.ts b/packages/ui/acp-agent/tests/acp-agent.spec.ts index 47cc399dfc..537155429c 100644 --- a/packages/ui/acp-agent/tests/acp-agent.spec.ts +++ b/packages/ui/acp-agent/tests/acp-agent.spec.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' +import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import type { Message } from '@deepseek-ai/dsh-llm' import * as acpAgent from '../src/index.ts' @@ -36,10 +37,11 @@ async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promi } async function composePrefix(ctx: Context): Promise { + const agent = { session: { header: { cwd: '/tmp' } } } as unknown as Agent const empty: Message[] = [] - return await ctx.waterfall( - 'agent/session-prefix', { session: { header: { cwd: '/tmp' } } } as never, - empty, new AbortController().signal, () => Promise.resolve(empty), + return await agentEvents(ctx, agent).waterfall( + 'agent/session-prefix', empty, new AbortController().signal, + () => Promise.resolve(empty), ) } diff --git a/packages/ui/acp-agent/tsconfig.json b/packages/ui/acp-agent/tsconfig.json index 13009a2e5c..5eb1c62282 100644 --- a/packages/ui/acp-agent/tsconfig.json +++ b/packages/ui/acp-agent/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../acp" }, + { + "path": "../../core/agent" + }, { "path": "../../core/agent-core" }, diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts index c08115526f..0668d25fb0 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import { AgentId } from '@deepseek-ai/dsh-agent' +import { AgentId, agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import type { Message } from '@deepseek-ai/dsh-llm' import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import * as stdioAgent from '../src/index.ts' @@ -43,10 +43,11 @@ async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promi } async function composePrefix(ctx: Context): Promise { + const agent = { session: { header: { cwd: '/tmp' } } } as unknown as Agent const empty: Message[] = [] - return await ctx.waterfall( - 'agent/session-prefix', { session: { header: { cwd: '/tmp' } } } as never, - empty, new AbortController().signal, () => Promise.resolve(empty), + return await agentEvents(ctx, agent).waterfall( + 'agent/session-prefix', empty, new AbortController().signal, + () => Promise.resolve(empty), ) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 018c439824..aace53a37e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1243,6 +1243,9 @@ importers: '@deepseek-ai/dsh-acp': specifier: workspace:^ version: link:../acp + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent '@deepseek-ai/dsh-agent-core': specifier: workspace:^ version: link:../../core/agent-core From cf255eebb168e3dcd331dedfcd513d3f28cb45fd Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:41:37 +0800 Subject: [PATCH 32/64] fix(scope): harden merged tool and skill boundaries --- docs/cordis-catalog/services.md | 4 +- .../cordis/tool-cordis/src/api-catalog.ts | 4 +- packages/core/tools/README.md | 2 +- packages/core/tools/src/index.ts | 44 +++++++++++- packages/core/tools/tests/tools.spec.ts | 60 ++++++++++++++++ packages/skill/skill/README.md | 6 +- packages/skill/skill/src/index.ts | 49 ++++++++----- packages/skill/skill/tests/skill.spec.ts | 68 +++++++++++++++++-- packages/skill/tool-skill/README.md | 2 +- packages/skill/tool-skill/package.json | 1 + packages/skill/tool-skill/src/index.ts | 31 ++++++--- .../skill/tool-skill/tests/tool-skill.spec.ts | 50 +++++++++++++- packages/skill/tool-skill/tsconfig.json | 1 + pnpm-lock.yaml | 3 + 14 files changed, 283 insertions(+), 42 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ef4a30a3c3..1dad87a861 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -227,8 +227,8 @@ Source: [`packages/core/session/src/index.ts:427`](../../packages/core/session/s Registry of skill providers. It merges provider catalogs with stable first-wins duplicate handling, exposes sorted model-visible summaries, and loads full skill bodies on demand. ```ts cordis-catalog -registerProvider(provider: SkillProvider): () => void -register(skill: SkillRegistration): () => void +registerProvider(provider: SkillProvider): () => Promise | void +register(skill: SkillRegistration): () => Promise | void async list(options: SkillLookupOptions = {}): Promise async get(name: string, options: SkillLookupOptions = {}): Promise ``` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index fe04ceb610..57390710f8 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -169,8 +169,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'skills', summary: 'Registry of skill providers.', methods: [ - 'registerProvider(provider: SkillProvider): () => void', - 'register(skill: SkillRegistration): () => void', + 'registerProvider(provider: SkillProvider): () => Promise | void', + 'register(skill: SkillRegistration): () => Promise | void', 'async list(options: SkillLookupOptions = {}): Promise', 'async get(name: string, options: SkillLookupOptions = {}): Promise', ], diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index d8a3ec08e5..2a678408e0 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -39,7 +39,7 @@ The live registry pipeline has three transformable waterfalls followed by the ow - `ToolExecutionToken` — a frozen, property-free identity value assigned by the registry. It supports equality correlation only and exposes no live outer execution state. - `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object. - `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. The registry validates the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). `additionalContext` (a `HookContext`) ferries any `tools/post-execute` context up to the loop, which buffers it and appends it as a `context/message` after all `tool/result`s in the step. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards it onto the `tool/result` session event for result-card rendering. -- `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite (changing `arguments`) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC); `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when a deployment mounts it (`allowed-once` proceeds to dispatch; `rejected`/`cancelled`/`unavailable` deny with distinct reasons) and degrades to `deny` when none is mounted or the execution carries no agent. +- `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. The registry validates this exact union at runtime: a JavaScript/casted value with an unknown kind, a malformed reason, or extra fields fails closed as an `isError`; the tool body does not run and final observers still receive one result. Input rewrite (changing `arguments`) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC); `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when a deployment mounts it (`allowed-once` proceeds to dispatch; `rejected`/`cancelled`/`unavailable` deny with distinct reasons) and degrades to `deny` when none is mounted or the execution carries no agent. - `PostToolDecision` — `{kind:'accept', content?, additionalContext?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContext?}` (turn it into an `isError` whose content is the corrective feedback). Output replacement is clean because `tool/result` is logged AFTER `execute()` returns. - `ToolGuard` — `(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch. - `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged render intents a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation"). diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 66dab9e2bd..71dd465dd6 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -91,6 +91,9 @@ declare module 'cordis' { * tool body never runs. Input rewrite is deliberately NOT offered here (see * {@link PreToolDecision}); `ask` is serviced by the `ctx.approval` seam * when one is mounted, and degrades to deny otherwise. + * The returned union is validated as an exact runtime shape before approval + * or guards run; a malformed JavaScript/casted decision fails closed as an + * `isError` result and the tool body never runs. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `exec.agent`: a * listener registered through `agent.ctx` fires only for that agent's * calls, while a plain plugin listener fires for every call (including @@ -908,6 +911,8 @@ export class ToolRegistry extends Service { * {@link HarnessError} surfaces its `{ name, code }` on the result. Before * the final observe-only notification, the authoritative outcome must survive * a lossless JSON round trip; an invalid outcome is normalized to an error. + * A malformed runtime/casted `tools/pre-execute` decision likewise normalizes + * to an error before approval, guards, or the tool body. * Caller-owned arguments must survive lossless-JSON validation before and * after cloning; a violation normalizes to an error before policy or dispatch. * @param exec - the single-use call input; its identity is snapshotted and @@ -1002,10 +1007,10 @@ export class ToolRegistry extends Service { // carrier keys dispatch by exec.agent, so an `agent.ctx` listener gates only // its own agent's calls (agent-less calls are subject-less). const carrier = scopeTarget(this, exec.agent) - const gate = await this.ctx.waterfall( + const gate = this.snapshotPreDecision(await this.ctx.waterfall( carrier, 'tools/pre-execute', exec, () => Promise.resolve({ kind: 'allow' }), - ) + )) const decision = gate.kind === 'ask' ? await this.serviceAsk(exec, gate) : gate const denialReason = decision.kind === 'allow' ? this.guardReason(exec) @@ -1055,6 +1060,41 @@ export class ToolRegistry extends Service { return await this.postExecute(exec, result) } + /** Validate and detach the extensible gate's decision before any grant can dispatch. */ + private snapshotPreDecision(value: unknown): PreToolDecision { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError('tools/pre-execute must return a PreToolDecision object') + } + const decision = value as { kind?: unknown; reason?: unknown } + const keys = Reflect.ownKeys(decision) + const hasExactKeys = (...expected: string[]): boolean => + keys.length === expected.length && expected.every(key => Object.hasOwn(decision, key)) + switch (decision.kind) { + case 'allow': + if (!hasExactKeys('kind')) { + throw new TypeError('tools/pre-execute allow decision must contain only kind') + } + return { kind: 'allow' } + case 'deny': { + const reason = decision.reason + if (!hasExactKeys('kind', 'reason') || typeof reason !== 'string') { + throw new TypeError('tools/pre-execute deny decision must contain only kind and a string reason') + } + return { kind: 'deny', reason } + } + case 'ask': { + const reason = decision.reason + if (!(hasExactKeys('kind') || hasExactKeys('kind', 'reason')) + || (reason !== undefined && typeof reason !== 'string')) { + throw new TypeError('tools/pre-execute ask decision must contain only kind and an optional string reason') + } + return { kind: 'ask', ...reason !== undefined ? { reason } : {} } + } + default: + throw new TypeError('tools/pre-execute must return an allow, deny, or ask decision') + } + } + /** Notify final-result observers without giving them a mutation/error channel into the outcome. */ private async notifyResult(exec: ToolExecution, result: ToolExecutionResult): Promise { // The pipeline is over: freeze the remaining mutable signal slot so every diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index d1c454738d..deb53b7dc7 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -229,6 +229,66 @@ describe('ToolRegistry', () => { expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' }) }) + it.each([ + { + name: 'non-object decision', + replacement: null, + message: 'tools/pre-execute must return a PreToolDecision object', + }, + { + name: 'unknown decision kind', + replacement: { kind: 'permit' }, + message: 'tools/pre-execute must return an allow, deny, or ask decision', + }, + { + name: 'allow decision carrying extra fields', + replacement: { kind: 'allow', reason: 'smuggled' }, + message: 'tools/pre-execute allow decision must contain only kind', + }, + { + name: 'deny decision without a reason', + replacement: { kind: 'deny' }, + message: 'tools/pre-execute deny decision must contain only kind and a string reason', + }, + { + name: 'deny decision with a non-string reason', + replacement: { kind: 'deny', reason: 42 }, + message: 'tools/pre-execute deny decision must contain only kind and a string reason', + }, + { + name: 'ask decision with a non-string reason', + replacement: { kind: 'ask', reason: true }, + message: 'tools/pre-execute ask decision must contain only kind and an optional string reason', + }, + { + name: 'ask decision carrying extra fields', + replacement: { kind: 'ask', cache: true }, + message: 'tools/pre-execute ask decision must contain only kind and an optional string reason', + }, + ])('fails closed on a malformed tools/pre-execute $name', async ({ replacement, message }) => { + const ctx = await setup() + let bodyCalls = 0 + const observed: ToolExecutionResult[] = [] + ctx.tools.register({ + ...echoTool, + async execute() { + bodyCalls += 1 + return [] + }, + }) + ctx.on('tools/pre-execute', async () => replacement as unknown as PreToolDecision) + ctx.on('tools/result', (_exec, result) => { observed.push(result) }) + + const result = await ctx.tools.execute({ + callId: CallId('malformed-pre'), name: 'echo', arguments: {}, + }) + + expect(result.isError).toBe(true) + expect(result.content[0]).toMatchObject({ text: `Error: ${message}` }) + expect(bodyCalls).toBe(0) + expect(observed).toEqual([result]) + }) + it('rejects a JavaScript guard that returns an async/non-string decision', async () => { const ctx = await setup() let bodyCalls = 0 diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index 98f787dbc7..33d16e1b69 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -8,10 +8,10 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ### Public API -- `ctx.skills.registerProvider(provider): () => void` Registers a provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registration is effect-scoped and HMR-safe. +- `ctx.skills.registerProvider(provider): () => Promise | void` Registers a provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registry snapshots the name and callback identities at registration, so replacing those fields later cannot change lookup or HMR cleanup; callbacks remain bound to the original provider object and can still read its mutable state. The registration is effect-scoped and HMR-safe, and the exact Cordis disposer supports ordered composite teardown. - `ctx.skills.list({ cwd?, signal? })` Returns model-invocable skill summaries for the current workspace, merged across providers and sorted by name. - `ctx.skills.get(name, { cwd?, signal? })` Returns the full winning skill, including disabled-for-model skills. -- `ctx.skills.register(skill): () => void` Registers a runtime embedded skill. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. +- `ctx.skills.register(skill): () => Promise | void` Registers a runtime embedded skill. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown. ### Config @@ -21,7 +21,7 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ## Provider Contract -A provider registers synchronously from its `apply()` and returns `SkillCandidate[]` from `list(options)` when discovery is requested. Remote setup, authentication, and discovery belong in the awaited `list()` call rather than plugin registration. Providers should stop promptly when `options.signal` aborts; the registry also stops awaiting an uncooperative provider so agent cancellation cannot hang prefix composition. The provider later receives the winning candidate back in `get(candidate, options)`. The candidate's `locator` is opaque to the registry, so a local provider can store a file path while a remote provider can store a URL, id, or version token. +A provider registers synchronously from its `apply()` and returns `SkillCandidate[]` from `list(options)` when discovery is requested. Registration copies `name` and binds the current `list` and `get` methods once; replacing those fields on the caller-owned object later does not rewrite the live registry entry, and disposal always removes the original name. Remote setup, authentication, and discovery belong in the awaited `list()` call rather than plugin registration. Providers should stop promptly when `options.signal` aborts; the registry also stops awaiting an uncooperative provider so agent cancellation cannot hang prefix composition. The provider later receives the winning candidate back in `get(candidate, options)`. The candidate's `locator` is opaque to the registry, so a local provider can store a file path while a remote provider can store a URL, id, or version token. The registry validates candidate names, descriptions, ranks, and provider ownership. Candidate contract violations fail fast because the provider plugin is malformed; a provider `list()` rejection is treated as a transient source failure, logged, skipped for that request, and not cached. Only completed catalogs are cached, and a provider/runtime revision change during discovery discards the stale result and retries. Duplicate skill names are resolved first-wins by `rank`, provider registration order, then the provider's own local order. The final summary list is sorted by skill `name` for deterministic consumers. diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index 50a8b5d0d0..20ac717e9c 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -177,31 +177,46 @@ export class SkillService extends Service { * Register a skill provider synchronously during the provider plugin's * `apply()`. Throws if another provider already owns the same provider name, * including the reserved runtime provider name. Providers that need remote - * initialization do that work inside `list()` after registration. Effect- - * scoped and HMR-safe: disposing the caller's fiber unregisters the provider - * and invalidates cached catalogs. + * initialization do that work inside `list()` after registration. The name + * and callback identities are snapshotted at registration, so later + * replacement of those fields cannot change the registry key, dispatch + * callbacks, or HMR cleanup identity. Bound callbacks retain the original + * provider object as their receiver, so provider-owned mutable state remains + * live. Effect-scoped and HMR-safe: disposing the caller's fiber unregisters + * the provider and invalidates cached catalogs. * @param provider - the provider to register by `provider.name`. - * @returns a disposer that unregisters this provider. + * @returns the exact Cordis effect disposer that unregisters this provider; + * composite effects may yield it directly to preserve teardown ordering. */ - registerProvider(provider: SkillProvider): () => void { + registerProvider(provider: SkillProvider): () => Promise | void { + // Snapshot the registration contract before entering the effect. The + // callback binding preserves the historical method receiver while making + // replacement of `provider.list`/`provider.get` after registration inert. + // In particular, cleanup must never re-read caller-owned `provider.name`: + // an HMR host may mutate or reuse that object before its old fiber unloads. + const snapshot: SkillProvider = Object.freeze({ + name: provider.name, + list: provider.list.bind(provider), + get: provider.get.bind(provider), + }) const dispose = this.ctx.effect(function* (this: SkillService) { - if (provider.name === RUNTIME_PROVIDER) { + if (snapshot.name === RUNTIME_PROVIDER) { throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`) } - if (this.providers.has(provider.name)) { - throw new Error(`a skill provider named "${provider.name}" is already registered`) + if (this.providers.has(snapshot.name)) { + throw new Error(`a skill provider named "${snapshot.name}" is already registered`) } - this.providers.set(provider.name, { provider, order: this.nextProviderOrder }) + this.providers.set(snapshot.name, { provider: snapshot, order: this.nextProviderOrder }) this.nextProviderOrder += 1 this.invalidateCache() yield () => { - this.providers.delete(provider.name) + this.providers.delete(snapshot.name) this.invalidateCache() - this.ctx.emit('skill/provider-removed', provider.name) + this.ctx.emit('skill/provider-removed', snapshot.name) } - this.ctx.emit('skill/provider-added', provider) + this.ctx.emit('skill/provider-added', snapshot) }.bind(this), 'skills.registerProvider()') - return () => void dispose() + return dispose } /** @@ -210,9 +225,11 @@ export class SkillService extends Service { * registrations are first-wins: a duplicate logs a warning and gets a no-op * disposer so it cannot remove the active contribution. * @param skill - the complete skill definition to expose for discovery. - * @returns a disposer that removes this runtime contribution and invalidates caches. + * @returns the exact Cordis effect disposer that removes this runtime + * contribution and invalidates caches; composite effects may yield it + * directly to preserve teardown ordering. */ - register(skill: SkillRegistration): () => void { + register(skill: SkillRegistration): () => Promise | void { const normalized = normalizeRuntimeSkill(skill) const existing = this.runtime.get(normalized.name) if (existing !== undefined) { @@ -229,7 +246,7 @@ export class SkillService extends Service { this.invalidateCache() } }.bind(this), 'skills.register()') - return () => void dispose() + return dispose } /** diff --git a/packages/skill/skill/tests/skill.spec.ts b/packages/skill/skill/tests/skill.spec.ts index 010d161039..e3b491f690 100644 --- a/packages/skill/skill/tests/skill.spec.ts +++ b/packages/skill/skill/tests/skill.spec.ts @@ -103,10 +103,68 @@ describe('SkillService registry', () => { }, })).toThrow('reserved') - disposeMemory() + await disposeMemory() expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['same-rank-skill', 'shadowed']) }) + it('snapshots a provider registration so caller mutation cannot corrupt HMR cleanup', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + const candidate: SkillCandidate = { + name: 'stable-skill', + description: 'Stable skill', + provider: 'stable-provider', + source: 'test', + rank: 1, + locator: 'original', + } + const originalList = vi.fn(() => Promise.resolve([candidate])) + const originalGet = vi.fn((listed: SkillCandidate) => Promise.resolve({ + ...listed, + content: 'Original body.', + })) + const provider: SkillProvider = { + name: 'stable-provider', + list: originalList, + get: originalGet, + } + const added: SkillProvider[] = [] + const removed: string[] = [] + ctx.on('skill/provider-added', (registered) => { added.push(registered) }) + ctx.on('skill/provider-removed', (name) => { removed.push(name) }) + const owner = await ctx.plugin({ + name: 'mutable-provider-owner', + inject: ['skills'], + apply(pluginCtx: Context) { + pluginCtx.skills.registerProvider(provider) + }, + }) + + provider.name = 'mutated-provider' + const replacementList = vi.fn(() => Promise.resolve([])) + const replacementGet = vi.fn(() => Promise.resolve(undefined)) + provider.list = replacementList + provider.get = replacementGet + + expect(added).toHaveLength(1) + expect(added[0]).not.toBe(provider) + expect(added[0]?.name).toBe('stable-provider') + expect(Object.isFrozen(added[0])).toBe(true) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['stable-skill']) + expect((await ctx.skills.get('stable-skill'))?.content).toBe('Original body.') + expect(originalList).toHaveBeenCalledOnce() + expect(originalGet).toHaveBeenCalledOnce() + expect(replacementList).not.toHaveBeenCalled() + expect(replacementGet).not.toHaveBeenCalled() + + await owner.dispose() + expect(removed).toEqual(['stable-provider']) + expect(await ctx.skills.list()).toEqual([]) + const replacement = new MemoryProvider([]) + Object.defineProperty(replacement, 'name', { value: 'stable-provider' }) + expect(() => ctx.skills.registerProvider(replacement)).not.toThrow() + }) + it('validates provider candidates and invalid registry caps', async () => { const defaultedService = new SkillService(new Context()) expect(await defaultedService.list()).toEqual([]) @@ -196,7 +254,7 @@ describe('SkillService registry', () => { path: 'memory://runtime-skill', metadata: { owner: 'tests' }, }) - disposeRuntime() + await disposeRuntime() await ctx.skills.list({ cwd: '/tmp/first-cache-key' }) await ctx.skills.list({ cwd: '/tmp/second-cache-key' }) @@ -245,7 +303,7 @@ describe('SkillService registry', () => { const pending = ctx.skills.list() await started - dispose() + await dispose() release?.() expect(await pending).toEqual([]) @@ -336,9 +394,9 @@ describe('SkillService registry', () => { const disposeFirst = ctx.skills.register({ name: 'same-skill', description: 'First', source: 'runtime', content: 'first' }) const disposeSecond = ctx.skills.register({ name: 'same-skill', description: 'Second', source: 'runtime', content: 'second' }) - disposeSecond() + await disposeSecond() expect((await ctx.skills.get('same-skill'))?.description).toBe('First') - disposeFirst() + await disposeFirst() expect(await ctx.skills.get('same-skill')).toBeUndefined() }) }) diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index c55bc3693d..7e72f89000 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -6,7 +6,7 @@ Requires `ctx.tools` and `ctx.skills` (`inject: ['tools', 'skills']`). ## Session-prefix catalog -The plugin contributes one user-role `` catalog through `agent/session-prefix`. It resolves skills for the calling session's cwd, forwards the prefix abort signal to discovery, and lists only sorted `name` and `description` entries; skill bodies, paths, sources, providers, and `whenToUse` hints remain outside the catalog. The catalog is omitted when no model-invocable skills are available. +The plugin contributes one user-role `` catalog through `agent/session-prefix`. It resolves skills for the calling session's cwd, forwards the prefix abort signal to discovery, and lists only sorted `name` and `description` entries; skill bodies, paths, sources, providers, and `whenToUse` hints remain outside the catalog. The catalog is omitted when no model-invocable skills are available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. This exact-definition check keeps prompt guidance, the model-visible schema, and executable dispatch aligned. `catalogDescriptionMaxLength` controls normalized, XML-escaped catalog descriptions. Its default is `500` and values must be integers of at least `3`, which reserves room for a truncation ellipsis. The [session-prefix RFC](../../../docs/rfc/implemented/feature/2026-07-07-session-prefix.md) defines the request-only, header-logged lifecycle of this message. diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index f0d97eb3d8..bf7a77a49e 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -34,6 +34,7 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index dc3232a13f..39fa0253b4 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -26,18 +26,16 @@ export const Config: z = z.object({ catalogDescriptionMaxLength: z.number().default(DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH), }) -/** Register the session-prefix skill catalog and the model-facing skill loader. */ +/** + * Register the model-facing skill loader and its visibility-matched + * session-prefix catalog. The catalog is emitted only when the calling agent + * resolves this plugin's exact tool registration; a restriction or scoped + * same-name shadow therefore removes both the schema and its call guidance. + */ export function apply(ctx: Context, config: Config = {}): void { const catalogDescriptionMaxLength = config.catalogDescriptionMaxLength ?? DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH assertPositiveInteger('catalogDescriptionMaxLength', catalogDescriptionMaxLength, 3) - ctx.on('agent/session-prefix', async (agent, _prefix, signal, next): Promise => { - const skills = await ctx.skills.list({ cwd: agent.session.header.cwd, signal }) - const rest = await next() - if (skills.length === 0) return rest - return [renderCatalogMessage(skills, catalogDescriptionMaxLength), ...rest] - }) - const skillTool = defineTool({ name: 'skill', description: 'Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.', @@ -62,6 +60,23 @@ export function apply(ctx: Context, config: Config = {}): void { }, }) ctx.tools.register(skillTool) + const registeredSkillTool = ctx.tools.get(skillTool.name) + /* v8 ignore next 3 -- register() publishes synchronously or throws; this guards future registry drift. */ + if (registeredSkillTool === undefined) { + throw new Error('dsh-tool-skill: registered skill tool is not visible in the global registry') + } + + // Register after the tool so reverse-order fiber teardown removes this + // guidance listener before its referenced tool. Exact definition identity is + // the shared truth for restrictions and scoped shadows: another tool merely + // named `skill` must not inherit this plugin's catalog or instructions. + ctx.on('agent/session-prefix', async (agent, _prefix, signal, next): Promise => { + if (ctx.tools.get(skillTool.name, agent) !== registeredSkillTool) return await next() + const skills = await ctx.skills.list({ cwd: agent.session.header.cwd, signal }) + const rest = await next() + if (skills.length === 0) return rest + return [renderCatalogMessage(skills, catalogDescriptionMaxLength), ...rest] + }) } function renderSkillContent(skill: SkillDefinition): string { diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index ccfd795144..cab7f4aa02 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -4,8 +4,9 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { Context } from 'cordis' import { CallId, type Message } from '@deepseek-ai/dsh-llm' +import { createScope, type Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import SkillService from '@deepseek-ai/dsh-skill' import * as SkillLocal from '@deepseek-ai/dsh-skill-local' @@ -36,7 +37,10 @@ function agentForCwd(cwd: string): Agent { } async function composePrefix(ctx: Context, cwd: string, signal = new AbortController().signal): Promise { - const agent = agentForCwd(cwd) + return await composePrefixForAgent(ctx, agentForCwd(cwd), signal) +} + +async function composePrefixForAgent(ctx: Context, agent: Agent, signal = new AbortController().signal): Promise { const empty: Message[] = [] return await agentEvents(ctx, agent).waterfall( 'agent/session-prefix', empty, signal, @@ -44,6 +48,15 @@ async function composePrefix(ctx: Context, cwd: string, signal = new AbortContro ) } +async function mintAgentScope(ctx: Context, cwd: string): Promise<{ agent: Agent; scope: Scope }> { + const agent = agentForCwd(cwd) + let scope!: Scope + await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, { + inject: ['tools'], + })) + return { agent, scope } +} + describe('dsh-tool-skill', () => { it('registers the skill tool schema and removes it on dispose', async () => { const ctx = new Context() @@ -154,6 +167,39 @@ describe('dsh-tool-skill', () => { expect(await composePrefix(ctx, '/workspace')).toEqual([]) }) + it('omits catalog guidance when the calling agent restricts away the shipped skill tool', async () => { + const home = await tempDir('tool-restricted-catalog') + const ctx = await setup(home) + ctx.skills.register({ name: 'listed-skill', description: 'Listed', source: 'runtime', content: 'body' }) + const { agent, scope } = await mintAgentScope(ctx, '/workspace') + scope.ctx.tools.restrict({ deny: ['skill'] }) + + expect(ctx.tools.get('skill', agent)).toBeUndefined() + expect(await composePrefixForAgent(ctx, agent)).toEqual([]) + expect(await composePrefix(ctx, '/workspace')).toHaveLength(1) + await scope.dispose() + }) + + it('does not attach shipped catalog guidance to a scoped same-name tool shadow', async () => { + const home = await tempDir('tool-shadowed-catalog') + const ctx = await setup(home) + ctx.skills.register({ name: 'listed-skill', description: 'Listed', source: 'runtime', content: 'body' }) + const { agent, scope } = await mintAgentScope(ctx, '/workspace') + scope.ctx.tools.register(defineTool({ + name: 'skill', + description: 'A scoped tool with unrelated semantics.', + parameters: {}, + execute() { + return Promise.resolve([{ type: 'text', text: 'shadow' }]) + }, + })) + + expect(ctx.tools.get('skill', agent)).not.toBe(ctx.tools.get('skill')) + expect(await composePrefixForAgent(ctx, agent)).toEqual([]) + expect(await composePrefix(ctx, '/workspace')).toHaveLength(1) + await scope.dispose() + }) + it('validates the catalog description cap', async () => { const home = await tempDir('tool-invalid-catalog-cap') const ctx = new Context() diff --git a/packages/skill/tool-skill/tsconfig.json b/packages/skill/tool-skill/tsconfig.json index 039fc641c0..52ebb8bf9d 100644 --- a/packages/skill/tool-skill/tsconfig.json +++ b/packages/skill/tool-skill/tsconfig.json @@ -9,6 +9,7 @@ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, { "path": "../../../vendor/schemastery" }, + { "path": "../../core/scope" }, { "path": "../../llm/llm" }, { "path": "../../core/agent" }, { "path": "../skill" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aace53a37e..68ec4e3183 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -822,6 +822,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../skill From 06e42f439c95346aeb22e6451dbcd018f6240fee Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:42:52 +0800 Subject: [PATCH 33/64] docs: regenerate merged catalogs --- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 12 ++++++------ docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 10 +++++----- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 238a739510..6e8bf080ac 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -988,7 +988,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:406`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:409`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-user-approval` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index c72bbc9c82..cd1327a1d1 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -371,7 +371,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:173`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:176`](../../packages/core/tools/src/index.ts) ### `tools/execute` — waterfall @@ -383,7 +383,7 @@ Around-dispatch waterfall wrapping the registry's core tool dispatch, between th Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:128`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:131`](../../packages/core/tools/src/index.ts) ### `tools/post-execute` — waterfall @@ -395,11 +395,11 @@ Waterfall AFTER a tool runs — where hook plugins inspect the result and accept Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:148`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:151`](../../packages/core/tools/src/index.ts) ### `tools/pre-execute` — waterfall -Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code's `PreToolUse`). Listeners receive `(exec, next)`: call `next()` to delegate to the default (allow), or return a PreToolDecision without calling `next()` to short-circuit. A `deny` skips dispatch and yields an `isError` result; the tool body never runs. Input rewrite is deliberately NOT offered here (see PreToolDecision); `ask` is serviced by the `ctx.approval` seam when one is mounted, and degrades to deny otherwise. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `exec.agent`: a listener registered through `agent.ctx` fires only for that agent's calls, while a plain plugin listener fires for every call (including agent-less ones, which dispatch subject-less). +Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code's `PreToolUse`). Listeners receive `(exec, next)`: call `next()` to delegate to the default (allow), or return a PreToolDecision without calling `next()` to short-circuit. A `deny` skips dispatch and yields an `isError` result; the tool body never runs. Input rewrite is deliberately NOT offered here (see PreToolDecision); `ask` is serviced by the `ctx.approval` seam when one is mounted, and degrades to deny otherwise. The returned union is validated as an exact runtime shape before approval or guards run; a malformed JavaScript/casted decision fails closed as an `isError` result and the tool body never runs. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `exec.agent`: a listener registered through `agent.ctx` fires only for that agent's calls, while a plain plugin listener fires for every call (including agent-less ones, which dispatch subject-less). ```ts cordis-catalog 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise @@ -407,7 +407,7 @@ Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook pl Types: [ToolExecution](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:101`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:104`](../../packages/core/tools/src/index.ts) ### `tools/result` — parallel @@ -419,7 +419,7 @@ Awaited notification of the authoritative FINAL tool outcome, after the complete Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:163`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:166`](../../packages/core/tools/src/index.ts) ## `workflow/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 1dad87a861..b331e53fe3 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -281,7 +281,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:478`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:481`](../../packages/core/tools/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index fea5dce332..36cad3e3ad 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -36,11 +36,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:98`](../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) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:176`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:131`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:151`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:104`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `tools/result` | `parallel` | [`packages/core/tools/src/index.ts:166`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts: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`) | - | From e6c7522a75570b3945851e91713e7afc01193d4d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:45:01 +0800 Subject: [PATCH 34/64] fix(agent-loop): nest config resume teardown --- packages/core/agent-loop/src/index.ts | 6 +++++- .../agent-loop/tests/config-session-id.spec.ts | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index c37cce6a1a..0b8879c2b7 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -120,7 +120,11 @@ export class AgentLoop extends Service implements AgentFactory { this.ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`) }) }) - return () => void fiber.dispose() + // Return the EXACT child-fiber disposer. Cordis moves a returned + // effect into this labeled owner's teardown tree by function + // identity; a wrapper would leave the child as a concurrent sibling + // and could discard its async quiescence promise. + return fiber.dispose }, `agentLoop.resume(${id})`) } else { this.create(id, options, cwd === undefined ? {} : { cwd }) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 97f04cbbca..07d6cd9e9b 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -24,6 +24,24 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { } describe('config-driven session id', () => { + it('identity-nests the deferred resume fiber under its labeled owner effect', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + const loopFiber = await ctx.plugin(AgentLoop, { + agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('deferred') }], + }) + + const resumeEffect = loopFiber.getEffects().find(effect => effect.label === 'agentLoop.resume(main)') + expect(resumeEffect?.children.map(child => child.label)).toEqual(['ctx.plugin()']) + expect(loopFiber.getEffects().filter(effect => effect.label === 'ctx.plugin()')).toEqual([]) + + await loopFiber.dispose() + }) + it('config-driven create uses a fresh ${id}-session- per run (restart-safe)', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-session-')) dirs.push(root) From 3529b3c166955d9bc111a7d7d74873b18b531870 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:31:46 +0800 Subject: [PATCH 35/64] fix(scope): close final ownership races Drain idle injection flushes before agent teardown, snapshot approval and subagent provider inputs, and gate subagent lifecycle events on real child readiness. Align the RFCs and generated contracts with the hardened behavior. --- docs/config-catalog.md | 4 +- docs/cordis-catalog/events.md | 38 ++--- docs/cordis-catalog/services.md | 6 +- docs/core-data-structures/subagent.md | 7 +- docs/core-data-structures/tools.md | 2 +- docs/event-producer-consumer.md | 32 ++-- docs/persistence-catalog.md | 6 +- .../2026-07-08-agent-scope-contexts.md | 25 ++- .../feature/2026-06-15-code-mode.md | 10 +- .../2026-06-21-subagent-capability-seam.md | 2 +- .../feature/2026-06-30-hook-bridges.md | 9 +- .../feature/2026-06-30-interception-seams.md | 4 +- .../2026-06-30-subagent-observe-enrich.md | 2 +- .../feature/2026-07-06-approval-seam.md | 36 ++--- .../cordis/tool-cordis/src/api-catalog.ts | 6 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/agent.ts | 96 ++++++++---- packages/core/agent-loop/src/index.ts | 14 +- packages/core/agent-loop/src/loop.ts | 2 +- packages/core/agent-loop/tests/agent.spec.ts | 9 +- .../agent-loop/tests/scope-lifecycle.spec.ts | 31 ++++ packages/core/agent/README.md | 2 +- packages/core/agent/src/index.ts | 11 +- packages/core/agent/src/types.ts | 6 +- packages/hooks/hooks-claude/README.md | 2 +- packages/subagent/subagent-acp/README.md | 4 +- packages/subagent/subagent-acp/src/run.ts | 97 +++++++----- packages/subagent/subagent-fork/README.md | 2 +- .../subagent-fork/tests/subagent-fork.spec.ts | 17 ++ .../subagent/subagent-inprocess/README.md | 4 +- .../subagent/subagent-inprocess/src/index.ts | 15 +- packages/subagent/subagent-spawn/README.md | 2 +- .../tests/subagent-spawn.spec.ts | 19 +++ packages/subagent/subagent/README.md | 10 +- packages/subagent/subagent/src/index.ts | 148 ++++++++++++------ packages/subagent/subagent/src/types.ts | 23 ++- .../subagent/subagent/tests/service.spec.ts | 140 ++++++++++++++++- .../tool-subagent/tests/tool-subagent.spec.ts | 10 ++ packages/support/subagent-mock/src/index.ts | 3 + packages/ui/user-approval/README.md | 2 +- packages/ui/user-approval/src/index.ts | 85 ++++++++-- .../ui/user-approval/tests/approval.spec.ts | 134 +++++++++++++++- .../tests/workflow-workerthread.spec.ts | 7 + 43 files changed, 825 insertions(+), 261 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6e8bf080ac..18ef659ee0 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -745,7 +745,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:84`](../packages/support/subagent-mock/src/index.ts) +Source: [`packages/support/subagent-mock/src/index.ts:87`](../packages/support/subagent-mock/src/index.ts) ## `@deepseek-ai/dsh-subagent-spawn` @@ -1019,7 +1019,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/ui/user-approval/src/index.ts:263`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:268`](../packages/ui/user-approval/src/index.ts) ## `@deepseek-ai/dsh-web` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index cd1327a1d1..f7bbecb20f 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -23,7 +23,7 @@ An agent's fully composed scoped world was published in the AgentRegistry. Its s Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:298`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:300`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -35,7 +35,7 @@ An agent was removed from the registry after its driver and any in-flight turn r Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:312`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:314`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -47,7 +47,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:585`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:587`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -61,7 +61,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:417`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:419`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -73,7 +73,7 @@ Waterfall: decide what happens to ONE drained queued message before it becomes a Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:435`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:437`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -85,7 +85,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:340`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:342`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -97,7 +97,7 @@ Waterfall: shape the step's call configuration — model switching, sampling ove Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:464`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:466`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -113,7 +113,7 @@ The seed is a frozen empty list; a contributing listener returns a NEW array — Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:516`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:518`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -125,7 +125,7 @@ The agent's session lifecycle began, fired once before its first turn. `source` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:360`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:362`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -137,7 +137,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:326`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:328`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -149,7 +149,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:531`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:533`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -161,7 +161,7 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:549`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:551`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -173,13 +173,13 @@ Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` wat Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:568`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:570`](../../packages/core/agent/src/types.ts) ## `approval/*` ### `approval/request` — waterfall -Waterfall asking the composed answerers to decide one approval request. Dispatched only from ApprovalService.request — callers go through the service (which owns cancellation and the audit events), never through `ctx.waterfall` directly. A listener that can answer for this request's agent returns an outcome WITHOUT calling `next()` (the decision slot is single-occupancy, first listener to answer wins); a listener that does not recognize the agent MUST call `next()` so another answerer — or the fail-closed default `'unavailable'` — gets the question. Throwing is contained by the service and yields `'unavailable'`. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `req.agent`: a listener registered through `agent.ctx` receives only that agent's questions, while a plain-context listener receives every agent's. +Waterfall asking the composed answerers to decide one approval request. Dispatched only from ApprovalService.request — callers go through the service (which owns cancellation and the audit events), never through `ctx.waterfall` directly. A listener that can answer for this request's agent returns an outcome WITHOUT calling `next()` (the decision slot is single-occupancy, first listener to answer wins); a listener that does not recognize the agent MUST call `next()` so another answerer — or the fail-closed default `'unavailable'` — gets the question. Throwing is contained by the service and yields `'unavailable'`. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `req.agent`: a listener registered through `agent.ctx` receives only that agent's questions, while a plain-context listener receives every agent's. `req` is the service's shallow-frozen acceptance snapshot: later caller mutation cannot redirect the question, while the `agent` and `signal` identity capabilities remain exact. ```ts cordis-catalog 'approval/request'(this: Scoped, req: ApprovalRequest, next: () => Promise): Promise @@ -187,7 +187,7 @@ Waterfall asking the composed answerers to decide one approval request. Dispatch Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) -Source: [`packages/ui/user-approval/src/index.ts:69`](../../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:72`](../../packages/ui/user-approval/src/index.ts) ## `fs/*` @@ -301,13 +301,13 @@ Source: [`packages/skill/skill/src/index.ts:136`](../../packages/skill/skill/src ### `subagent/end` — emit -A subagent run settled — emitted when SubagentRun.result resolves (any stop reason). Paired with Events['subagent/start']. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by the DELEGATING PARENT — a listener registered through the parent's `agent.ctx` observes only its own delegations; a plain plugin listener observes every run. +A started subagent run settled — emitted when SubagentRun.result resolves (any stop reason) or rejects (reported as `error`). Paired with Events['subagent/start']; a run whose readiness rejected emits neither event. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by the DELEGATING PARENT — a listener registered through the parent's `agent.ctx` observes only its own delegations; a plain plugin listener observes every run. ```ts cordis-catalog 'subagent/end'(this: Scoped, info: SubagentRunEndInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:109`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:114`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -331,13 +331,13 @@ Source: [`packages/subagent/subagent/src/index.ts:86`](../../packages/subagent/s ### `subagent/start` — emit -A subagent run started — emitted after the provider is resolved and its capabilities validated, as the child run begins. Paired with Events['subagent/end']. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by the DELEGATING PARENT — a listener registered through the parent's `agent.ctx` observes only its own delegations; a plain plugin listener observes every run. +A subagent run started — emitted only after SubagentRun.started fulfills, when the provider has established a live child. For an in-process provider, `ctx.agents.get(info.id)` is therefore guaranteed to resolve during this notification. A readiness rejection emits neither lifecycle event; every emitted start is paired with Events['subagent/end']. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by the DELEGATING PARENT — a listener registered through the parent's `agent.ctx` observes only its own delegations; a plain plugin listener observes every run. ```ts cordis-catalog 'subagent/start'(this: Scoped, info: SubagentRunInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:98`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:101`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index b331e53fe3..f3cf92f425 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -40,7 +40,7 @@ list(): Agent[] Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:168`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:169`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` @@ -54,7 +54,7 @@ async request(req: ApprovalRequest): Promise Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) -Source: [`packages/ui/user-approval/src/index.ts:287`](../../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:292`](../../packages/ui/user-approval/src/index.ts) ## `ctx.bash` — `BashExecutor` (abstract seam) @@ -246,7 +246,7 @@ list(): string[] start(name: string, request: SubagentStartRequest): SubagentRun ``` -Source: [`packages/subagent/subagent/src/index.ts:155`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:160`](../../packages/subagent/subagent/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 66525592cf..3de6b5861e 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -62,11 +62,12 @@ interface SubagentStopReasonMap { ## A live run: `SubagentRun` -The handle the consumer holds while a child executes. The consumer 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 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. ```ts type-equiv interface SubagentRun { readonly id: AgentId + readonly started: Promise readonly result: Promise cancel(reason?: string): void dispose(): Promise @@ -88,11 +89,11 @@ interface SubagentProvider { } ``` -The service (`ctx.subagents`) emits `subagent/start` when a run begins and `subagent/end` when it settles (see the [events catalog](../cordis-catalog/events.md)). `subagent/end` carries `lastAssistantMessage` (the child's final `output`) on the settle path, so an observer sees WHAT the subagent produced without holding the run (absent when the run rejected at the infrastructure level — no result was produced). These are **observe-only** events: both are plain `emit`s (the `subagent/end` fires from a detached `.then` after the result settles and awaits no listener), so a subscriber observes but cannot change the run. Both emits contain a thrown listener **per listener** (logged, never propagated): one bad subscriber can neither strand a live run, surface as an unhandled rejection on the detached settle hook, nor starve the listeners registered after it. +The service (`ctx.subagents`) emits `subagent/start` only after `run.started` fulfills and emits the paired `subagent/end` when that started run settles (see the [events catalog](../cordis-catalog/events.md)); a pre-publication readiness rejection emits neither event. For an in-process provider, a start listener can therefore resolve the live child with `ctx.agents.get(info.id)`; a remote provider need not publish into the local registry. `subagent/end` carries `lastAssistantMessage` (the child's final `output`) on the settle path, so an observer sees WHAT the subagent produced without holding the run (absent when the run rejected at the infrastructure level — no result was produced). These are **observe-only** events: both are plain `emit`s, so a subscriber observes but cannot change the run. Result settlement is observed immediately even while readiness is pending, then its cloned end payload is buffered until start has been announced; this prevents an early rejection from becoming unhandled while preserving start-before-end order and protecting the caller's result from listener mutation. Both emits contain a thrown listener **per listener** (logged, never propagated): one bad subscriber can neither strand a live run, surface as an unhandled rejection on the detached settle hook, nor starve the listeners registered after it. ## 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. Two pieces of vocabulary ride on the existing agent/session types rather than new core types: +The two in-process backends ([dsh-subagent-spawn](../../packages/subagent/subagent-spawn) fresh, [dsh-subagent-fork](../../packages/subagent/subagent-fork) seeded) run the child as a child `Agent` on the same application. They synchronously snapshot caller-owned data, install provider ownership before attaching the abort listener, create one run-owner fiber under `parent.ctx`, and invoke the factory through that fiber: parent teardown, provider teardown, and manual run disposal share the same pre-publication ownership and quiescence boundary, while the child still receives a flat new scope rather than inheriting the parent's 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: - **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`. - **Fork seeding** uses `CreateAgentOptions.seed` (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `resume` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/support/invariants) replay accepts it (the in-flight, unbalanced turn is excluded). diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index e62e11922f..ad741a7522 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -175,7 +175,7 @@ type PostToolDecision = | { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext } ``` -Call `next()` to delegate to the default (allow / dispatch / accept-unchanged), or return a decision/result to short-circuit. A `pre-execute` `deny` (or `ask`, which degrades to deny until the permission system lands) skips dispatch and yields an `isError` result; a registered `ToolGuard` runs after that waterfall and can impose a final denial. Input rewrite is deliberately NOT offered on `PreToolDecision` because it would desync the pre-execution audit/history/UI from what ran. A `post-execute` `accept` may replace the model-facing `content`; a `block` turns the call into an `isError` whose content is the corrective `feedback`. The awaited `tools/result` notification then receives the frozen execution identity and a deep-frozen result snapshot after every wrapper, post decision, and outer error catch; observers cannot transform the outcome or race each other through payload mutation, and one observer failure neither changes the result nor starves peers. An unregistered tool routes through the same catch as a tool-thrown error, so both failure classes get a structured `{ name, code }` (`ToolNotFoundError` → `UNKNOWN_TOOL`) — the loop records a failed tool call instead of failing the whole turn. +Call `next()` to delegate to the default (allow / dispatch / accept-unchanged), or return a decision/result to short-circuit. A `pre-execute` `deny` skips dispatch and yields an `isError` result. An `ask` resolves through the optional approval seam: only `allowed-once` proceeds, while every non-grant, missing channel/service, or agent-less request becomes a normalized denial. A registered `ToolGuard` then runs and can still impose a final denial. Input rewrite is deliberately NOT offered on `PreToolDecision` because it would desync the pre-execution audit/history/UI from what ran. A `post-execute` `accept` may replace the model-facing `content`; a `block` turns the call into an `isError` whose content is the corrective `feedback`. The awaited `tools/result` notification then receives the frozen execution identity and a deep-frozen result snapshot after every wrapper, post decision, and outer error catch; observers cannot transform the outcome or race each other through payload mutation, and one observer failure neither changes the result nor starves peers. An unregistered tool routes through the same catch as a tool-thrown error, so both failure classes get a structured `{ name, code }` (`ToolNotFoundError` → `UNKNOWN_TOOL`) — the loop records a failed tool call instead of failing the whole turn. ## The structured-output schema subset diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 36cad3e3ad..879c0ac2e4 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,20 +7,20 @@ 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:298`](../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:312`](../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:585`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:417`](../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:435`](../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:340`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:464`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:516`](../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:360`](../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:326`](../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:531`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:549`](../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:568`](../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:69`](../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:300`](../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:314`](../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:587`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:419`](../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:437`](../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:342`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:466`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:518`](../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:362`](../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:328`](../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:533`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:551`](../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:570`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`strictSerial (serial)`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:72`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | @@ -30,10 +30,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../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:109`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:114`](../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:75`](../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:86`](../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:98`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:101`](../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:176`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 7139ce261a..4bbe894338 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -23,7 +23,7 @@ An approval question was put to the answerer chain — log-only audit (like `hoo Types: [CallId](core-data-structures/core.md) -Source: [`packages/ui/user-approval/src/index.ts:83`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:86`](../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:94`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:97`](../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:106`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:109`](../packages/ui/user-approval/src/index.ts) ### `assistant/*` diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md index b92896c2b8..d0cdefaee8 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -206,6 +206,8 @@ The operation being described determines the key; callers cannot attach an unrel | `session/created`, `session/event`, `session/flush` | The owner scope captured when the session enters the store | | `subagent/start`, `subagent/end` | The delegating parent agent | +Approval requests cross an asynchronous answer boundary, so the service snapshots the accepted record synchronously. It preserves the exact agent and abort-signal identities but copies the scalar fields, captures the agent's session once, and uses that one snapshot for `approval/asked`, scoped dispatch, cancellation, policy, and `approval/decided`. Mutating the caller-owned record after `request()` returns therefore cannot split the audit pair or redirect the question to another agent's listeners. + The dispatch rule can be read independently of Cordis internals: ```text @@ -334,12 +336,11 @@ The sequence is not described as atomic because observers run between its steps. ### Teardown stops work before revoking its world -Every owner path uses the same reverse order: stop the loop and await its actual exit, remove the agent from the registry, detach the session, then unwind the scope. The final session events and durability flush therefore happen while the session and scoped listeners are still live. +Every owner path uses the same reverse order: stop the loop and await its actual exit plus every agent-started durability checkpoint, remove the agent from the registry, detach the session, then unwind the scope. Final turn events, the turn-ending flush, and any outstanding idle-injection flush therefore settle while the session and scoped listeners are still live. ```text disposeOwnedAgent(world): - world.stopDriver() - await world.agent.done # waits for any in-flight turn close and flush + await world.stopDriver() # waits for loop exit and all agent-started flushes world.detachAgent() # emits agent/disposed when announced world.detachSession() await world.scope.dispose() @@ -519,10 +520,14 @@ In-process subagents demonstrate how the scope, lifecycle, and final-policy piec ### Inputs and ownership are fixed before asynchronous creation +Provider registration first freezes an acceptance snapshot of the provider name, capability flags, parent-context descriptor, and `start` callback; the callback is bound to the original provider receiver so its intentional internal state stays live. Lookup, validation, model-facing wording, dispatch, lifecycle notifications, and HMR cleanup all use that snapshot. Mutating or reusing the caller's provider object later therefore cannot rename a live entry, change its advertised powers, replace its callback, or make its disposer delete the wrong key. + Starting a run snapshots every accepted field before asynchronous owner setup. The parent and abort signal are retained as identity capabilities but never reread from the mutable request record; tool filters, seed events, agent options, output schema, and prompt are detached. The schema is validated before cloning, while the prompt must pass the same lossless-JSON check before and after cloning that the session log requires. Later caller mutation therefore cannot change lifecycle scope, configuration, the schema enforced by the capture tool, or the prompt eventually logged and sent. The driver first installs provider ownership. Only after that succeeds does it attach the request's abort listener and create one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves neither a child nor an orphaned listener. The child factory runs through the owner fiber. Parent teardown, provider teardown, and manual run disposal all dispose this same node; moving it out of the active state synchronously prevents an unpublished setup from publishing afterward, while all three paths follow one quiescence promise. This structured ownership does not change the child's flat capability view. +The returned run separates acceptance from publication with `started: Promise`. For spawn and fork, it fulfills only after the child factory returns a published handle, so the service can emit `subagent/start` with `ctx.agents.get(run.id)` already live; it rejects when rollback prevents publication. The service observes `result` immediately but buffers its cloned end payload until readiness, preserving start-before-end order without leaving an early rejection unhandled. A readiness rejection emits neither lifecycle event. The result driver awaits the same boundary before sending the child prompt. + ```text startInProcessRun(providerContext, acceptedRequest): snapshot all request data, including parent identity @@ -535,11 +540,21 @@ startInProcessRun(providerContext, acceptedRequest): dispose providerLink await disposeRunOwner() - childHandle = await runOwner.ctx.agents.create({ + creation = runOwner.ctx.agents.create({ fresh ids and lineage, cloned options and optional seed, setup(childCtx) => install persona, tool restriction, structured runtime }) + + returnedRun.started = creation.then(childHandle => publication complete) + returnedRun.result = async: + await returnedRun.started + send the child prompt, await idle, derive the terminal result + +SubagentService.start(...): + attach result settlement handlers immediately + await returnedRun.started + emit subagent/start; later emit the buffered or eventual subagent/end ``` Parent teardown reaches `runOwner` by nesting; the provider and returned run handle reach the same node through their explicit disposers. @@ -687,7 +702,7 @@ The main benefit is one composition model across data, behavior, and lifetime: r - Plugin authors use the same registration APIs globally and per agent; only the context changes. - Registry-owned prompt schemas, executable lookup, Code Mode bindings, policy listeners, and UI presentation resolve from the same agent view. - Create and resume expose no partially configured registry entry during awaited setup. -- Agent disposal revokes scoped contributions after the driver and final session flush have settled. +- Agent disposal revokes scoped contributions after the driver and all final or idle-injection session flushes have settled. - Structured output composes per child without global mutation or listener-order assumptions. - Existing unscoped plugins remain deployment-wide contributors and observers. diff --git a/docs/rfc/implemented/feature/2026-06-15-code-mode.md b/docs/rfc/implemented/feature/2026-06-15-code-mode.md index f97e7abf50..be18e94ae0 100644 --- a/docs/rfc/implemented/feature/2026-06-15-code-mode.md +++ b/docs/rfc/implemented/feature/2026-06-15-code-mode.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -Today the agent loop advertises every registered tool to the model as a native JSON-schema function definition. `ToolRegistry` contributes its schemas to the system-prompt assembly, the assembly's `tools` land on the wire (and in the logged request header), the model invokes one `tool-call` block per step, and the loop dispatches each call through `ctx.tools.execute()` **sequentially** (parallel tool execution is an explicit open TODO in `dsh-tools` and [docs/architecture.md](../../../architecture.md)), with **every** intermediate `tool-result` re-entering the model's context on the next request. +In the registry's native presentation, the agent loop advertises every visible capability as a JSON-schema function definition. `ToolRegistry` contributes its schemas to the system-prompt assembly, the assembly's `tools` land on the wire (and in the logged request header), the model invokes one `tool-call` block per step, and the loop dispatches each call through `ctx.tools.execute()` **sequentially** (parallel tool execution is an explicit open TODO in `dsh-tools` and [docs/architecture.md](../../../architecture.md)), with **every** intermediate `tool-result` re-entering the model's context on the next request. For multi-step tool work this is token-heavy and serial. The model cannot compose tools — loop over a result set, branch on an intermediate value, fan out, post-process — without a full model round-trip per call, and each round-trip drags the entire intermediate result back into context whether the model needs it or not. @@ -37,14 +37,14 @@ Three decisions, each elaborated in its own section below: Under `'code'` and `'both'` the registry owns `run_code` as a reserved presentation transport with one required parameter, `{ code: string }`. It is represented by a normal `ToolDefinition` for dispatch but stays outside the filterable capability layers, so restrictions cannot accidentally remove Code Mode's only entry point. Calls traverse the complete tool pipeline — `tools/pre-execute` → monotonic guards → `tools/execute` around dispatch → `tools/post-execute` → immutable `tools/result` notification — exactly like native calls; a permission plugin can inspect the program text before it runs, and final-result observers see the normalized outer outcome. Its `execute(args, exec)`: 1. **Builds the bindings**: the bridge owns a **run-scoped `AbortController`** whose signal follows `exec.signal` (an outer cancel propagates in) and which the bridge itself fires the moment the run settles for any reason — completion, program exception, `computeMs`/`maxWallMs` expiry, worker exit. For every visible capability tool, the binding is an async function that (a) checks the run signal before and after, (b) **JSON-normalizes the argument** — a `JSON.parse(JSON.stringify(args))` round-trip, rejecting that one call with a descriptive `Error` when the value does not survive (`BigInt`, circular structures) — because the seam's structured-clone boundary is wider than JSON while the session log accepts only JSON, (c) awaits its turn on the **per-run serialization queue** (below), (d) calls `this.execute({ callId, name, arguments, agent: exec.agent, parent: exec.token, signal: runSignal })` with a deterministic sub-id `` CallId(`${exec.callId}:code:${n}`) ``, (e) appends a `tool/code-dispatch` session event, and (f) maps the result: success → the text-block contents joined as a `string` (non-text blocks become placeholders), `isError` → **the binding rejects** with an `Error` carrying the result text. The child's readonly `parent` is only the outer execution's frozen, property-free token, so commit-style observers can correlate outcomes without receiving a mutation path into the live `run_code` wrapper. Every sub-call still traverses the full pipeline under its own immutable identity and registry-assigned token. The run signal, rather than the bare outer one, lets budget expiry abort an in-flight sub-tool instead of orphaning it. Rejection gives programs ordinary `try/catch` and `Promise.all` failure semantics rather than a bespoke result envelope. -2. **Runs the program**: `ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: exec.signal })`. -3. **Surfaces the outcome — after reaching quiescence.** When `ctx.codeRuntime.run()` resolves, the bridge fires the run-scoped abort (cancelling any in-flight sub-dispatch and abandoning queued-unstarted ones), then **awaits the dispatch queue's drain before returning**, per the dispose-to-quiescence rule in [defensive patterns](../../../defensive-patterns.md): an aborted in-flight sub-call still settles and logs its `isError` `tool/code-dispatch` event *inside* the open turn, and nothing can append after `run_code` returns. A successful run then returns one text block — the captured console/stdout output followed by the rendered return value (if any) — plus a `meta` payload (capped logs, dispatch count) for presentation. A run with `result.error` throws a `CodeRunFailedError extends HarnessError` (`code: 'CODE_RUN_FAILED'`, message = the error kind and text plus captured logs so the model can self-correct); the registry's existing catch turns it into a structured `isError` result. +2. **Runs the program**: `ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`. The runtime receives the run-scoped signal, not only the caller's outer signal, so any way the outer run settles also aborts work inside the runtime. +3. **Surfaces the outcome — after reaching quiescence.** When `ctx.codeRuntime.run()` settles, whether by fulfillment or rejection, the bridge fires the run-scoped abort (cancelling any in-flight sub-dispatch and abandoning queued-unstarted ones), then **awaits the dispatch queue's drain before returning or propagating**, per the dispose-to-quiescence rule in [defensive patterns](../../../defensive-patterns.md): an aborted in-flight sub-call still settles and logs its `isError` `tool/code-dispatch` event *inside* the open turn, and nothing can append after `run_code` settles. A successful result then returns one text block — the captured console/stdout output followed by the rendered return value (if any) — plus a `meta` payload (capped logs, dispatch count) for presentation. A fulfilled run with `result.error` throws a `CodeRunFailedError extends HarnessError` (`code: 'CODE_RUN_FAILED'`, message = the error kind and text plus captured logs so the model can self-correct); a backend rejection propagates through the same registry error boundary. Both become structured `isError` tool results. **Sub-call `additionalContext` is suppressed, deliberately.** A `tools/post-execute` hook may attach `additionalContext` to a call; for loop-dispatched calls the loop buffers those and appends each as a `context/message` only after the step's `tool/result`s, preserving call/result adjacency. A sub-dispatch result's `additionalContext` has no such safe outlet from inside a running `run_code`: injecting immediately would land a `context/message` between the parent's `tool/call` and its `tool/result` (breaking the adjacency the buffering exists to protect), and `PostToolDecision.additionalContext` is singular where a program may produce many. The MVP therefore drops sub-call `additionalContext`, pinned by a test and stated in the hooks bridge's docs; the follow-up (a plural context channel or loop-level sub-dispatch buffering) is deferred until a real hook needs it through Code Mode. **Concurrency: serialized, enforced by the binding.** The bindings are async, so a model writing `Promise.all([tools.a(…), tools.b(…)])` starts both immediately — concurrent dispatch would be the default, while the tool contract carries no concurrency-safety metadata (the open parallel-execution TODO). Each `run_code` invocation therefore owns a dispatch queue and every binding call chains onto it, so even `Promise.all` executes the underlying `ctx.tools.execute()` calls one at a time in submission order; when the run settles, queued-but-unstarted dispatches are abandoned. Lifting this per tool remains tied to tools declaring themselves concurrency-safe. -**Presentation.** `run_code`'s render intent is decided here per the [render-intent RFC](../../implemented/architecture/2026-07-02-tool-render-intent-union.md): `presentCall` → a `generic` card, `kind: 'execute'`, title `Run code`, `rawInput` = the program text; `presentResult` → a `generic` card whose content is the captured output (from `meta`). Not a `terminal` card: that card's semantics are "a shell command in a working directory", which a program is not. +**Presentation.** `run_code`'s render intent is decided here per the [render-intent RFC](../../implemented/architecture/2026-07-02-tool-render-intent-union.md): `presentCall` → a `generic` card, `kind: 'execute'`, title = the program text, `rawInput` = the same program text; `presentResult` → a `generic` card whose content is the captured output (from `meta`). The program is the title because ACP execute cards reliably render that field while some clients omit body and raw-input content. This is not a `terminal` card: that card's semantics are "a shell command in a working directory", which a program is not. ### Observability: `tool/code-dispatch` @@ -56,7 +56,7 @@ Each sub-dispatch appends one session event, declared by `dsh-tools` via `Sessio - `CodeRunRequest = { program: string; bindings: CodeBindingNamespace[]; signal?: AbortSignal }` - `CodeBindingNamespace = { global: string; functions: Record Promise> }` — the runtime exposes each namespace as a global object of async functions inside the program; binding arguments and resolutions must be structured-cloneable (a runtime may cross a serialization boundary; ours does). -- `CodeRunResult = { value?: unknown; logs: CodeLogEntry[]; error?: CodeRunFailure }` — an error is a field on a resolved result, never a rejection of `run()`. +- `CodeRunResult = { value?: unknown; logs: CodeLogEntry[]; error?: CodeRunFailure }` — program execution outcomes, including exception, timeout, abort, and worker exit, resolve as the `error` field. `run()` may reject only for caller/seam misuse (for example a duplicate binding namespace); consumers still contain a non-conforming backend rejection at their own error boundary. - `CodeLogEntry = { source: 'console' | 'stdout' | 'stderr'; level?: 'log' | 'info' | 'warn' | 'error' | 'debug'; text: string }` - `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'; message: string }` — orthogonal outcomes reported independently per [defensive patterns](../../../defensive-patterns.md); a timed-out run is not an exception, an abort is not a timeout. - Two readonly backend descriptors, informational not gating: `language` (what the program must be written in — `'typescript'` for the shipped backend; a Python backend would say so, and pair with its own SDK generator on the presentation side) and `isolation` (`'worker-thread'` for the shipped backend; `'process'`, `'container'`, … for future ones). `dsh-tools` requires `language === 'typescript'` in the MVP — its codegen emits TS — and fails the assembly loudly otherwise, the same misconfiguration idiom as `toolOrder` violations (as when `mode` is non-native with no `ctx.codeRuntime` loaded at all). diff --git a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md index 28daff14ff..474abd2c67 100644 --- a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -37,7 +37,7 @@ A new package group `packages/subagent/`: ### The primitive: `start → SubagentRun` -A provider exposes `start(request) → SubagentRun`. The run carries a `result` promise (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 emits `subagent/start` / `subagent/end` around the run. +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. ### Two kinds of optional capability, discovered two ways diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md index fc3c8a9a93..4c9b89b8d0 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md +++ b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md @@ -26,9 +26,11 @@ Each bridge maps the neutral `MergedHookOutcome` from the shared lib onto the se | `tools/pre-execute` | `deny`→`deny`; `ask`→`ask` | `block`→`deny` (no allow/ask) | | `tools/post-execute` | `deny`→`block`+feedback; context-only→delegate+fold | same | | `agent/turn-continuation` | blocking Stop → `continue` (reason = next-step steering) | same | -| `subagent/start` (emit) | additionalContext → inject into the live child | — (not a Codex event) | +| `subagent/start` (emit) | additionalContext → inject into a live in-process child; a remote child has no local injection target | — (not a Codex event) | | `subagent/end` (emit) | observe-only | — | +The CC bridge's `ask` result is a real permission path, not a terminal bridge decision: `dsh-tools` resolves it through the optional [approval seam](2026-07-06-approval-seam.md). A composed ACP answerer prompts the owning editor session and `allowed-once` proceeds; without an ApprovalService or answerer, the call fails closed to `deny`. + ### Context source is always the plugin (the mislabel guard) `agent.inject()` defaults a missing `MessageSource` to `{ kind: 'user' }` — which would record plugin-injected context as if the user had typed it. So every bridge `inject()` and every `HookContext` passes an explicit `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }` source. A test asserts the resulting `context/message.source` is the plugin, never `user`. @@ -52,11 +54,10 @@ Two different cwds, kept distinct on purpose. The hooks **themselves** run in th ## Deferred (faithful-but-degraded) - **Tool-input rewrite.** A CC/Codex `updatedInput` is logged + warned, not honored — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite RFC](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)), because the pre-execution args are read by `tool/call` audit + `assistant/message` history + ACP/tool-bash presentation, so an honest rewrite is a design unit, not a field. -- **Stop loop-guard** (`TODO(stop-loop-guard)`). CC/Codex break an infinite force-continue with `stop_hook_active` (true once a Stop hook fired this run) plus a max-consecutive cap; both are deferred. Today `stop_hook_active` is always `false`, so a Stop hook that unconditionally blocks would force-continue every step — a hook author must self-limit until the guard lands. -- **Permission `ask`** — deferred at landing, since serviced: the [approval seam](2026-07-06-approval-seam.md) resolves `ask` through `ctx.approval` (ACP prompts over `session/request_permission`), degrading to `deny` only where no approval service is composed. +- **Stop loop-guard** (`TODO(stop-loop-guard)`). CC/Codex break an infinite force-continue with `stop_hook_active` (true once a Stop hook fired this run) plus a max-consecutive cap; both are deferred. `stop_hook_active` is always `false`, so a Stop hook that unconditionally blocks would force-continue every step — a hook author must self-limit until the guard lands. - **Hook `continue:false` (hard halt).** A hook can ask to halt the whole run (CC/Codex `continue:false`); the shared merge folds it into `MergedHookOutcome.stop`/`stopReason`, but no bridge acts on it (`TODO(hook-continue-false)`) — the interception seams have no "hard-halt the agent" primitive yet (a Decision blocks/steers a single point, not the run). Deferred with the loop-guard work; the halt request is recorded in the `hook/result` log, and the hook keeps its per-point effect (decision/context) meanwhile. - **Config discovery.** The path is explicit in `cordis.yml` and process-level (see above); the full multi-layer CC/Codex precedence walk, per-session project-local discovery, and the trust/hash model are not reimplemented (`TODO(per-session-hook-config)`). -- **Session-start / subagent-start context is best-effort, not gated (`TODO(session-start-gating)`).** `agent/session-start` is a synchronous emit and the bridge runs its hook on a detached `.then`, so the injected `additionalContext` is not guaranteed to land before the first turn reaches the model — a slow hook can miss the first request (the context then arrives as a later injection). `subagent/start` is sharper: an in-process provider may have already queued the child's prompt before the listener runs, and a short-lived child can finish before the detached inject fires. Making startup context a gated/awaited primitive is a loop-level change deferred to the interception seams; today the contract is "injected as soon as the hook resolves", not "before the first request". The bridge tests do NOT wait on the injection where they assert the guaranteed-timing behavior, so they document the real (best-effort) timing rather than masking it. +- **Session-start / subagent-start context is best-effort, not gated (`TODO(session-start-gating)`).** `agent/session-start` is a synchronous emit and the bridge runs its hook on a detached `.then`, so the injected `additionalContext` is not guaranteed to land before the first turn reaches the model — a slow hook can miss the first request (the context then arrives as a later injection). `subagent/start` is emitted only after child publication, so the bridge can capture the live in-process child synchronously, but the result driver may queue the prompt as that same readiness boundary resolves and a short-lived child can finish before the detached hook injects. Making startup context a gated/awaited primitive is a loop-level change deferred to the interception seams; the contract is "injected as soon as the hook resolves", not "before the first request". The bridge tests do NOT wait on the injection where they assert the guaranteed-timing behavior, so they document the real (best-effort) timing rather than masking it. ## Alternatives considered diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md index 111b5d833f..ba372b98b2 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md @@ -22,7 +22,7 @@ The canonical surface separates transformable policy, around-dispatch control, a 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. -- **`tools/pre-execute`** is the extensible waterfall gate. Its `PreToolDecision` allows, denies, or asks; deny/ask skips `tools/execute` and core dispatch but still produces a normalized result for post-policy and final observers. +- **`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. @@ -46,7 +46,7 @@ Core dispatch and the tool body sit inside normalization boundaries, so tool, li ### Boundaries -The seam package does **not** declare `hook/*` session events (the durable hook-invocation log); those belong to `dsh-hook-protocol`, because a native plugin uses typed decisions without an external hook log. The native-plugin integration test (`packages/core/agent-loop/tests/interception.spec.ts`) composes the seams through the real loop with no `hook/*` protocol. Compaction (`PreCompact`/`PostCompact`), Notification, and Codex `PermissionRequest` remain outside this decision. The permission/`ask` system has since landed as the [approval seam](2026-07-06-approval-seam.md), whose `ctx.approval` services the `ask` this RFC originally shipped degraded to deny; terminal monotonic stopping is now provided separately by `agent/turn-stop`. +The seam package does **not** declare `hook/*` session events (the durable hook-invocation log); those belong to `dsh-hook-protocol`, because a native plugin uses typed decisions without an external hook log. The native-plugin integration test (`packages/core/agent-loop/tests/interception.spec.ts`) composes the seams through the real loop with no `hook/*` protocol. Compaction (`PreCompact`/`PostCompact`), Notification, and Codex `PermissionRequest` remain outside this decision. The [approval seam](2026-07-06-approval-seam.md) resolves `ask` decisions through `ctx.approval`, while terminal monotonic stopping is owned separately by `agent/turn-stop`. ## Alternatives considered diff --git a/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md index f3b1c9bfb6..17730458de 100644 --- a/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md +++ b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md @@ -12,7 +12,7 @@ This RFC enriches the end payload. It is deliberately **observe-only**: no contr **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`. -Both events stay plain **`emit`s**. `subagent/end` fires from a detached `.then` on `run.result` and awaits no listener, so it is genuinely observe-only by construction — a `subagent/start` listener can still reach the live child via `ctx.agents.get(info.id)` and `inject()` into it; a `subagent/end` listener can only observe (the run has settled). Per-listener containment (already in place) keeps one bad subscriber from stranding a live run or surfacing as an unhandled rejection on the detached settle hook. +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. ## Alternatives considered diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md index eddb3ba733..1257554d30 100644 --- a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -Two callers need to put one question — "may this specific action proceed?" — to a human, and neither has a channel. `tools/pre-execute`'s `ask` decision (produced today by the Claude-Code hook bridge's `permissionDecision: ask`) degrades to deny because nothing services it. The [sandbox RFC](2026-07-06-sandbox.md)'s escalation phase needs the same channel for its post-denial one-shot retry. Without a shared seam, each would invent its own outcome vocabulary, UI routing, cancellation, and audit trail — and a deployment with no UI at all needs a guarantee that an unanswerable question can never grant anything. +Two callers need to put one question — "may this specific action proceed?" — to a human: `tools/pre-execute`'s `ask` decision (including the Claude-Code hook bridge's `permissionDecision: ask`) and the [sandbox RFC](2026-07-06-sandbox.md)'s post-denial one-shot escalation retry. A shared seam keeps them from inventing separate outcome vocabularies, UI routing, cancellation, and audit trails, while guaranteeing that a deployment with no UI can never grant an unanswerable request. The routing problem is ownership: an approval prompt must reach the editor session that owns the asking agent (the ACP bridge multiplexes N sessions over one connection), fail closed for agents nobody owns (in-process subagents, tests), and stay out of deployments that compose no UI (headless, CI). @@ -14,7 +14,7 @@ One package, `dsh-user-approval` (`packages/ui/user-approval`), owning the vocab ### How a deployment uses it -One `cordis.yml` entry mounts the seam; not loading it is the opt-out — consumers degrade to their historical fail-closed behavior with zero approval code registered: +One `cordis.yml` entry mounts the seam. Not loading it is the fail-closed opt-out: consumers deny unanswerable requests with zero approval code registered. ```yaml - id: approval @@ -49,29 +49,29 @@ The `escalation-rejected` twin ends in `{"outcome": "rejected"}` instead: nothin #### The seam: mechanism and policy split -`ApprovalService.request(req)` always resolves to a closed `ApprovalOutcome` — `allowed-once` / `rejected` / `cancelled` / `unavailable` — and never rejects. The service is the mechanism: it dispatches the `approval/request` waterfall, races the request's `AbortSignal` (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 requesting agent's session log. 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. The one precondition: `request()` 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 shipped ask path runs mid-turn already, and idle asks are a deferred design. +`ApprovalService.request(req)` always resolves to a closed `ApprovalOutcome` — `allowed-once` / `rejected` / `cancelled` / `unavailable` — and never rejects. The service synchronously snapshots and shallow-freezes the accepted request before its first asynchronous boundary: scalar fields are copied while the agent and `AbortSignal` remain exact identity capabilities, so later caller mutation cannot redirect scope, payload, cancellation, or either audit event. The service dispatches the `approval/request` waterfall, races the captured signal (abort settles `cancelled`; a late answer is discarded, never double-audited), contains a throwing answerer as `unavailable`, normalizes a rogue non-vocabulary return to `unavailable`, and lands the log-only audit pair `approval/asked`/`approval/decided` (paired by the branded `ApprovalRequestId`) on the captured agent's captured session log. A session observer runs after an event enters the append-only log; if one throws, the service recognizes the recorded event, contains the callback failure, and completes the pair. 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. The one precondition: `request()` 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 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 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. #### Ask routing in dsh-tools -`ToolRegistry.execute()` resolves an `ask` decision through the seam before the shared deny path: `allowed-once` proceeds to dispatch, and the three non-grants deny with distinct reasons — "the user rejected…", "…was cancelled", "…no approval channel is available" — so the model can tell a human "no" from an absent channel. The seam is consumed opportunistically (`ctx.get('approval')`, the `tool-bash`/`agent-loop` pattern), not statically injected: a deployment that composes no ApprovalService keeps the historical ask→deny degrade verbatim, an unmount mid-session degrades the same way on the next ask, and the registry's fiber never gates on the seam's presence. An agent-less execution also degrades — without an agent there is no session to audit to and no UI to route to. +`ToolRegistry.execute()` resolves an `ask` decision through the seam before the shared deny path: `allowed-once` proceeds to guards and dispatch, and the three non-grants deny with distinct reasons — "the user rejected…", "…was cancelled", "…no approval channel is available" — so the model can tell a human "no" from an absent channel. The seam is consumed opportunistically (`ctx.get('approval')`, the `tool-bash`/`agent-loop` pattern), not statically injected: with no ApprovalService, or after one unmounts, the next ask fails closed without gating the registry's fiber. An agent-less execution also fails closed — without an agent there is no session to audit to and no UI to route to. #### The per-session policy tier -The seam also owns the session-scoped approval policy — the approval knob of the two-knob per-session switching design ([the sandbox RFC](2026-07-06-sandbox.md) § Per-session modes is the pattern's home: one log-only event per knob, a pure fold, THE write path, ACP config-option advertisement, and turn-anchoring). `ApprovalPolicy` is `'ask' | 'never'`, and `effectiveApprovalPolicy(events) ?? Config.policy` (default `'ask'`) decides every request BEFORE any interactive answerer: the service resolves a `'never'` session to `'rejected'` INSIDE `request()`, before dispatching the waterfall at all — no listener registration, including a later `prepend`, can sit ahead of it — while `'ask'` dispatches unchanged (fail-closed `'unavailable'` with nobody composed, exactly the prior behavior). Visibility follows the switching design's two layers with one asymmetry: the prompt section states ONLY `'never'` (deterministic, availability-independent — "you will be prompted" would overclaim in a composition with no answerer, and absence under a logged header is exactly how the narrator reads `'ask'` back), the narrator injects at most one coalesced notice per switch, and the audit pair still lands on every ask, including the policy's auto-rejections. +The seam also owns the session-scoped approval policy — the approval knob of the two-knob per-session switching design ([the sandbox RFC](2026-07-06-sandbox.md) § Per-session modes is the pattern's home: one log-only event per knob, a pure fold, THE write path, ACP config-option advertisement, and turn-anchoring). `ApprovalPolicy` is `'ask' | 'never'`, and `effectiveApprovalPolicy(events) ?? Config.policy` (default `'ask'`) decides every request BEFORE any interactive answerer: the service resolves a `'never'` session to `'rejected'` INSIDE `request()`, before dispatching the waterfall at all — no listener registration, including a later `prepend`, can sit ahead of it — while `'ask'` dispatches unchanged and falls through to fail-closed `'unavailable'` when nobody answers. Visibility follows the switching design's two layers with one asymmetry: the prompt section states ONLY `'never'` (deterministic, availability-independent — "you will be prompted" would overclaim in a composition with no answerer, and absence under a logged header is exactly how the narrator reads `'ask'` back), the narrator injects at most one coalesced notice per switch, and the audit pair still lands on every ask, including the policy's auto-rejections. #### The ACP answerer The bridge registers the first real answerer: it resolves the owning session through its existing `WeakMap` reverse map, issues `session/request_permission` with the request's `callId` as the `toolCall` reference and the one-shot options `allow_once`/`reject_once`, and maps the response — selected `allow-once` → `allowed-once`, any other selection → `rejected` (an unknown optionId from a non-conforming client never grants), client `cancelled` → `cancelled`. A request for a foreign agent — or one without a `callId`, since the protocol prompt must attach to a tool call — delegates via `next()`. A rejected RPC (client gone mid-prompt) propagates to the service, which contains it as `unavailable`. Whether a call ASKS at all is policy — a hook or `tools/pre-execute` plugin returning `ask` — never the bridge's own judgment. -The reverse-map ownership seam [the ACP support RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md) laid down is exactly what the answerer routes through, and per-session permission ownership (the blocker recorded in [the multi-session RFC](../../implemented/feature/2026-06-14-acp-multi-session.md)) is what it implements. +The answerer routes through the bridge's reverse-map ownership seam described by [the ACP support RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md), implementing the per-session permission ownership required by [the multi-session RFC](../../implemented/feature/2026-06-14-acp-multi-session.md). #### Audit, and what the model sees -`approval/asked` / `approval/decided` are log-only session events (the `hook/invoked`/`hook/result` precedent): durable, replayable, never in the model transcript. The model's entire view of an approval is the tool result the asker derives from the outcome — reconstructability holds because that result is an ordinary logged `tool/result`. One `decided` per `asked`, whatever the outcome, including an already-aborted signal (settled `cancelled` without dispatching) and a contained answerer failure. +`approval/asked` / `approval/decided` are log-only session events (the `hook/invoked`/`hook/result` precedent): durable, replayable, never in the model transcript. The model's entire view of an approval is the tool result the asker derives from the outcome — reconstructability holds because that result is an ordinary logged `tool/result`. One `decided` lands per `asked`, whatever the outcome, including an already-aborted signal (settled `cancelled` without dispatching), a contained answerer failure, or a session observer that throws after either event is already appended. #### Entities and dependencies @@ -79,35 +79,35 @@ 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) 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), 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`. 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). ## Deferred - **`allow_always` grant storage** — honoring a persistent grant means designing storage, scope identity (call? path? prefix? session? time window?), and revocation; until designed, only the one-shot options are advertised ([the sandbox RFC](2026-07-06-sandbox.md) § Escalation records the open scope question). -- **A recorded hook-driven `ask` scenario** — the wire is recorded via the sandbox example's escalation branches; the hook-producer variant stays on the unit tier and the hook matrix's `hook-cc-pretool-ask`, with its deny texts pinned verbatim there. -- **Routing a child agent's approvals to the parent session** — `subagent-acp`'s child today auto-answers its own `permission` requests; surfacing them to the parent's editor is its own design. +- **A recorded hook-driven `ask` through a composed answerer** — the human-prompt wire is recorded through the sandbox example's escalation branches. The hook matrix's `hook-cc-pretool-ask` pins the no-ApprovalService fallback denial, while the hook-producer-plus-answerer composition remains on the unit tier. +- **Routing a child agent's approvals to the parent session** — `subagent-acp`'s child auto-answers its own `permission` requests; surfacing them to the parent's editor is its own design. ## Alternatives considered - **A single registered provider instead of waterfall listeners** — rejected: a `registerProvider()` surface forces every composition question — allowlist pre-filters, external hook deciders, scripted test answers, a policy gate in front of a human — inside one provider implementation. The waterfall gets composition, fail-closed absence, and HMR disposal from machinery the runtime already has; the seam's JSDoc pins the single-decision-slot convention instead of inventing a provider registry. -- **[The ACP support RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md)'s inline `tools/pre-execute` permission gate** — rejected, and superseded by this seam: prompting for every bridge-owned call hardwires the asking POLICY into the UI plugin, cannot serve a second asker (sandbox escalation happens after execution starts, with no pre-execute moment), and leaves hooks' `ask` — the vocabulary the interception seams already ship — unserviced. -- **A generic user-interaction seam (`ctx.userInteraction`) instead** — rejected: the two share a skeleton (route by agent, block for a human, handle absence), but approval's contract is narrower in every dimension that matters: a closed outcome vocabulary instead of free text, a protocol-native prompt attached to a tool call instead of a generic form, mandatory fail-closed absence, and audit events. The generic seam has since shipped (`packages/ui/user-interaction`, the `ask_user_question` tool over ACP elicitation) and approval deliberately still does not ride it — an elicitation form is not a permission prompt, and a free-text answer is not a closed outcome; sharing provider plumbing stays open if the two ever converge. +- **An inline `tools/pre-execute` permission gate in the ACP bridge** — rejected: prompting for every bridge-owned call hardwires the asking POLICY into the UI plugin, cannot serve a second asker (sandbox escalation happens after execution starts, with no pre-execute moment), and leaves hook-produced `ask` decisions without a shared mechanism. +- **The generic user-interaction seam (`ctx.userInteraction`)** — rejected as the approval mechanism: the two share a skeleton (route by agent, block for a human, handle absence), but approval's contract is narrower in every dimension that matters: a closed outcome vocabulary instead of free text, a protocol-native prompt attached to a tool call instead of a generic form, mandatory fail-closed absence, and audit events. Approval therefore does not ride the shipped `packages/ui/user-interaction` / `ask_user_question` elicitation path — an elicitation form is not a permission prompt, and a free-text answer is not a closed outcome; sharing provider plumbing stays open if the two ever converge. - **Static optional injection in `dsh-tools`** — rejected: the vendored cordis `Inject` type has no optional flag — the object form maps service names to intercept config, and a declared inject gates the fiber. `ctx.get('approval')` is the documented opportunistic-consumption pattern (the `tool-bash` owner-token lookup, the loop's persistence probe), reads presence per call, and degrades correctly across HMR without extra machinery. - **The capability-seam three-package split** — rejected: interface/implementation/consumer fits a seam whose implementation is swappable (bash-local vs bash-sandbox). Here the service body is fixed mechanism and the variable part is listeners that live with their owners — splitting would manufacture an implementation package with nothing in it ("don't split preemptively"). - **Offering `allow_always` now** — rejected: the protocol can express it, but honoring it means designing grant storage, scope identity, and revocation (§ Deferred). Advertising an option the harness cannot honor manufactures doomed grants. ## Consequences -What shipped pins — the suites in Testing hold each: +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 (degrade, verbatim historical text), no listener, a foreign or agent-less request, a throwing answerer, a rogue return value, a dead client connection. -- Every `request()` lands exactly one `approval/asked`/`approval/decided` pair on the asking agent's log, replayable, invisible to the model transcript. +- 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. - Prompts route per-session through the bridge's ownership map; one session's prompt can never reach another session's editor. -- A deployment that composes nothing new behaves byte-identically (the snapshot suite's goldens are unchanged). +- A deployment with no ApprovalService emits no approval prompt or approval audit events and denies every `ask` request. Costs and accepted limits: @@ -125,7 +125,7 @@ Behavioral and usage questions only — every "why not X?" design question lives - **Who decides whether a call asks in the first place?** Policy producers: a hook returning `permissionDecision: ask`, any `tools/pre-execute` listener, or the sandbox escalation gate. The seam and the bridge only route and answer; neither injects its own judgment about what deserves a prompt. - **What happens when the user dismisses the prompt, or the turn aborts mid-ask?** Dismissal maps to `cancelled` with its own deny text. An already-aborted signal settles `cancelled` without dispatching; an abort during the ask discards the late answer — one audit pair either way, never two. - **What if the client answers with an option the harness never offered?** Any selection other than the offered `allow_once` maps to `rejected` — an unknown optionId from a non-conforming client can never grant. -- **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are unanswerable today by design. `subagent-acp`'s child-side auto-answer is untouched; routing a child's asks to the parent's editor is deferred (§ Deferred). +- **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent's editor is deferred (§ Deferred). - **What does `policy: 'never'` actually change at runtime?** The service resolves every ask for that session to `rejected` before dispatching any answerer (in-service, so no registration order can bypass it); the system prompt states the policy; switches are narrated at boundaries; the audit pair still lands for every auto-rejection. - **What happens across a hot reload, or when the UI plugin unloads mid-session?** Answerers dispose with their owning fiber, so the next ask degrades to `unavailable` instead of hanging on a dead channel; remounting re-registers the answerer with no catch-up state. - **Where does the user see what they are approving?** On the tool call itself: the prompt attaches to the already-streamed call via `callId` — arguments included — and adds the asker's human-readable `reason`; the request carries no argument copy of its own. diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 57390710f8..e56f675bb2 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -381,7 +381,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'subagent/end', mode: 'emit', signature: '\'subagent/end\'(this: Scoped, info: SubagentRunEndInfo): void', - summary: 'A subagent run settled — emitted when SubagentRun.result resolves (any stop reason).', + summary: 'A started subagent run settled — emitted when SubagentRun.result resolves (any stop reason) or rejects (reported as `error`).', }, { name: 'subagent/provider-added', @@ -399,7 +399,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'subagent/start', mode: 'emit', signature: '\'subagent/start\'(this: Scoped, info: SubagentRunInfo): void', - summary: 'A subagent run started — emitted after the provider is resolved and its capabilities validated, as the child run begins.', + summary: 'A subagent run started — emitted only after SubagentRun.started fulfills, when the provider has established a live child.', }, { name: 'system-prompt/assemble', @@ -869,7 +869,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentRun', - declaration: 'export interface SubagentRun {\n readonly id: AgentId;\n readonly result: Promise;\n cancel(reason?: string): void;\n dispose(): Promise;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): SubagentRun;\n}', + declaration: 'export interface SubagentRun {\n readonly id: AgentId;\n readonly started: Promise;\n readonly result: Promise;\n cancel(reason?: string): void;\n dispose(): Promise;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): SubagentRun;\n}', }, { name: 'SubagentStartRequest', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 2909ba6c37..217ba0a643 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -8,7 +8,7 @@ This is the only package in the harness that contains concrete loop logic. Every ### Public API -Lifecycle (scoped): programmatic creation and resume snapshot caller-owned identity/configuration data, reserve both IDs, mint `agent.ctx`, and install the ordered teardown skeleton before awaiting optional `setup`. Resume installs an owner-liveness sentinel before persistence load, then hands ownership directly to the full lifecycle. After setup resolves, the factory checks its lifecycle flag, owner-fiber state, and owning agent status around one microtask checkpoint so a same-turn Cordis unload wins before publication. Successful setup inserts both session and agent before announcing either, enables driving immediately before `agent/session-start`, then starts the loop. Setup calls to `send`/`steer`/`inject`/`cancel` reject structurally; load/setup rejection or owner unload publishes nothing. Teardown runs stop/drain → unregister → detach session → unwind scope. All `agent/*` dispatches go through `agentEvents(ctx, agent)`; per-step assembly through `assembleContextFor(agent)`; the turn-end durability checkpoint through `ctx.sessions.flush(session)`. +Lifecycle (scoped): programmatic creation and resume snapshot caller-owned identity/configuration data, reserve both IDs, mint `agent.ctx`, and install the ordered teardown skeleton before awaiting optional `setup`. Resume installs an owner-liveness sentinel before persistence load, then hands ownership directly to the full lifecycle. After setup resolves, the factory checks its lifecycle flag, owner-fiber state, and owning agent status around one microtask checkpoint so a same-turn Cordis unload wins before publication. Successful setup inserts both session and agent before announcing either, enables driving immediately before `agent/session-start`, then starts the loop. Setup calls to `send`/`steer`/`inject`/`cancel` reject structurally; load/setup rejection or owner unload publishes nothing. Teardown runs stop/drain (including outstanding idle-injection flushes) → unregister → detach session → unwind scope. All `agent/*` dispatches go through `agentEvents(ctx, agent)`; per-step assembly through `assembleContextFor(agent)`; the turn-end durability checkpoint through `ctx.sessions.flush(session)`. - `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — config-driven create: an agent on a fresh per-run session id `${id}-session-` with optional session metadata. Used for `cordis.yml`-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber. diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index fb00f4ad76..c4c35f34db 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -31,8 +31,12 @@ export interface PreparedReactLoopAgent { agent: ReactLoopAgent /** Open its driving verbs at the rollback-covered publication boundary. */ enableDrive(): void - /** Start its driver after publication and session-start notification. */ - startDriver(): () => void + /** + * Start its driver after publication and session-start notification. + * The returned disposer reaches quiescence for both the loop and every + * fire-and-forget idle-injection flush the agent started. + */ + startDriver(): () => Promise } /** @@ -126,6 +130,12 @@ export class ReactLoopAgent implements Agent { * the `disposed` transition fires and leave the promise hanging. */ private idleWaiters: (() => void)[] = [] + /** + * Durability checkpoints started by idle {@link inject} calls. `inject()` is + * synchronous, so it cannot await them itself; the driver disposer drains + * this set before the lifecycle unregisters the agent or detaches its session. + */ + private pendingIdleFlushes = new Set>() constructor( private loopCtx: Context, @@ -249,14 +259,16 @@ export class ReactLoopAgent implements Agent { // will flush this turn. Fire-and-forget with error containment: inject() // is synchronous, and a persistence backend failing must not throw into // the caller (e.g. a tool-bash task-done callback). Disposal still drains - // independently, so a slow flush is safe. A flush failure is reported via - // agent/error (step 0 — the idle-injection convention, there is no real - // step) AND the logger, mirroring the loop's post-turn/end flush path so - // plugins monitoring agent/error see idle-injection persistence failures - // too. A throwing agent/error listener is contained. + // independently, so a slow flush is safe. The task is tracked until it + // settles: driver disposal awaits every pending idle-injection checkpoint + // before unregistering the agent or detaching the session. A flush failure + // is reported via agent/error (step 0 — the idle-injection convention, + // there is no real step) AND the logger, mirroring the loop's post-turn/end + // flush path so plugins monitoring agent/error see idle-injection + // persistence failures too. A throwing agent/error listener is contained. if (turnRecorded) { // Through the store's flush (the carrier owner), never a raw parallel. - void this.loopCtx.sessions.flush(this.session).catch((error: unknown) => { + const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => { const err = error instanceof Error ? error : new Error(String(error)) this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${err.message}`) try { @@ -266,6 +278,13 @@ export class ReactLoopAgent implements Agent { // listener must not escape this fire-and-forget catch. } }) + this.pendingIdleFlushes.add(flush) + // Attach the same retirement callback to both settlement arms so even a + // logger failure in the catch above cannot become an unhandled rejection. + // Teardown uses allSettled for the same reason: a reporting failure must + // not strand ownership. + const retire = (): void => { this.pendingIdleFlushes.delete(flush) } + void flush.then(retire, retire) } } } @@ -310,8 +329,8 @@ export class ReactLoopAgent implements Agent { * fully ended) or chaining {@link done} on `disposed` (wait for the loop to * actually exit). Implements the {@link Agent.whenIdle} contract: a non-owner * quiescence-observation hook, distinct from teardown (a lifecycle owner stops - * and unregisters via `AgentHandle.dispose()`, which awaits {@link done} - * directly, not through this). + * and unregisters via `AgentHandle.dispose()`, whose driver boundary awaits + * both {@link done} and outstanding idle-injection flushes, not through this). */ whenIdle(): Promise { if (this._status === 'disposed') return this.done @@ -334,12 +353,14 @@ export class ReactLoopAgent implements Agent { * Start the driver loop. Returns a disposer: calling it sets status to * `disposed`, emits `agent/status('disposed')`, resolves the disposed * promise (unblocking the idle wait), releases any `whenIdle` waiters, and - * aborts the current request if any. The returned `agent.done` promise - * resolves once the loop exits. - * @returns the disposer — idempotent and infallible (it runs inside the - * fiber's LIFO disposal chain, where a throw would skip later disposers). + * aborts the current request if any. Its returned promise resolves only after + * the loop exits and every idle-injection flush started by this agent settles. + * @returns the disposer — idempotent, synchronously marks the agent disposed, + * and asynchronously reaches loop + flush quiescence without rejecting (it + * runs inside the fiber's LIFO disposal chain, where a rejection would skip + * later disposers). */ - [startDriver](): () => void { + [startDriver](): () => Promise { this.done = runLoop(this.loopCtx, this, { inbox: this.#inbox, setStatus: (status) => { this.setStatus(status) }, @@ -360,22 +381,35 @@ export class ReactLoopAgent implements Agent { // The disposer must be infallible: it runs inside the fiber's LIFO // disposal chain, where a throw would skip later disposers (e.g. the // registry unregistration) and leave `done` pending forever. - return () => { - if (this._status === 'disposed') return - this._status = 'disposed' - this.resolveDisposed() - // Release whenIdle waiters BEFORE the (guarded) event emit — they are - // internal state that must settle even if a listener throws below. Each - // waiter chains `done`, so it resolves only once the loop actually exits. - this.settleIdleWaiters() - this.currentAbort?.abort('disposed') - // setStatus refuses transitions out of 'disposed', so emit directly — - // 'disposed' is part of the agent/status contract. Guarded: a throwing - // listener must not break the disposal chain. - try { - this.loopCtx.emit(this.carrier, 'agent/status', this, 'disposed') - } catch { - // listener error during disposal — nothing safe left to do with it + return async () => { + if (this._status !== 'disposed') { + this._status = 'disposed' + this.resolveDisposed() + // Release whenIdle waiters BEFORE the (guarded) event emit — they are + // internal state that must settle even if a listener throws below. Each + // waiter chains `done`, so it resolves only once the loop actually exits. + this.settleIdleWaiters() + this.currentAbort?.abort('disposed') + // setStatus refuses transitions out of 'disposed', so emit directly — + // 'disposed' is part of the agent/status contract. Guarded: a throwing + // listener must not break the disposal chain. + try { + this.loopCtx.emit(this.carrier, 'agent/status', this, 'disposed') + } catch { + // listener error during disposal — nothing safe left to do with it + } + } + // An unexpected driver rejection must not skip registry/session/scope + // cleanup. The normal loop contains turn failures itself; allSettled is the + // final lifecycle backstop for anything outside those boundaries. + await Promise.allSettled([this.done]) + // No new inject() can start after the synchronous disposed transition. + // Loop because settled tasks retire themselves in promise reactions that + // may run beside this continuation; either the set is empty or this waits + // the exact remaining quiescence boundary. allSettled keeps a failure in + // error reporting from skipping the registry/session/scope disposers. + while (this.pendingIdleFlushes.size > 0) { + await Promise.allSettled([...this.pendingIdleFlushes]) } } } diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 0b8879c2b7..bd5b7e3188 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -375,7 +375,7 @@ export class AgentLoop extends Service implements AgentFactory { let active = true let detachSession: (() => void) | undefined let detachAgent: (() => void) | undefined - let stop: (() => void) | undefined + let stop: (() => Promise) | undefined const { promise: deactivated, resolve: markDeactivated } = Promise.withResolvers() const { promise: torndown, resolve: markTorndown } = Promise.withResolvers() @@ -401,8 +401,7 @@ export class AgentLoop extends Service implements AgentFactory { active = false markDeactivated() if (stop === undefined) return - stop() - return agent.done + return stop() } }, 'agentLoop.lifecycle()') @@ -461,8 +460,9 @@ export class AgentLoop extends Service implements AgentFactory { /** * Build an {@link AgentHandle} for a PREPARED session + a fresh agent. The * handle's `dispose()` runs the composite effect's disposer (see - * {@link start}) — which stops the loop, awaits its exit (final flush - * captured), unregisters the agent, and detaches the session, in that order. + * {@link start}) — which stops the loop, awaits its exit and outstanding + * idle-injection flushes, unregisters the agent, and detaches the session, in + * that order. * The same composite effect is what a fiber unload disposes, so both teardown * triggers honor the ordering identically. * @@ -470,8 +470,8 @@ export class AgentLoop extends Service implements AgentFactory { * single-shot (a second call returns immediately because the effect's epoch is * already cleared, NOT awaiting the in-flight teardown), so concurrent/repeated * `dispose()` calls would otherwise resolve before the first call's - * `await agent.done` + final flush completed. Memoizing the promise makes every - * caller observe the SAME quiescence boundary, honoring the + * loop + flush quiescence boundary completed. Memoizing the promise makes + * every caller observe that SAME boundary, honoring the * `AgentHandle.dispose(): Promise` contract (mirrors the ACP `quiesce()` * helper). */ diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index b49c38f5da..8c30e42257 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -489,7 +489,7 @@ async function runTurn( // against the system prompt (it counts toward the budget). runStep reuses // this same assembly for the request, so the prompt is assembled once per // step. renderPrompt IS the full prompt — the persona is the order-0 - // section (registered by the AgentLoop plugin) and `{{variable}}` + // section (owned by dsh-system-prompt) and `{{variable}}` // interpolation happens in the render, so there is no separate join. const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent)) const fullSystemPrompt = renderPrompt(assembly) diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index d4f9d1e3d4..ab3494d991 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -242,11 +242,12 @@ describe('ReactLoopAgent', () => { const dispose = prepared.startDriver() // First dispose - dispose() + const firstDisposal = dispose() expect(agent.status).toBe('disposed') + await firstDisposal // Second dispose — idempotent, no throw - expect(() => { dispose() }).not.toThrow() + await expect(dispose()).resolves.toBeUndefined() expect(agent.status).toBe('disposed') }) @@ -347,10 +348,10 @@ describe('ReactLoopAgent', () => { expect(agent.status).toBe('running') const idle = agent.whenIdle() // queues an internal waiter (running) - dispose() // settles the waiter synchronously; whenIdle chains done + const disposal = dispose() // settles the waiter synchronously; whenIdle chains done await idle expect(agent.status).toBe('disposed') - await agent.done + await disposal }) it('whenIdle() subscribed while running survives a FIBER dispose (no hung promise)', async () => { diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 085629db64..2bf58be832 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -440,4 +440,35 @@ describe('agent scope lifecycle', () => { expect(ctx.sessions.get(SessionId('h1-s'))).toBeUndefined() await unload }) + + it('handle.dispose() awaits an idle-injection flush before unregistering or detaching', async () => { + const ctx = await harness() + const handle = await ctx.agents.create({ + agentId: AgentId('idle-flush'), + sessionId: SessionId('idle-flush-s'), + agentOptions: { model: 'mock' }, + }) + const gate = Promise.withResolvers() + let flushStarted = false + ctx.on('session/flush', (session) => { + if (session !== handle.agent.session) return + flushStarted = true + return gate.promise + }) + + handle.agent.inject(text('durable idle context'), { source: { kind: 'plugin', plugin: 'test' } }) + expect(flushStarted).toBe(true) + + let disposed = false + const disposal = handle.dispose().then(() => { disposed = true }) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(disposed).toBe(false) + expect(ctx.agents.get(AgentId('idle-flush'))).toBe(handle.agent) + expect(ctx.sessions.get(SessionId('idle-flush-s'))).toBe(handle.agent.session) + + gate.resolve(undefined) + await disposal + expect(ctx.agents.get(AgentId('idle-flush'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('idle-flush-s'))).toBeUndefined() + }) }) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 035332704e..71e2be279d 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -23,7 +23,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age - `ctx.agents.create(options: CreateAgentOptions): Promise` — snapshot caller-owned IDs/options/metadata/seed, construct and await optional setup while unpublished, insert and announce both session and agent, open the `agent/session-start` driving boundary, then start a new loop on the caller-supplied `sessionId`. Agent/session IDs are reserved across setup; setup rejection or owner unload publishes nothing. Publication is rollback-covered: if a creation listener throws, entries and scope unwind but effects of already-delivered notifications remain observable; an agent whose announcement began emits `agent/disposed` during that rollback. Rejects if no factory is registered. - `ctx.agents.resume(options: ResumeAgentOptions): Promise` — snapshot caller-owned IDs/options, load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh agent scope, await optional setup while unpublished, then follow the same insert → announce → session-start → loop-start boundary. The IDs are reserved across persistence load and setup; load/setup rejection or owner unload publishes nothing. Rejects if no factory is registered or session persistence is unconfigured. -`AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit (quiescence — NOT just the `disposed` status flip), unregisters the agent, removes its session from the store, and finally unwinds its scoped world. This order captures the loop's final `session/flush` before the session is detached and keeps scoped listeners alive through that flush. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge and in-process subagent backends are production consumers; config-created agents are owned by the loop fiber and never need a handle. +`AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit plus every outstanding idle-injection flush (quiescence — NOT just the `disposed` status flip), unregisters the agent, removes its session from the store, and finally unwinds its scoped world. This order captures every agent-started `session/flush` before the session is detached and keeps scoped listeners alive through those checkpoints. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge and in-process subagent backends are production consumers; config-created agents are owned by the loop fiber and never need a handle. ### Live events diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 5bd3dd80d7..c73de89e6d 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -107,11 +107,12 @@ export interface ResumeAgentOptions { /** * An owned agent plus its disposer, returned by {@link AgentRegistry.create} / * {@link AgentRegistry.resume}. The disposer is a CAPABILITY: only the holder - * can tear this agent down. `dispose()` stops the loop, awaits its exit - * (quiescence — NOT just the `disposed` status flip), unregisters the agent, - * removes its session from the store, and finally unwinds its scoped world. - * This order captures the loop's final `session/flush` before the session is - * detached and keeps scoped listeners alive through that flush. + * can tear this agent down. `dispose()` stops the loop, awaits its exit and + * every outstanding idle-injection flush (quiescence — NOT just the `disposed` + * status flip), unregisters the agent, removes its session from the store, and + * finally unwinds its scoped world. This order captures every agent-started + * `session/flush` before the session is detached and keeps scoped listeners + * alive through those checkpoints. * * `ctx.agents.get(id)` still returns a bare {@link Agent} — the handle is only * for the OWNER that created it. Config-created agents (the loop's own startup) diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 1017a2df46..4196eb43d6 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -222,8 +222,10 @@ export interface Agent { * turn (`turn/start` → `context/message` → `turn/end`) and checkpoints it for * durability, so every event stays inside a turn and a persistence backend * never loses a between-turn notice. The idle checkpoint is fire-and-forget - * (inject is synchronous): a failing flush is reported via `agent/error` - * (step `0`) and the logger, never thrown into the caller. + * from this synchronous method, but lifecycle disposal awaits it before + * unregistering the agent or detaching its session. A failing flush is + * reported via `agent/error` (step `0`) and the logger, never thrown into the + * caller. * * Live-adapter review has validated the tagged-envelope rendering against * current DeepSeek behavior; provider-specific mismatches belong in that diff --git a/packages/hooks/hooks-claude/README.md b/packages/hooks/hooks-claude/README.md index 306bfdbfeb..f97cdb5cfc 100644 --- a/packages/hooks/hooks-claude/README.md +++ b/packages/hooks/hooks-claude/README.md @@ -39,7 +39,7 @@ The hooks **themselves** run in the agent's session workspace: for the agent-sco | `PreToolUse` | `tools/pre-execute` (waterfall) | `deny` → `PreToolDecision.deny`; `ask` → `PreToolDecision.ask` | | `PostToolUse` | `tools/post-execute` (waterfall) | `deny` → `block` with feedback; additionalContext-only → delegate via `next()` then fold context onto the downstream decision (a Code Mode sub-call’s context is dropped by the run_code bridge — see [the pipeline doc](../../../docs/tool-execution-pipeline.md)) | | `Stop` | `agent/turn-continuation` (waterfall) | a blocking Stop hook forces `continue`, feeding its reason as next-step steering | -| `SubagentStart` | `subagent/start` (emit) | additionalContext → `agent.inject()` into the live child | +| `SubagentStart` | `subagent/start` (emit) | additionalContext → `agent.inject()` into a live in-process child; a remote child has no local injection target | | `SubagentStop` | `subagent/end` (emit) | observe-only | The three emit points run detached — no seam awaits a `SessionStart`/`SubagentStart`/`SubagentStop` hook. Each run chain is tracked, and disposing the bridge aborts still-running hook processes, then drains the continuations before the dispose resolves (`createDetachedRuns` in `dsh-hook-protocol`). diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 7fb087dcdc..cb51986915 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -6,7 +6,7 @@ It is the direction-inverted twin of the server-side bridge in [`@deepseek-ai/ds ## What it does -`start(request)` spawns the configured command, wraps its stdio in an ACP `ClientSideConnection`, and drives one session: `initialize` → `newSession` → `prompt`. The child's streamed `agent_message_chunk` text becomes the `SubagentResult.output`; the prompt's terminal `StopReason` maps to the stop reason. `dispose()` kills the subprocess and awaits its exit. +`start(request)` spawns the configured command, wraps its stdio in an ACP `ClientSideConnection`, and drives one session: `initialize` → `newSession` → `prompt`. `run.started` resolves after `newSession` publishes the remote session and rejects when initialization fails or cancellation wins first; the service emits no start/end pair for a child that never became live. The child's streamed `agent_message_chunk` text becomes the `SubagentResult.output`; the prompt's terminal `StopReason` maps to the stop reason. `dispose()` kills the subprocess and awaits its exit. **Fresh process per run.** Each `start` spawns a new child, runs exactly one ACP session, and disposes it. Persistent-process pooling is a future optimization (see the RFC). @@ -22,7 +22,7 @@ Unlike the in-process backends, the child does NOT share this cordis context — | `providerName` | string | `acp` | Registry name on `ctx.subagents`. | | `command` | string | — (required) | The executable to spawn for each run (the child ACP agent). | | `args` | string[] | `[]` | Arguments passed to `command`. | -| `cwd` | string | parent cwd | Working directory for the child process and its ACP session. | +| `cwd` | string | process cwd | Working directory for the child process and its ACP session. | | `permission` | `'allow' \| 'reject'` | `reject` | How to auto-answer the child's `session/request_permission` prompts. `reject` declines every prompt (answer `cancelled`); `allow` approves via the first allow-shaped option. The first cut surfaces no prompt to a human. | | `env` | Record | `{}` | Extra env vars for the child (e.g. its own `DEEPSEEK_API_KEY`). Forwarded on top of a credential-scrubbed copy of the parent env, so an explicit key reaches the child while ambient secrets do not leak implicitly. | | `disposeEofGraceMs` | number | `6000` | Dispose ladder tier 1: how long the child gets to quiesce on its own after stdin EOF (flush persistence, tear down its nested subprocesses) before SIGTERM. | diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 9d5b17cb9a..410f7ad7bf 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -196,9 +196,15 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su // return an inert run that settled `aborted`, rather than launching the // configured binary just to tear it down. `dispose`/`cancel` are no-ops. if (request.signal?.aborted) { + const started = Promise.reject(new Error('subagent request was aborted before the ACP child started')) + // The result is derived from the same boundary so the readiness rejection + // is observed even when this provider is driven directly rather than + // through SubagentService. + const result: Promise = started.catch(() => ({ output: [], stopReason: 'aborted' })) return { id, - result: Promise.resolve({ output: [], stopReason: 'aborted' }), + started, + result, cancel(_reason?: string): void { /* nothing was started */ }, dispose(): Promise { return Promise.resolve() }, } @@ -289,54 +295,70 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su const onAbort = (): void => { requestCancel() } request.signal?.addEventListener('abort', onAbort, { once: true }) + // The accumulated child text as harness ContentBlocks (empty array when the + // child streamed nothing). Read at every return so a partial answer survives + // a later cancel/error. + const collectOutput = (): ContentBlock[] => { + const text = output.join('') + return text.length > 0 ? [{ type: 'text', text }] : [] + } + + // A provider is "started" only once the remote child has completed ACP + // initialization and published a session. SubagentService gates its + // `subagent/start` notification on this boundary, just as the in-process + // provider gates it on local Agent publication. Failure or cancellation + // before this point rejects readiness and therefore produces no paired + // lifecycle events for a child that never became live. + const started: Promise = Promise.race([ + (async (): Promise => { + await conn.initialize({ + protocolVersion: PROTOCOL_VERSION, + // Advertise NO optional client capabilities (no fs, no terminal): the + // child self-serves in its own process. + clientCapabilities: {}, + }) + const session = await conn.newSession({ cwd: spec.cwd, mcpServers: [] }) + sessionId = session.sessionId + if (flags.cancelled) throw new Error('subagent cancelled before the ACP session started') + })(), + spawnFailed.then((err): never => { throw err }), + cancelSettled.then((): never => { throw new Error('subagent cancelled before the ACP session started') }), + ]) + const result: Promise = (async (): Promise => { - // The accumulated child text as harness ContentBlocks (empty array when the - // child streamed nothing). Read at every return so a partial answer survives - // a later cancel/error. - const collectOutput = (): ContentBlock[] => { - const text = output.join('') - return text.length > 0 ? [{ type: 'text', text }] : [] - } try { - // Race three outcomes, first to settle wins: - // - driveAcp: the normal initialize → newSession → prompt path; - // - spawnFailed: a bad command never speaks ACP, so `initialize` would - // hang forever — the spawn `error` event is the only signal, and a - // rejected race settles the run `error` via the catch; + // Readiness is the initialize → newSession phase above. Awaiting the SAME + // promise immediately observes its rejection even without the service, + // and guarantees the prompt phase never starts before the provider can + // truthfully announce a live child. + await started + + // Race two post-start outcomes, first to settle wins: + // - prompt: the normal remote turn; // - cancelSettled: a cancel was requested — settle `aborted` immediately // rather than waiting on a child that may ignore `session/cancel` or // wedge the prompt (the `cancel()` contract: `result` settles `aborted`). - const driveAcp = async (): Promise => { - await conn.initialize({ - protocolVersion: PROTOCOL_VERSION, - // Advertise NO optional client capabilities (no fs, no terminal): the - // child self-serves in its own process. - clientCapabilities: {}, - }) - const session = await conn.newSession({ cwd: spec.cwd, mcpServers: [] }) - sessionId = session.sessionId - // A cancel that raced ahead of `newSession` set `cancelled` but could not - // send `session/cancel` (no session id yet). Honor it here: settle - // `aborted` without ever issuing the prompt, rather than running the child - // to completion and ignoring the cancel. - if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' } - const promptResult = await conn.prompt({ sessionId, prompt: toAcpPrompt(request.prompt) }) + // A spawn error can only precede readiness and is already one arm of + // `started`; after `newSession` succeeds, transport/process failure rejects + // the in-flight prompt RPC through the connection. + const prompt = async (): Promise => { + // `started` cannot fulfill without assigning the session id; the cast + // records that local invariant without an unreachable defensive arm. + const promptResult = await conn.prompt({ sessionId: sessionId as string, prompt: toAcpPrompt(request.prompt) }) return { output: collectOutput(), stopReason: acpStopReason(promptResult.stopReason) } } return await Promise.race([ - driveAcp(), - spawnFailed.then((err): SubagentResult => { throw err }), + prompt(), cancelSettled.then((): SubagentResult => ({ output: collectOutput(), stopReason: 'aborted' })), ]) } catch (error: unknown) { + if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' } // The seam contract: result resolves (never rejects) on a child-level - // failure. Cancellation is handled by the `cancelSettled` race arm above - // (it settles `aborted` the instant cancel is requested, beating any - // rejection), so a rejection that reaches HERE is always a genuine - // child-level error — the awaited ACP RPCs or the spawn-failure race - // (initialize/newSession/prompt transport/RPC errors, or ENOENT), not a - // local bug. Flatten to `error` and surface the original via onError so a - // real fault is preserved rather than silently lost. + // failure. A cancellation is recognized by the flag above even when it + // wins during readiness; every other rejection is a genuine child-level + // error — initialize/newSession/prompt transport/RPC failure or ENOENT. + // Flatten to `error` and surface the original via onError so a real fault + // is preserved rather than silently lost. try { spec.onError?.(toError(error), 'error') } catch { @@ -350,6 +372,7 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su return { id, + started, result, cancel(_reason?: string): void { requestCancel() diff --git a/packages/subagent/subagent-fork/README.md b/packages/subagent/subagent-fork/README.md index c626775945..1434601c8e 100644 --- a/packages/subagent/subagent-fork/README.md +++ b/packages/subagent/subagent-fork/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-subagent-fork -The in-process **fork** subagent backend: a [`SubagentProvider`](../subagent/README.md) that runs each child as a child [`Agent`](../../core/agent) **seeded with a prefix of the parent's session log** — so the child inherits the parent's conversation context instead of starting fresh. Shares the run driver (`startInProcessRun`) with [`dsh-subagent-spawn`](../subagent-spawn/README.md); the only difference is the seed. +The in-process **fork** subagent backend: a [`SubagentProvider`](../subagent/README.md) that runs each child as a child [`Agent`](../../core/agent) **seeded with a prefix of the parent's session log** — so the child inherits the parent's conversation context instead of starting fresh. Shares the run driver (`startInProcessRun`) with [`dsh-subagent-spawn`](../subagent-spawn/README.md); the only difference is the seed. The shared `run.started` boundary resolves only after the seeded child is published, so `subagent/start` observers see a live registry entry. ## The seed boundary (the crux) diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index 31a4f4c895..f9c7b04a51 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -71,6 +71,23 @@ describe('completedTurnPrefix', () => { }) describe('dsh-subagent-fork', () => { + it('emits subagent/start only after the seeded child is published', async () => { + const { ctx, parent } = await setup([textResponse('child answer')]) + let childAtStart: ReturnType + ctx.on('subagent/start', (info) => { + if (info.provider === 'fork') childAtStart = ctx.agents.get(info.id) + }) + + const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child q' }], parent }) + expect(childAtStart).toBeUndefined() + await run.started + expect(childAtStart).toBe(ctx.agents.get(run.id)) + expect(childAtStart?.id).toBe(run.id) + + await run.result + await run.dispose() + }) + it('forks an UNSEEDED (fresh) child when the parent has no completed turn', async () => { // The parent has never completed a turn → empty prefix → the provider omits // the seed → the child runs fresh. Exercises the `seed.length > 0` false arm. diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 1c5aa13374..caa7b9b161 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -9,11 +9,11 @@ The shared **in-process subagent run driver**. A library with no provider or imp Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`): 1. snapshots the accepted request before asynchronous owner setup: the parent and signal remain identity capabilities but are never reread from the caller-owned record; tool filter, seed, agent options, output schema, and prompt are detached. It computes child depth = `depthOf(parent) + 1` and rejects `request.maxDepth` overflow with `SubagentDepthError`; `outputSchema` is asserted before cloning so a hostile value fails as `OutputSchemaError`, while the prompt passes the session log's lossless-JSON check before and after cloning; -2. first installs provider ownership, then attaches the request abort listener and creates one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves no child or orphaned listener. Async child creation goes through that fiber's `ctx.agents` service with fresh IDs, lineage/seed, inherited model, and an unpublished setup transaction for persona, tool restriction, and structured output. Parent teardown, provider teardown, and manual `run.dispose()` all dispose this exact node, preventing publication after it becomes inactive and awaiting the same quiescence boundary. `startInProcessRun` still returns its `SubagentRun` immediately, and cancellation during creation is recorded and applied when a child exists; +2. first installs provider ownership, then attaches the request abort listener and creates one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves no child or orphaned listener. Async child creation goes through that fiber's `ctx.agents` service with fresh IDs, lineage/seed, inherited model, and an unpublished setup transaction for persona, tool restriction, and structured output. Parent teardown, provider teardown, and manual `run.dispose()` all dispose this exact node, preventing publication after it becomes inactive and awaiting the same quiescence boundary. `startInProcessRun` still returns its `SubagentRun` immediately: `run.started` resolves only after `ctx.agents.create()` has published the child (and rejects if publication never happens), while cancellation during creation is recorded and applied when a child exists; 3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts); there is deliberately NO re-prompt for a structured child that finished cleanly without calling `structured_output` — the shortfall maps to an `error` result for the parent; 4. reads the result, scoped to the child's OWN events (everything at or after `seedLength`, so a seeded child that produced no message of its own never returns the seeded parent's last message): the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. A structured run surfaces the captured value as `result.structured`; a structured child that finished cleanly WITHOUT ever capturing settles `error` (a clean finish without the demanded result is a failure, not a success with a missing field). -`dispose()` awaits creation or rollback and then delegates to `AgentHandle.dispose()` (stop and drain → remove agent → detach session → unwind scope); `cancel()` records its request even before publication and cancels the child immediately once available. A cancel landing before any `turn/end` still settles `aborted`, honoring the cancel contract rather than the generic no-turn `error`. +`SubagentService` waits for `run.started` before emitting `subagent/start`, so a synchronous start observer can resolve the published child with `ctx.agents.get(run.id)`; the result driver awaits the same boundary before sending the prompt. An attempt that never publishes rejects readiness and emits no false start/end pair; its result reports a deliberate cancel/dispose as `aborted` and propagates an infrastructure fault. `dispose()` awaits creation or rollback and then delegates to `AgentHandle.dispose()` (stop and drain → remove agent → detach session → unwind scope); `cancel()` records its request even before publication and cancels the child immediately once available. A cancel landing before any `turn/end` still settles `aborted`, honoring the cancel contract rather than the generic no-turn `error`. ### `InProcessRunOptions` diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 61867e1b39..de3b042fe3 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -289,11 +289,23 @@ export function startInProcessRun( return created.agent })() + // Provider readiness is a distinct lifecycle boundary from accepting the + // request. It resolves only after the factory has published the child and + // returned its handle, so SubagentService can emit `subagent/start` while + // `ctx.agents.get(childId)` is guaranteed to resolve. The result path awaits + // THIS SAME promise immediately, which also observes a readiness rejection + // when the driver is invoked directly rather than through SubagentService. + const started: Promise = creation.then(() => undefined) + const result: Promise = (async () => { try { let liveChild: Agent try { - liveChild = await creation + await started + // `creation` assigns `child` before it fulfills, and `started` is its + // direct fulfillment projection. The cast records that local invariant + // without manufacturing an unreachable runtime branch. + liveChild = child as Agent } catch (error: unknown) { if (isManualDisposeRequested()) return { output: [], stopReason: 'aborted' } throw error instanceof Error ? error : new Error('subagent child creation failed with a non-Error value', { cause: error }) @@ -313,6 +325,7 @@ export function startInProcessRun( let disposing: Promise | undefined return { id: childId, + started, result, cancel(reason?: string): void { requestCancel(reason ?? 'subagent cancelled') diff --git a/packages/subagent/subagent-spawn/README.md b/packages/subagent/subagent-spawn/README.md index e133cb1e5b..e411d44ab0 100644 --- a/packages/subagent/subagent-spawn/README.md +++ b/packages/subagent/subagent-spawn/README.md @@ -6,7 +6,7 @@ The run mechanics live in the shared [`@deepseek-ai/dsh-subagent-inprocess`](../ ## What it does -`start(request)` delegates to `startInProcessRun(ctx, request, {})` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. The driver creates one run-owner fiber under `parent.ctx`; parent teardown, this provider's teardown, and manual disposal all converge there before child publication. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose). +`start(request)` delegates to `startInProcessRun(ctx, request, {})` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. The driver creates one run-owner fiber under `parent.ctx`; parent teardown, this provider's teardown, and manual disposal all converge there before child publication. Its `run.started` boundary resolves only after the fresh child is published, so `subagent/start` observers see a live registry entry. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose). ## Capabilities diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index a8dde937c0..3d23fca285 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -55,6 +55,25 @@ describe('dsh-subagent-spawn', () => { await run.dispose() }) + it('emits subagent/start only after the fresh child is published', async () => { + const { ctx, parent } = await setup([textResponse('child answer')]) + let childAtStart: ReturnType + ctx.on('subagent/start', (info) => { + if (info.provider === 'spawn') childAtStart = ctx.agents.get(info.id) + }) + + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'do X' }], parent }) + // Creation is asynchronous; no lifecycle claim is made while the child is + // still inside its unpublished setup transaction. + expect(childAtStart).toBeUndefined() + await run.started + expect(childAtStart).toBe(ctx.agents.get(run.id)) + expect(childAtStart?.id).toBe(run.id) + + await run.result + await run.dispose() + }) + it('gives the child its OWN session (not the parent\'s), with parentSession lineage', async () => { const { ctx, parent } = await setup([textResponse('hi')]) const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 695bd8f952..e214ba58c6 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -18,10 +18,10 @@ Unlike the bash seam (one executor per context, second load throws), **multiple | Member | Semantics | |---|---| -| `registerProvider(provider)` | Register under `provider.name`. Throws `SubagentError('DUPLICATE_PROVIDER')` on a name clash. Effect-scoped (HMR-safe); returns the disposer. | -| `getProvider(name)` | Look up a provider (`undefined` if absent). | +| `registerProvider(provider)` | Register a frozen acceptance snapshot under `provider.name`; later caller mutation cannot change registry behavior or HMR cleanup, while `start` stays bound to the original provider receiver. Throws `SubagentError('DUPLICATE_PROVIDER')` on a name clash. Effect-scoped (HMR-safe); returns the disposer. | +| `getProvider(name)` | Look up the frozen registry snapshot (`undefined` if absent). | | `list()` | Registered provider names (insertion order). | -| `start(name, request)` | Resolve the provider (`NO_PROVIDER` if absent), validate every requested START-TIME capability (`UNSUPPORTED_CAPABILITY` for the first unmet one — before any child is created), then delegate to `provider.start` and emit `subagent/start` / `subagent/end` around the run. | +| `start(name, request)` | Resolve the provider (`NO_PROVIDER` if absent), validate every requested START-TIME capability (`UNSUPPORTED_CAPABILITY` for the first unmet one — before any child is created), then delegate to `provider.start`. Emit `subagent/start` only after `run.started` fulfills and the paired `subagent/end` after that started run settles; a pre-publication readiness rejection emits neither. | ## Capabilities: two kinds, discovered two ways @@ -32,9 +32,9 @@ Beside `capabilities` sits one DESCRIPTIVE fact, not validated by the service: ` ## Run lifecycle -`provider.start(request)` returns a `SubagentRun`: a handle with a `result` promise, `cancel()`, `dispose()`, and the optional runtime methods. `result` resolves with a `SubagentResult` (`output`, optional `structured`, `stopReason`) — it 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` tool result. The consumer MUST `dispose()` on every path (success, error, abort) to reach child quiescence and avoid leaking an idle child / session. +`provider.start(request)` returns a `SubagentRun`: a handle with `started` (the publication/readiness promise), `result` (the terminal outcome), `cancel()`, `dispose()`, and the optional runtime methods. `started` resolves only after the provider has established a real child and rejects if the attempt fails or is cancelled first. `result` resolves with a `SubagentResult` (`output`, optional `structured`, `stopReason`) — it 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` tool result. The consumer MUST `dispose()` on every path (success, error, abort) to reach child quiescence and avoid leaking an idle child / session. -The service also announces provider lifecycle: `subagent/provider-added` (the live provider) fires after a registration and `subagent/provider-removed` (the name) after an unregistration, so a consumer deriving state from a named provider (the model-facing tool wording) mirrors registry membership instead of assuming load order — the cordis Loader starts sibling plugins concurrently, so "listed earlier" does not mean "registered earlier". The service emits `subagent/start` (payload `SubagentRunInfo`) and `subagent/end` (payload `SubagentRunEndInfo`) around the run — both **observe-only** (plain `emit`s; `subagent/end` fires from a detached `.then` and awaits no listener). `subagent/end` carries `lastAssistantMessage` (a deep clone of the child's final `output`) on the settle path, absent when the run rejected at the infrastructure level. The clone keeps the surface observe-only: the end emit fires from a detached `.then` before the caller's `await run.result` resumes, so a shared reference would let a mutating listener corrupt the caller's result. A `subagent/start` listener can still reach the live child via `ctx.agents.get(info.id)`; a `subagent/end` listener can only observe (the run has settled). Any run-affecting decision (continuation, injection that changes the run) is out of scope for this observe-only surface. +The service also announces provider lifecycle: `subagent/provider-added` (the frozen registry snapshot) fires after a registration and `subagent/provider-removed` (the accepted name) after an unregistration, so a consumer deriving state from a named provider (the model-facing tool wording) mirrors registry membership instead of assuming load order — the cordis Loader starts sibling plugins concurrently, so "listed earlier" does not mean "registered earlier". Run lifecycle is gated by provider readiness: `subagent/start` (payload `SubagentRunInfo`) fires only after `run.started` fulfills, and `subagent/end` (payload `SubagentRunEndInfo`) fires only for that announced run; readiness rejection emits neither. For spawn/fork, the start listener can resolve the published child via `ctx.agents.get(info.id)`; a remote provider need not have a local registry entry. Both events are **observe-only** plain emits. The service observes `result` immediately even while readiness is pending, clones its output before the caller can mutate it, and buffers that end payload until start has fired; a rejecting result cannot become an unhandled detached promise, start always precedes end, and a listener cannot corrupt the caller's result. `subagent/end` carries the cloned output as `lastAssistantMessage` on the settle path and omits it on infrastructure rejection. Any run-affecting decision is out of scope for this observe-only surface. ## Scope (first cut) diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 45287271c6..e1f4fac594 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -69,7 +69,7 @@ declare module 'cordis' { * 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". - * @param provider - the provider that just registered, live in the registry. + * @param provider - the registry's frozen acceptance snapshot of the provider. * @mode emit */ 'subagent/provider-added'(provider: SubagentProvider): void @@ -85,8 +85,11 @@ declare module 'cordis' { */ 'subagent/provider-removed'(name: string): void /** - * A subagent run started — emitted after the provider is resolved and its - * capabilities validated, as the child run begins. Paired with + * A subagent run started — emitted only after {@link SubagentRun.started} + * fulfills, when the provider has established a live child. For an + * in-process provider, `ctx.agents.get(info.id)` is therefore guaranteed to + * resolve during this notification. A readiness rejection emits neither + * lifecycle event; every emitted start is paired with * {@link Events['subagent/end']}. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed * by the DELEGATING PARENT — a listener registered through the parent's @@ -97,8 +100,10 @@ declare module 'cordis' { */ 'subagent/start'(this: Scoped, info: SubagentRunInfo): void /** - * A subagent run settled — emitted when {@link SubagentRun.result} - * resolves (any stop reason). Paired with {@link Events['subagent/start']}. + * A started subagent run settled — emitted when {@link SubagentRun.result} + * resolves (any stop reason) or rejects (reported as `error`). Paired with + * {@link Events['subagent/start']}; a run whose readiness rejected emits + * neither event. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed * by the DELEGATING PARENT — a listener registered through the parent's * `agent.ctx` observes only its own delegations; a plain plugin listener @@ -161,21 +166,43 @@ export class SubagentService extends Service { /** * Register a provider under its `provider.name`. Throws {@link SubagentError} - * (`DUPLICATE_PROVIDER`) if the name is already taken. Effect-scoped: disposed - * with the calling fiber (HMR-safe). Emits `subagent/provider-added` after - * the registration and `subagent/provider-removed` on unregistration, so - * consumers can mirror provider lifecycle instead of assuming load order. + * (`DUPLICATE_PROVIDER`) if the name is already taken. The registry snapshots + * the name, static descriptors, and `start` callback identity at acceptance; + * later caller mutation cannot change lookup, capability validation, consumer + * wording, dispatch, or HMR cleanup. The callback remains bound to the + * original provider object, so provider-owned mutable state stays live. + * Effect-scoped: disposed with the calling fiber (HMR-safe). Emits + * `subagent/provider-added` after the registration and + * `subagent/provider-removed` on unregistration, so consumers can mirror + * provider lifecycle instead of assuming load order. * @param provider - the provider; its `name` is the registry key. * @returns the disposer that unregisters the provider. The exact * Cordis effect disposer (single-shot): composite (generator) effects may * yield it directly — exact identity nests the teardown in order. */ registerProvider(provider: SubagentProvider): () => Promise | void { + // Snapshot the accepted registration contract before entering the effect. + // Cleanup must never re-read caller-owned `provider.name`: an HMR host may + // mutate or reuse the provider object before its old fiber unloads. Binding + // preserves the provider method's receiver while making replacement of the + // public callback field after registration inert. + const capabilities: SubagentCapabilities = Object.freeze({ + outputSchema: provider.capabilities.outputSchema, + depthLimit: provider.capabilities.depthLimit, + toolFilter: provider.capabilities.toolFilter, + persona: provider.capabilities.persona, + }) + const snapshot: SubagentProvider = Object.freeze({ + name: provider.name, + capabilities, + inheritsParentContext: provider.inheritsParentContext, + start: provider.start.bind(provider), + }) const dispose = this.ctx.effect(function* (this: SubagentService) { - if (this.providers.has(provider.name)) { - throw new SubagentError(`a subagent provider named "${provider.name}" is already registered`, 'DUPLICATE_PROVIDER') + if (this.providers.has(snapshot.name)) { + throw new SubagentError(`a subagent provider named "${snapshot.name}" is already registered`, 'DUPLICATE_PROVIDER') } - this.providers.set(provider.name, provider) + this.providers.set(snapshot.name, snapshot) // Yield the rollback BEFORE emitting `subagent/provider-added`: a // throwing added-listener then unregisters the provider (and announces // the removal) instead of leaking it into the registry. The removal @@ -183,10 +210,10 @@ export class SubagentService extends Service { // it runs inside this disposer, where a propagating subscriber would // disrupt the backend fiber's teardown and starve later mirrors. yield () => { - this.providers.delete(provider.name) - this.emitLifecycle('subagent/provider-removed', provider.name) + this.providers.delete(snapshot.name) + this.emitLifecycle('subagent/provider-removed', snapshot.name) } - this.ctx.emit('subagent/provider-added', provider) + this.ctx.emit('subagent/provider-added', snapshot) }.bind(this), 'subagents.registerProvider()') // The EXACT cordis effect disposer, not a wrapper: a composite (generator) // effect that owns a teardown ORDER must be able to yield THIS function — @@ -198,9 +225,10 @@ export class SubagentService extends Service { } /** - * Look up a registered provider by name (`undefined` if absent). - * @param name - the provider name as registered. - * @returns the provider, or undefined when the name is unknown. + * Look up the registry's frozen provider snapshot by its accepted name + * (`undefined` if absent). + * @param name - the provider name accepted at registration. + * @returns the frozen acceptance snapshot, or undefined when the name is unknown. */ getProvider(name: string): SubagentProvider | undefined { return this.providers.get(name) @@ -219,8 +247,9 @@ export class SubagentService extends Service { * `NO_PROVIDER` if absent), validates every requested START-TIME capability * against {@link SubagentProvider.capabilities} (throws `UNSUPPORTED_CAPABILITY` * for the first unmet one — fail loud, before any child is created), then - * delegates to {@link SubagentProvider.start} and emits `subagent/start` / - * `subagent/end` around the run. + * delegates to {@link SubagentProvider.start}, then emits `subagent/start` / + * `subagent/end` only after the run's readiness boundary fulfills. A provider + * that fails before establishing a child emits neither event. * @param name - the provider to run on. * @param request - the child's prompt, capabilities, and options. * @returns the live run (its `result` resolves when the child settles). @@ -252,46 +281,63 @@ export class SubagentService extends Service { ...request.persona !== undefined ? { persona: request.persona } : {}, } const run = provider.start(accepted) - // Emit `subagent/start` with PER-LISTENER containment (see {@link emitLifecycle}): - // the run is already live, so neither a throwing subscriber escaping - // `start()` (the caller would never receive the run to dispose it — a leaked - // child) NOR one bad subscriber starving the listeners after it is - // acceptable. `ctx.emit` halts the dispatch on the first throw, so a single - // surrounding try/catch is not enough — each listener is invoked and - // contained individually. - this.emitLifecycle('subagent/start', { provider: name, id: run.id }, parent) - // Emit `subagent/end` when the run settles. The result promise does not - // reject on a child-level failure (it resolves with stopReason 'error'), - // so a rejection here is an infrastructure fault — surface its stop reason - // as 'error' for the telemetry event without swallowing the rejection - // (the consumer still observes it via `run.result`). On the resolve path the - // child's final output rides on the event (lastAssistantMessage); on the - // reject path there is no SubagentResult, so only the stop reason is known. - // Per-listener containment also keeps a thrown `subagent/end` listener from - // becoming an unhandled rejection on this detached `.then`. + + // Observe result settlement IMMEDIATELY, before waiting on readiness. A + // provider may fail both promises in the same turn; deferring the rejection + // handler until `started` fulfilled would leave `result` transiently + // unhandled. The settled event is buffered until start has been announced, + // preserving start → end order even for an already-settled scripted run. + let readiness: 'pending' | 'started' | 'failed' = 'pending' + let pendingEnd: SubagentRunEndInfo | undefined + const deliverEnd = (info: SubagentRunEndInfo): void => { + if (readiness === 'started') this.emitLifecycle('subagent/end', info, parent) + else if (readiness === 'pending') pendingEnd = info + // A pre-publication readiness failure has no lifecycle pair; result + // remains observable by the run's consumer, but telemetry must not claim + // that a child started. + } void run.result.then( (result) => { - // Deep-clone the output onto the event: this detached `.then` runs BEFORE - // the caller's own `await run.result` continuation, so handing listeners - // the SAME array reference the caller consumes would let a mutating - // `subagent/end` listener corrupt the caller's SubagentResult.output — - // breaking the observe-only contract. A snapshot makes the event a - // read-only view, not a shared handle. The clone is wrapped: it runs - // inside `onFulfilled`, OUTSIDE emitLifecycle's per-listener containment, - // so an uncloneable value (a future non-serializable content-block type, - // or a contract-violating result with no `output`) would otherwise become - // an unhandled rejection on this detached `.then`. On clone failure, log - // and emit the event WITHOUT lastAssistantMessage rather than dropping the - // whole `subagent/end`. + // Snapshot before the caller's own `await run.result` continuation. Even + // when readiness is still pending, buffering the clone rather than the + // caller-owned result keeps the eventual observe-only event immutable + // with respect to consumer mutation. let lastAssistantMessage: SubagentResult['output'] | undefined try { lastAssistantMessage = structuredClone(result.output) } catch (error: unknown) { this.ctx.logger.warn(`subagent: could not clone ${name} output for subagent/end: ${String(error)}`) } - this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason, ...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {} }, parent) + deliverEnd({ + provider: name, + id: run.id, + stopReason: result.stopReason, + ...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {}, + }) + }, + () => { deliverEnd({ provider: name, id: run.id, stopReason: 'error' }) }, + ) + + // Readiness is the publication boundary owned by the provider. For + // in-process runs, fulfillment means the agent registry already contains + // `run.id`; for ACP it means the remote session exists. Emit start with + // per-listener containment, then flush an outcome that settled unusually + // early. A readiness rejection is handled here and deliberately emits no + // false start/end pair; the result path above remains independently handled. + void run.started.then( + () => { + readiness = 'started' + this.emitLifecycle('subagent/start', { provider: name, id: run.id }, parent) + if (pendingEnd !== undefined) { + const info = pendingEnd + pendingEnd = undefined + this.emitLifecycle('subagent/end', info, parent) + } + }, + () => { + readiness = 'failed' + pendingEnd = undefined }, - () => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }, parent) }, ) return run } diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index a95fbae623..003d6a6a3d 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -142,8 +142,16 @@ export interface SubagentResult { * presence of the method IS the capability — narrow before calling. */ export interface SubagentRun { - /** The child agent's id (use `ctx.agents.get(id)` to reach the live child). */ + /** The child agent's id (local in-process runs publish it in `ctx.agents`; remote transports need not). */ readonly id: AgentId + /** + * The provider's publication/readiness boundary. Resolves only after a real + * child is established: an in-process agent is live in `ctx.agents`, or a + * remote transport has created its child session. Rejects when the attempt + * fails or is cancelled before that boundary. The service emits the paired + * `subagent/start`/`subagent/end` lifecycle only after this fulfills. + */ + readonly started: Promise /** * Resolves with the child's terminal {@link SubagentResult} when the run * settles. Does NOT reject on a child-level failure — a model/transport @@ -176,7 +184,9 @@ export interface SubagentRun { * A subagent backend: one transport for running a child agent (in-process * spawn/fork, ACP to another process, …). Implementations register under a * unique name via {@link SubagentService.registerProvider}; multiple providers - * coexist in one context (unlike the single-implementation bash seam). + * coexist in one context (unlike the single-implementation bash seam). The + * service freezes the public descriptor and callback identity at registration; + * the captured `start` remains bound to the original provider receiver. */ export interface SubagentProvider { /** Unique registry name (e.g. `spawn`, `fork`, `acp`). */ @@ -194,9 +204,12 @@ export interface SubagentProvider { */ readonly inheritsParentContext: boolean /** - * Start a child run. The service has already validated that every requested - * start-time capability is supported, so an implementation may assume e.g. - * `request.maxDepth` is honorable when present. + * Start preparing a child run and return its handle synchronously. The + * service has already validated that every requested start-time capability + * is supported, so an implementation may assume e.g. `request.maxDepth` is + * honorable when present. The returned {@link SubagentRun.started} must mark + * the real publication/readiness boundary; the result path must observe that + * promise immediately so a pre-start rejection cannot become unhandled. */ start(request: SubagentStartRequest): SubagentRun } diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 3a16b19e22..7b9e828c37 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -34,6 +34,7 @@ class StubProvider implements SubagentProvider { this.startCount++ return { id: AgentId(`child:${this.name}:${request.parent.id}`), + started: Promise.resolve(), result: Promise.resolve(this.result), cancel() {}, async dispose() {}, @@ -106,7 +107,7 @@ describe('SubagentService', () => { ctx.subagents.registerProvider(provider) expect(ctx.subagents.list()).toEqual(['alpha']) - expect(ctx.subagents.getProvider('alpha')).toBe(provider) + expect(ctx.subagents.getProvider('alpha')).toMatchObject({ name: 'alpha' }) const run = ctx.subagents.start('alpha', baseRequest()) expect(provider.startCount).toBe(1) @@ -162,6 +163,75 @@ describe('SubagentService', () => { expect(ctx.subagents.list()).toEqual([]) }) + it('snapshots a provider registration so caller mutation cannot corrupt dispatch or HMR cleanup', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const capabilities: SubagentCapabilities = { + outputSchema: true, + depthLimit: true, + toolFilter: true, + persona: true, + } + const provider = new StubProvider('stable', capabilities) + const added: SubagentProvider[] = [] + const removed: string[] = [] + ctx.on('subagent/provider-added', registered => void added.push(registered)) + ctx.on('subagent/provider-removed', name => void removed.push(name)) + const owner = await ctx.plugin({ + name: 'mutable-provider-owner', + inject: ['subagents'], + apply(pluginCtx: Context) { + pluginCtx.subagents.registerProvider(provider) + }, + }) + const accepted = ctx.subagents.getProvider('stable') + + const mutable = provider as unknown as { + name: string + capabilities: SubagentCapabilities + inheritsParentContext: boolean + start: SubagentProvider['start'] + } + mutable.name = 'mutated' + capabilities.outputSchema = false + capabilities.depthLimit = false + capabilities.toolFilter = false + capabilities.persona = false + mutable.capabilities = NO_CAPS + mutable.inheritsParentContext = true + const replacementStart = vi.fn((_request: SubagentStartRequest): SubagentRun => { + throw new Error('replacement start must not run') + }) + mutable.start = replacementStart + + expect(added).toEqual([accepted]) + expect(accepted).not.toBe(provider) + expect(Object.isFrozen(accepted)).toBe(true) + expect(Object.isFrozen(accepted?.capabilities)).toBe(true) + expect(accepted).toMatchObject({ + name: 'stable', + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, + inheritsParentContext: false, + }) + expect(ctx.subagents.list()).toEqual(['stable']) + expect(ctx.subagents.getProvider('mutated')).toBeUndefined() + + const run = ctx.subagents.start('stable', baseRequest({ + outputSchema: { type: 'object', properties: { answer: { type: 'string' } } }, + maxDepth: 2, + toolFilter: { deny: ['bash'] }, + persona: 'reviewer', + })) + await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) + expect(provider.startCount).toBe(1) + expect(replacementStart).not.toHaveBeenCalled() + + await owner.dispose() + expect(removed).toEqual(['stable']) + expect(ctx.subagents.list()).toEqual([]) + expect(() => ctx.subagents.registerProvider(new StubProvider('stable'))).not.toThrow() + }) + it('re-registers a name after its prior registration is disposed (not wedged)', async () => { const ctx = new Context() await ctx.plugin(SubagentService) @@ -220,6 +290,7 @@ describe('SubagentService', () => { ctx.on('subagent/end', ended) const run = ctx.subagents.start('events', baseRequest()) + await run.started expect(started).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id })) await run.result @@ -228,6 +299,67 @@ describe('SubagentService', () => { expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id, stopReason: 'completed' })) }) + it('waits for provider readiness and observes an early result rejection without reordering lifecycle', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const readiness = Promise.withResolvers() + ctx.subagents.registerProvider({ + name: 'delayed-start', + capabilities: NO_CAPS, + inheritsParentContext: false, + start: () => ({ + id: AgentId('delayed-child'), + started: readiness.promise, + // Already rejected: SubagentService must attach its result handler in + // the same synchronous start() call, before awaiting readiness. + result: Promise.reject(new Error('early infrastructure fault')), + cancel() {}, + async dispose() {}, + }), + }) + const lifecycle: string[] = [] + ctx.on('subagent/start', () => void lifecycle.push('start')) + ctx.on('subagent/end', info => void lifecycle.push(`end:${info.stopReason}`)) + + const run = ctx.subagents.start('delayed-start', baseRequest()) + await expect(run.result).rejects.toThrow('early infrastructure fault') + expect(lifecycle).toEqual([]) + + readiness.resolve(undefined) + await run.started + expect(lifecycle).toEqual(['start', 'end:error']) + }) + + it('emits no lifecycle pair when readiness rejects before a child exists', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const readiness = Promise.withResolvers() + const result = Promise.withResolvers() + ctx.subagents.registerProvider({ + name: 'never-started', + capabilities: NO_CAPS, + inheritsParentContext: false, + start: () => ({ + id: AgentId('never-started-child'), + started: readiness.promise, + result: result.promise, + cancel() {}, + async dispose() {}, + }), + }) + const lifecycle = vi.fn() + ctx.on('subagent/start', lifecycle) + ctx.on('subagent/end', lifecycle) + + const run = ctx.subagents.start('never-started', baseRequest()) + readiness.reject(new Error('publication rolled back')) + await expect(run.started).rejects.toThrow('publication rolled back') + result.resolve({ output: [], stopReason: 'aborted' }) + await run.result + await Promise.resolve() + expect(lifecycle).not.toHaveBeenCalled() + }) + it('pins start and end to the parent accepted at start despite caller mutation', async () => { const ctx = new Context() await ctx.plugin(SubagentService) @@ -241,6 +373,7 @@ describe('SubagentService', () => { acceptedRequest = accepted return { id: AgentId('deferred-child'), + started: Promise.resolve(), result: gate.promise, cancel() {}, async dispose() {}, @@ -282,6 +415,7 @@ describe('SubagentService', () => { ctx.on('subagent/end', ended) const run = ctx.subagents.start('enriched', baseRequest()) + await run.started expect(started).toHaveBeenCalledWith(expect.objectContaining({ provider: 'enriched', id: run.id })) await run.result @@ -331,6 +465,7 @@ describe('SubagentService', () => { inheritsParentContext: false, start: () => ({ id: AgentId('rej-child'), + started: Promise.resolve(), result: Promise.reject(new Error('infra fault')), cancel() {}, dispose: async () => {}, @@ -365,6 +500,7 @@ describe('SubagentService', () => { inheritsParentContext: false, start: () => ({ id: AgentId('unclone-child'), + started: Promise.resolve(), result: Promise.resolve({ output: uncloneable, stopReason: 'completed' } as SubagentResult), cancel() {}, dispose: async () => {}, @@ -395,6 +531,7 @@ describe('SubagentService', () => { inheritsParentContext: false, start: () => ({ id: AgentId('rej-child'), + started: Promise.resolve(), result: Promise.reject(new Error('infra fault')), cancel() {}, dispose: async () => {}, @@ -424,6 +561,7 @@ describe('SubagentService', () => { const run = ctx.subagents.start('contain', baseRequest()) expect(run.id).toBeDefined() + await run.started expect(second).toHaveBeenCalledWith(expect.objectContaining({ provider: 'contain', id: run.id })) await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) }) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 9d9941fdff..95d26dabdf 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -115,6 +115,7 @@ describe('dsh-tool-subagent', () => { inheritsParentContext: false, start: () => ({ id: AgentId('weird-child'), + started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }), cancel() {}, dispose: async () => {}, @@ -143,6 +144,7 @@ describe('dsh-tool-subagent', () => { seen = request return { id: AgentId('capture-child'), + started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), cancel() {}, dispose: async () => {}, @@ -173,6 +175,7 @@ describe('dsh-tool-subagent', () => { seen = request return { id: AgentId('bare-child'), + started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), cancel() {}, dispose: async () => {}, @@ -301,6 +304,7 @@ describe('dsh-tool-subagent', () => { inheritsParentContext: false, start: () => ({ id: AgentId('spy-child'), + started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), cancel() {}, dispose: async () => void disposed(), @@ -324,6 +328,7 @@ describe('dsh-tool-subagent', () => { inheritsParentContext: false, start: () => ({ id: AgentId('spy-child'), + started: Promise.resolve(), result: Promise.resolve({ output: [], stopReason: 'error' as const }), cancel() {}, dispose: async () => void disposed(), @@ -351,6 +356,7 @@ describe('dsh-tool-subagent', () => { const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res }) return { id: AgentId('spy-child'), + started: Promise.resolve(), result, cancel: () => { cancelled() @@ -398,6 +404,7 @@ describe('dsh-tool-subagent', () => { const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res }) return { id: AgentId('spy-child'), + started: Promise.resolve(), result, cancel: () => { cancelled() @@ -466,6 +473,7 @@ describe('dsh-tool-subagent', () => { seen = request return { id: AgentId('capture2-child'), + started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), cancel() {}, dispose: async () => {}, @@ -499,6 +507,7 @@ describe('dsh-tool-subagent', () => { seen = request return { id: AgentId('capture3-child'), + started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), cancel() {}, dispose: async () => {}, @@ -529,6 +538,7 @@ describe('dsh-tool-subagent', () => { seen = request return { id: AgentId('capture4-child'), + started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), cancel() {}, dispose: async () => {}, diff --git a/packages/support/subagent-mock/src/index.ts b/packages/support/subagent-mock/src/index.ts index e72765ba4d..715c7451d7 100644 --- a/packages/support/subagent-mock/src/index.ts +++ b/packages/support/subagent-mock/src/index.ts @@ -66,6 +66,9 @@ class MockSubagentProvider implements SubagentProvider { return { id, + // A scripted run has no asynchronous publication phase; it is ready as + // soon as the provider returns the handle. + started: Promise.resolve(), result: Promise.resolve().then(resultFor), cancel() { cancelled = true diff --git a/packages/ui/user-approval/README.md b/packages/ui/user-approval/README.md index 26ec82bbc9..821e98638f 100644 --- a/packages/ui/user-approval/README.md +++ b/packages/ui/user-approval/README.md @@ -2,7 +2,7 @@ User-approval seam. Owns the `ctx.approval` service ([`ApprovalService`](src/index.ts)) and the one-shot permission vocabulary the harness shares: `ApprovalRequest` (agent + tool identity + reason + abort signal), the closed `ApprovalOutcome` union (`allowed-once` / `rejected` / `cancelled` / `unavailable`), the `ApprovalRequestId` brand pairing the two log-only audit events (`approval/asked` / `approval/decided`), and the `approval/request` waterfall the answerers listen on. It lives in the UI group because its purpose is human permission, while remaining channel-neutral: it depends only on Cordis and core vocabulary packages, never on a concrete UI. -The contract in one line: `ctx.approval.request(req)` puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and always resolves to an outcome, never rejects: an aborted signal yields `cancelled`, a throwing or missing answerer yields `unavailable`, and `allowed-once` is a grant for the single asked-about action, never a class of future ones. The one precondition: ask from inside an open turn — the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask throws before appending anything. +The contract in one line: `ctx.approval.request(req)` puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and always resolves to an outcome, never rejects: an aborted signal yields `cancelled`, a throwing or missing answerer yields `unavailable`, and `allowed-once` is a grant for the single asked-about action, never a class of future ones. Acceptance is synchronous: the service shallow-freezes a detached request record before dispatch, preserving the exact `agent` and `AbortSignal` identities while making later caller mutation unable to redirect scope, payload, cancellation, or either audit event. Session observers run after an event enters the append-only log; if one throws, the service recognizes that the audit is already authoritative, contains the observer failure, and completes the pair. The one precondition: ask from inside an open turn — the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask throws before appending anything. The service is the mechanism, answerers are the policy. Answerers are `approval/request` waterfall listeners occupying a single decision slot: answer for an agent you own by returning an outcome without calling `next()`, or delegate an agent you don't recognize by calling `next()` — the chain's built-in default is `unavailable`, so a deployment with no answerer (headless, CI) fails closed with zero configuration. Dispatch is keyed by `req.agent`: a listener registered through `agent.ctx` receives only that agent's questions, while a plain-context listener receives every agent's. Registration order across sibling plugins is not load-order deterministic; compose one terminal answerer per deployment and use `prepend` listeners only for decide-or-delegate gates. diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 656f799d6a..4fb50ba23b 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -63,7 +63,10 @@ declare module 'cordis' { * 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. - * @param req - the pending decision (agent, tool identity, reason, signal). + * `req` is the service's shallow-frozen acceptance snapshot: later caller + * mutation cannot redirect the question, while the `agent` and `signal` + * identity capabilities remain exact. + * @param req - the accepted decision (agent, tool identity, reason, signal). * @mode waterfall */ 'approval/request'(this: Scoped, req: ApprovalRequest, next: () => Promise): Promise @@ -234,7 +237,9 @@ export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): voi * for an answerer to present it and for the audit events to reconstruct what * was asked — 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. + * of re-rendering the call. `request()` synchronously copies and shallow-freezes + * this record before crossing an asynchronous boundary. Scalar fields are + * detached; the `agent` and `signal` identity capabilities are preserved. */ export interface ApprovalRequest { /** @@ -364,14 +369,36 @@ export class ApprovalService extends Service { * Within that precondition it always resolves to an outcome, never rejects: * an aborted signal yields `'cancelled'`, a missing or throwing answerer * yields `'unavailable'` (fail closed), and a rogue non-vocabulary return - * value is normalized to `'unavailable'`. Appends the + * value is normalized to `'unavailable'`. The caller-owned request is + * synchronously snapshotted, so later mutation cannot split routing, + * dispatch payload, cancellation, or the audit pair across agents/sessions. + * Appends the * `approval/asked`/`approval/decided` audit pair (log-only) around the - * decision regardless of outcome. + * decision regardless of outcome. A synchronous session observer failure + * after an audit event entered the append-only log is contained; the event + * is already authoritative, so the pair still completes and the request + * still resolves. * @param req - the pending decision (agent, tool identity, reason, signal). * @returns the closed outcome; `'allowed-once'` is the only grant. */ async request(req: ApprovalRequest): Promise { - if (!hasOpenTurn(req.agent.session.events)) { + // Accept one immutable request shape before the first async boundary. The + // caller retains its record and may mutate it as soon as this async method + // returns; identity capabilities stay live, but the record is never reread. + const agent = req.agent + const toolName = req.toolName + const callId = req.callId + const reason = req.reason + const signal = req.signal + const accepted: Readonly = Object.freeze({ + agent, + toolName, + ...callId !== undefined ? { callId } : {}, + ...reason !== undefined ? { reason } : {}, + ...signal !== undefined ? { signal } : {}, + }) + const session = accepted.agent.session + if (!hasOpenTurn(session.events)) { throw new Error( 'approval.request() outside an open turn: the approval/asked + approval/decided audit pair ' + 'must be turn-enclosed (a bare event between turns is crash-tail garbage on reload). ' @@ -379,17 +406,47 @@ export class ApprovalService extends Service { ) } const id = ApprovalRequestId(randomUUID()) - req.agent.session.append('approval/asked', { - id, - toolName: req.toolName, - ...req.callId !== undefined ? { callId: req.callId } : {}, - ...req.reason !== undefined ? { reason: req.reason } : {}, + this.appendAudit(session, 'approval/asked', id, () => { + session.append('approval/asked', { + id, + toolName: accepted.toolName, + ...accepted.callId !== undefined ? { callId: accepted.callId } : {}, + ...accepted.reason !== undefined ? { reason: accepted.reason } : {}, + }) + }) + const outcome = await this.decide(accepted) + this.appendAudit(session, 'approval/decided', id, () => { + session.append('approval/decided', { id, outcome }) }) - const outcome = await this.decide(req) - req.agent.session.append('approval/decided', { id, outcome }) return outcome } + /** + * Append one audit event while distinguishing a post-append observer throw + * from a failure that prevented the event entering the log. `Session.append` + * pushes first and then notifies synchronously, so log growth proves the + * event is already authoritative; that observer failure is reported and + * contained so it cannot reject the approval or suppress its matching event. + * @param session - the captured session receiving both audit events. + * @param type - the audit event currently being appended. + * @param id - the request id, used to identify the contained failure. + * @param append - the single concrete `Session.append` call. + */ + private appendAudit( + session: Session, + type: 'approval/asked' | 'approval/decided', + id: ApprovalRequestId, + append: () => void, + ): void { + const length = session.events.length + try { + append() + } catch (error) { + if (session.events.length === length) throw error + this.ctx.logger.warn(`approval request "${id}": ${type} observer threw after the event was appended`) + } + } + /** * The session's effective policy: its own `approval/policy` fold, else the * configured default (the schema already defaulted an omitted policy to @@ -401,8 +458,8 @@ export class ApprovalService extends Service { return effectiveApprovalPolicy(agent.session.events) ?? this.config.policy ?? 'ask' } - /** Dispatch the waterfall, contained and raced against `req.signal`. */ - private async decide(req: ApprovalRequest): Promise { + /** Dispatch the waterfall, contained and raced against the accepted signal. */ + private async decide(req: Readonly): Promise { if (req.signal?.aborted) return 'cancelled' // The 'never' policy is decided HERE, before any dispatch: a listener // registered with `prepend: true` after this service mounts would sit diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index 6b405e10a7..0e69b8dfd7 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { CallId } from '@deepseek-ai/dsh-llm' import { carrierKeyOf, scopeHost } from '@deepseek-ai/dsh-scope' -import { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ApprovalService, { ApprovalOutcome, ApprovalRequest, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' @@ -78,6 +78,138 @@ describe('ApprovalService.request', () => { expect(Object.keys(appended[0]?.data ?? {}).sort()).toEqual(['id', 'toolName']) }) + it('snapshots request identity, scope, payload, and audit before deferred dispatch', async () => { + const ctx = await mounted() + const { agent: acceptedAgent, appended: acceptedAudit } = fakeAgent() + const { agent: replacementAgent, appended: replacementAudit } = fakeAgent() + const host = await scopeHost(ctx, ['approval']) + const acceptedScope = host.mint(acceptedAgent) + const replacementScope = host.mint(replacementAgent) + const dispatchStarted = Promise.withResolvers<'started'>() + const answer = Promise.withResolvers() + const originalSignal = new AbortController().signal + const replacementSignal = new AbortController().signal + let heardBy: 'accepted' | 'replacement' | undefined + let received: ApprovalRequest | undefined + let carrier: unknown + acceptedScope.ctx.on('approval/request', function (req) { + heardBy = 'accepted' + received = req + carrier = carrierKeyOf(this) + dispatchStarted.resolve('started') + return answer.promise + }) + replacementScope.ctx.on('approval/request', function (req) { + heardBy = 'replacement' + received = req + carrier = carrierKeyOf(this) + dispatchStarted.resolve('started') + return answer.promise + }) + const request = requestOf(acceptedAgent, { + toolName: 'original-tool', + callId: CallId('original-call'), + reason: 'original reason', + signal: originalSignal, + }) + + const pending = ctx.approval.request(request) + // request() has returned, but the answerer dispatch is deliberately queued + // in a microtask. Mutating the caller-owned record must not redirect it. + request.agent = replacementAgent + request.toolName = 'mutated-before-dispatch' + request.callId = CallId('mutated-call') + request.reason = 'mutated reason' + request.signal = replacementSignal + await dispatchStarted.promise + // Mutation while the answer is pending must not redirect the final audit. + request.toolName = 'mutated-after-dispatch' + request.reason = 'mutated again' + answer.resolve('allowed-once') + + await expect(pending).resolves.toBe('allowed-once') + expect(heardBy).toBe('accepted') + expect(carrier).toBe(acceptedAgent) + expect(received).not.toBe(request) + expect(Object.isFrozen(received)).toBe(true) + expect(received).toMatchObject({ + agent: acceptedAgent, + toolName: 'original-tool', + callId: 'original-call', + reason: 'original reason', + signal: originalSignal, + }) + expect(acceptedAudit).toHaveLength(2) + expect(acceptedAudit[0]?.data).toMatchObject({ + toolName: 'original-tool', + callId: 'original-call', + reason: 'original reason', + }) + expect(acceptedAudit[1]?.data).toMatchObject({ outcome: 'allowed-once' }) + expect(acceptedAudit[1]?.data['id']).toBe(acceptedAudit[0]?.data['id']) + expect(replacementAudit).toEqual([]) + await host.dispose() + }) + + it('contains an approval/asked observer throw after append and still completes the pair', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(ApprovalService) + const session = ctx.sessions.create(SessionId('asked-observer-throw')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const agent = { session } as unknown as Agent + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + ctx.on('session/event', (_session, event) => { + if (event.type === 'approval/asked') throw new Error('observer failed after asked append') + }) + ctx.on('approval/request', () => Promise.resolve('allowed-once')) + + await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('allowed-once') + + const audit = session.events.filter(event => event.type.startsWith('approval/')) + const asked = session.events.find((event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked') + const decided = session.events.find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided') + expect(audit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided']) + expect(decided?.data.id).toBe(asked?.data.id) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('approval/asked observer threw')) + }) + + it('contains an approval/decided observer throw after append and still resolves', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(ApprovalService) + const session = ctx.sessions.create(SessionId('decided-observer-throw')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const agent = { session } as unknown as Agent + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + ctx.on('session/event', (_session, event) => { + if (event.type === 'approval/decided') throw new Error('observer failed after decided append') + }) + ctx.on('approval/request', () => Promise.resolve('rejected')) + + await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('rejected') + + const audit = session.events.filter(event => event.type.startsWith('approval/')) + const asked = session.events.find((event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked') + const decided = session.events.find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided') + expect(audit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided']) + expect(decided?.data).toMatchObject({ id: asked?.data.id, outcome: 'rejected' }) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('approval/decided observer threw')) + }) + + it('does not misclassify a pre-append failure as an observer failure', async () => { + const ctx = await mounted() + const failure = new Error('append failed before log growth') + const agent = { + session: { + events: [{ type: 'turn/start' }], + append: () => { throw failure }, + }, + } as unknown as Agent + + await expect(ctx.approval.request(requestOf(agent))).rejects.toBe(failure) + }) + it('returns the first answering listener outcome (single decision slot)', async () => { const ctx = await mounted() const { agent } = fakeAgent() diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 32d605c651..67f3c771cb 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -83,6 +83,7 @@ class StubProvider implements SubagentProvider { } return { id: AgentId(`stub-child-${index}`), + started: Promise.resolve(), result, cancel: (reason?: string) => { controlled.cancelled = reason ?? 'cancelled' @@ -222,6 +223,7 @@ describe('dsh-workflow-workerthread', () => { inheritsParentContext: false, start: () => ({ id: AgentId('reject-child'), + started: Promise.resolve(), result: Promise.reject(new Error('backend exploded')), cancel: () => { /* nothing in flight */ }, dispose: () => Promise.resolve(), @@ -245,6 +247,7 @@ describe('dsh-workflow-workerthread', () => { inheritsParentContext: false, start: () => ({ id: AgentId('bad-dispose-child'), + started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }), cancel: () => { /* settled already */ }, dispose: () => Promise.reject(new Error('dispose exploded')), @@ -266,6 +269,7 @@ describe('dsh-workflow-workerthread', () => { inheritsParentContext: false, start: () => ({ id: AgentId('trap-child'), + started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }), cancel: () => { /* settled already */ }, // The rejection VALUE's own coercion throws: a warn built with bare @@ -530,6 +534,7 @@ describe('dsh-workflow-workerthread', () => { }, { once: true }) return { id: AgentId('signal-only-child'), + started: Promise.resolve(), result, // The seam leaves a provider free to honor EITHER cancel channel; // this one deliberately ignores run.cancel() — only the request @@ -572,6 +577,7 @@ describe('dsh-workflow-workerthread', () => { starts += 1 return { id: AgentId('cancel-only-child'), + started: Promise.resolve(), result: new Promise(() => { /* only cancel() ends this child */ }), // Deliberately ignores the request signal — the seam leaves a // provider free to honor ONLY the explicit cancel() channel. @@ -749,6 +755,7 @@ describe('dsh-workflow-workerthread', () => { inheritsParentContext: false, start: () => ({ id: AgentId('doomed-child'), + started: Promise.resolve(), result: new Promise(() => { /* never settles; the reap is the teardown */ }), cancel: (reason?: string) => { cancelled.push(reason ?? 'cancelled') }, dispose: () => Promise.reject(new Error('dispose exploded during reap')), From cb05300ba737a93ea594543340892f2a4a825f85 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:38:38 +0800 Subject: [PATCH 36/64] docs: remove duplicate scoped-event explanation --- .../architecture/2026-07-08-agent-scope-contexts.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md index d0cdefaee8..9fccc392da 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -223,9 +223,7 @@ dispatchScoped(subject, scopeKey, event, arguments): skip listener ``` -The real helpers fuse values that must agree: `agentEvents(context, agent)` supplies the same agent as subject, scope key, and first event argument; session storage captures its carrier once at `enter()`. - -`agentEvents(context, agent)` creates the carrier and injects that same agent as the first event argument. `assembleContextFor(agent)` similarly sets both the agent-facing field and the scope selector. The session store captures its carrier when a session enters because later appends and flushes may occur where the original agent context is no longer available. +The real helpers fuse values that must agree. `agentEvents(context, agent)` uses the same agent as the subject, scope key, and first event argument. `assembleContextFor(agent)` similarly sets both the agent-facing field and the scope selector. The session store captures its carrier when a session enters because later appends and flushes may occur where the original agent context is no longer available. ### The carrier behaves like the subject but has distinct identity From c632b693c676dea22c9b8fc07dcc5b1528889c68 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:50:50 +0800 Subject: [PATCH 37/64] test(e2e): tolerate bounded loader contention --- .../tests/code-mode-keyless-smoke.e2e.ts | 11 ++++++++--- .../coding-agent/tests/keyless-smoke.e2e.ts | 11 ++++++++--- .../cordis-agent/tests/keyless-smoke.e2e.ts | 11 ++++++++--- examples/echo-agent/tests/echo.e2e.ts | 17 +++++++++++------ 4 files changed, 35 insertions(+), 15 deletions(-) diff --git a/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts b/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts index 7894e9ff9f..b25e26ea05 100644 --- a/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts +++ b/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts @@ -26,6 +26,11 @@ const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) // `paths` map; tsx searches UP from cwd, and we spawn from a temp dir outside // the repo, so point it at the repo tsconfig. const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +// The real-API workflow runs up to 14 e2e files at once. Cold tsx/Loader +// startup can therefore outlive a tight smoke-test deadline before the child +// emits any output; 30s still detects a wedged process without confusing +// bounded CI contention with a lifecycle failure. +const PROCESS_TIMEOUT_MS = 30_000 let child: ChildProcessWithoutNullStreams | undefined let workdir: string | undefined @@ -67,8 +72,8 @@ async function bootAndEof(): Promise<{ stdout: string; code: number }> { const timer = setTimeout(() => { proc.kill('SIGKILL') - reject(new Error(`code-mode overlay did not exit within 10s. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, 10_000) + reject(new Error(`code-mode overlay did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }, PROCESS_TIMEOUT_MS) proc.on('exit', (code) => { clearTimeout(timer) @@ -87,5 +92,5 @@ describe('code-mode overlay keyless smoke (real code-mode.cordis.yml via the Loa const { stdout, code } = await bootAndEof() expect(code).toBe(0) expect(stdout).toContain('code-mode agent ready.') - }, 15_000) + }, PROCESS_TIMEOUT_MS + 5_000) }) diff --git a/examples/coding-agent/tests/keyless-smoke.e2e.ts b/examples/coding-agent/tests/keyless-smoke.e2e.ts index b4d73f4d90..b104d8af63 100644 --- a/examples/coding-agent/tests/keyless-smoke.e2e.ts +++ b/examples/coding-agent/tests/keyless-smoke.e2e.ts @@ -35,6 +35,11 @@ const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) // `paths` map; tsx searches UP from cwd, and we spawn from a temp dir outside // the repo, so point it at the repo tsconfig (root is four levels up). const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +// The real-API workflow runs up to 14 e2e files at once. Cold tsx/Loader +// startup can therefore outlive a tight smoke-test deadline before the child +// emits any output; 30s still detects a wedged process without confusing +// bounded CI contention with a lifecycle failure. +const PROCESS_TIMEOUT_MS = 30_000 let child: ChildProcessWithoutNullStreams | undefined let workdir: string | undefined @@ -78,8 +83,8 @@ async function bootAndEof(): Promise<{ stdout: string; code: number }> { const timer = setTimeout(() => { proc.kill('SIGKILL') - reject(new Error(`coding-agent did not exit within 10s. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, 10_000) + reject(new Error(`coding-agent did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }, PROCESS_TIMEOUT_MS) proc.on('exit', (code) => { clearTimeout(timer) @@ -98,5 +103,5 @@ describe('coding-agent keyless smoke (real cordis.yml via the Loader)', () => { const { stdout, code } = await bootAndEof() expect(code).toBe(0) expect(stdout).toContain('agent REPL ready.') - }, 15_000) + }, PROCESS_TIMEOUT_MS + 5_000) }) diff --git a/examples/cordis-agent/tests/keyless-smoke.e2e.ts b/examples/cordis-agent/tests/keyless-smoke.e2e.ts index b37ea8d83e..d170d0b9bf 100644 --- a/examples/cordis-agent/tests/keyless-smoke.e2e.ts +++ b/examples/cordis-agent/tests/keyless-smoke.e2e.ts @@ -29,6 +29,11 @@ const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) // `paths` map; tsx searches UP from cwd, and we spawn from a temp dir outside // the repo, so point it at the repo tsconfig (root is three levels up). const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +// The real-API workflow runs up to 14 e2e files at once. Cold tsx/Loader +// startup can therefore outlive a tight smoke-test deadline before the child +// emits any output; 30s still detects a wedged process without confusing +// bounded CI contention with a lifecycle failure. +const PROCESS_TIMEOUT_MS = 30_000 let child: ChildProcessWithoutNullStreams | undefined let workdir: string | undefined @@ -70,8 +75,8 @@ async function bootAndEof(): Promise<{ stdout: string; code: number }> { const timer = setTimeout(() => { proc.kill('SIGKILL') - reject(new Error(`cordis-agent did not exit within 10s. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, 10_000) + reject(new Error(`cordis-agent did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }, PROCESS_TIMEOUT_MS) proc.on('exit', (code) => { clearTimeout(timer) @@ -90,5 +95,5 @@ describe('cordis-agent keyless smoke (real cordis.yml via the Loader)', () => { const { stdout, code } = await bootAndEof() expect(code).toBe(0) expect(stdout).toContain('cordis-agent ready.') - }, 15_000) + }, PROCESS_TIMEOUT_MS + 5_000) }) diff --git a/examples/echo-agent/tests/echo.e2e.ts b/examples/echo-agent/tests/echo.e2e.ts index b446b2b191..546b69c0f3 100644 --- a/examples/echo-agent/tests/echo.e2e.ts +++ b/examples/echo-agent/tests/echo.e2e.ts @@ -37,6 +37,11 @@ const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) // a temp cwd OUTSIDE the repo, so point tsx at the repo tsconfig explicitly // (repo root is four levels up from examples/echo-agent/tests). const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +// The real-API workflow runs up to 14 e2e files at once. Cold tsx/Loader +// startup can therefore outlive a tight smoke-test deadline before the child +// emits any output; 30s still detects a wedged process without confusing +// bounded CI contention with a lifecycle failure. +const PROCESS_TIMEOUT_MS = 30_000 let child: ChildProcessWithoutNullStreams | undefined let workdir: string | undefined @@ -51,7 +56,7 @@ afterEach(async () => { /** * Boot echo-agent, write `lines` to its stdin, close stdin, and resolve with * the full stdout once the process exits (the stdio UI exits on EOF after the - * agent settles). Rejects on a non-zero exit or a 10s timeout. + * agent settles). Rejects on a non-zero exit or the process deadline. */ async function runEcho(lines: string[]): Promise<{ stdout: string; code: number }> { workdir = await mkdtemp(join(tmpdir(), 'echo-smoke-')) @@ -84,8 +89,8 @@ async function runEcho(lines: string[]): Promise<{ stdout: string; code: number const timer = setTimeout(() => { proc.kill('SIGKILL') - reject(new Error(`echo-agent did not exit within 10s. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, 10_000) + reject(new Error(`echo-agent did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }, PROCESS_TIMEOUT_MS) proc.on('exit', (code) => { clearTimeout(timer) @@ -105,19 +110,19 @@ describe('echo-agent keyless smoke (real cordis.yml via the Loader)', () => { const { stdout, code } = await runEcho([]) expect(code).toBe(0) expect(stdout).toContain('echo-agent ready.') - }, 15_000) + }, PROCESS_TIMEOUT_MS + 5_000) it('runs the echo tool round-trip for an "echo …" line', async () => { const { stdout } = await runEcho(['echo hello world']) // mock-llm.ts emits a tool-call for the echo tool; echo-tool.ts uppercases. expect(stdout).toContain('[tool call] echo') expect(stdout).toContain('[tool result] ECHO: HELLO WORLD') - }, 15_000) + }, PROCESS_TIMEOUT_MS + 5_000) it('streams a direct canned reply for a non-echo line', async () => { const { stdout } = await runEcho(['just chatting']) // The direct-response branch of mock-llm.ts quotes the input back. expect(stdout).toContain('just chatting') expect(stdout).not.toContain('[tool call]') - }, 15_000) + }, PROCESS_TIMEOUT_MS + 5_000) }) From 35715423f8f3321ebe457e1bb53f9074ea6ebf2b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 12 Jul 2026 01:35:09 +0800 Subject: [PATCH 38/64] fix(workflow): honor subagent readiness boundary --- docs/cordis-catalog/events.md | 8 +- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 6 +- .../2026-07-08-agent-scope-contexts.md | 10 ++ .../cordis/tool-cordis/src/api-catalog.ts | 2 +- .../workflow/workflow-workerthread/README.md | 6 +- .../workflow-workerthread/src/host.ts | 69 ++++++-- .../workflow-workerthread/src/protocol.ts | 4 +- .../workflow-workerthread/src/runtime.ts | 5 +- .../workflow-workerthread/src/session.ts | 16 +- .../workflow-workerthread/src/types.ts | 3 +- .../tests/integration.spec.ts | 7 +- .../tests/workflow-workerthread.spec.ts | 161 ++++++++++++++++-- packages/workflow/workflow/README.md | 2 +- packages/workflow/workflow/src/index.ts | 16 +- 15 files changed, 260 insertions(+), 57 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index f7bbecb20f..0c5399b41b 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -431,17 +431,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:96`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:98`](../../packages/workflow/workflow/src/index.ts) ### `workflow/agent-start` — emit -One `agent()` call started a child run. Paired with Events['workflow/agent-end'] by `agent.seq`. +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. ```ts cordis-catalog 'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:85`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:87`](../../packages/workflow/workflow/src/index.ts) ### `workflow/end` — emit @@ -451,7 +451,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:106`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:108`](../../packages/workflow/workflow/src/index.ts) ### `workflow/log` — emit diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index f3cf92f425..ddf2a860e4 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -331,7 +331,7 @@ Semantics every implementation must honor: abstract start(request: WorkflowStartRequest): WorkflowRun ``` -Source: [`packages/workflow/workflow/src/index.ts:210`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:214`](../../packages/workflow/workflow/src/index.ts) ## Inherited `ctx` members (cordis core + loader/hmr/timer) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 879c0ac2e4..1ed2405671 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -41,9 +41,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:151`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:104`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `tools/result` | `parallel` | [`packages/core/tools/src/index.ts:166`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | -| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts: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/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:98`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:87`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:108`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | | `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:77`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | | `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | | `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:62`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md index 9fccc392da..9091965a59 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -553,8 +553,18 @@ SubagentService.start(...): attach result settlement handlers immediately await returnedRun.started emit subagent/start; later emit the buffered or eventual subagent/end + +Workflow worker bridge after receiving returnedRun: + register the run so cancellation can reach pre-publication work + attach result settlement handlers immediately and snapshot the outcome + if returnedRun.started fulfills: + send ChildStarted; then send the buffered or eventual outcome + else: + send ChildStartError and dispose the attempt ``` +Every downstream protocol that announces a subagent must honor the same boundary. The workflow worker bridge therefore registers the returned run before waiting, observes and snapshots `result` immediately, sends `ChildStarted` only after `started` fulfills, and sends `ChildStartError` plus host-driven disposal when readiness rejects. This keeps cancellation able to reach pending creation, prevents an early result rejection from going unhandled, and ensures `workflow/agent-start` never names an unpublished child. + Parent teardown reaches `runOwner` by nesting; the provider and returned run handle reach the same node through their explicit disposers. ### Persona, filtering, and lifetime use ordinary registrations diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index e56f675bb2..7f7882f2b7 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -453,7 +453,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'workflow/agent-start', mode: 'emit', signature: '\'workflow/agent-start\'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void', - summary: 'One `agent()` call started a child run.', + summary: 'One `agent()` call established a ready child run.', }, { name: 'workflow/end', diff --git a/packages/workflow/workflow-workerthread/README.md b/packages/workflow/workflow-workerthread/README.md index 1ee959cc0c..86d386350a 100644 --- a/packages/workflow/workflow-workerthread/README.md +++ b/packages/workflow/workflow-workerthread/README.md @@ -23,7 +23,11 @@ What the seam guarantees regardless, because benign scripts hit these constantly `start()` shape-validates the meta DATA host-side and parse-checks the body with the identical wrapper the worker compiles (`new vm.Script`, discarded), preserving the seam's synchronous `META_INVALID`/`SCRIPT_PARSE` throws; one redundant parse per run is the deliberate price. It then spawns the worker (`src/worker.ts` unbuilt via an explicit tsx `execArgv`; the sibling `lib/worker.js` bundle when built) with the meta, body, `args`, and worker-side limits as `workerData`. -Inside the worker, `runWorkerSession` builds the execution core (hooks, combinators, concurrency semaphore, caps, fatal-error discipline) over a **child port**: `agent()` sends `child-start` and the host starts the child on `ctx.subagents` (parent attribution, the shared per-run abort signal, `outputSchema`/`model` pass-through), replying with the child id, its settlement (a JSON projection; an infrastructure REJECTION crosses as `child-failed` and stays the fatal `AGENT_RESULT`), and dispose acks. Observer narration (`phase`/`log`/`agent-start`/`agent-end`) crosses as messages and re-emits as the seam's `workflow/*` events. A **ready→go handshake** gates the body: a cancellation racing worker boot arrives before `go`, so a run cancelled before start never executes the body at all. +Inside the worker, `runWorkerSession` builds the execution core (hooks, combinators, concurrency semaphore, caps, fatal-error discipline) over a **child port**. `agent()` sends `child-start`, and the host starts the child on `ctx.subagents` with parent attribution, the shared per-run abort signal, and `outputSchema`/`model` pass-through. + +The host observes `run.result` immediately but buffers its snapshotted wire projection until `run.started` fulfills. It then replies `child-started` with the child id before forwarding settlement, so `workflow/agent-start` always names a ready child and precedes its end. A readiness rejection replies `child-start-error`, emits no workflow agent pair, and makes the host dispose the attempt because the worker never received a handle; the worker classifies it as fatal `AGENT_START` unless cancellation already owns the run. If readiness fulfills, an infrastructure result rejection crosses as `child-failed`/`AGENT_RESULT` regardless of whether that rejection settled before or after readiness. Child disposal acknowledgements complete the RPC. + +Observer narration (`phase`/`log`/`agent-start`/`agent-end`) crosses as messages and re-emits as the seam's `workflow/*` events. A **ready→go handshake** gates the body: a cancellation racing worker boot arrives before `go`, so a run cancelled before start never executes the body at all. ## The value boundary diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index 6b6391a02e..fff9d8496f 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -14,12 +14,16 @@ * (a script that never settles is force-settled `cancelled` and its worker * terminated — the real kill an in-process engine could not perform). * - * Children live in a host-side registry (callId → run): the worker drives - * their disposal by RPC on the graceful path, `dispose()` host-drives every - * registered child's disposal immediately (a wedged worker can relay no - * dispose RPC, and child teardown must overlap the grace, not start after - * it), and the registry is what lets the host abort and dispose every - * survivor when the worker dies or is terminated mid-flight. The three + * Children live in a host-side registry (callId → run) as soon as the provider + * accepts them, so cancellation reaches even a pre-publication attempt. The + * host observes `result` immediately but acknowledges the child to the worker + * only after `started` fulfills; readiness failure is a start error and the + * host disposes the attempt because the worker never received a handle. The + * worker drives disposal by RPC on the graceful path, `dispose()` host-drives + * every registered child's disposal immediately (a wedged worker can relay no + * dispose RPC, and child teardown must overlap the grace, not start after it), + * and the registry lets the host abort and dispose every survivor when the + * worker dies or is terminated mid-flight. The three * paths share ONE disposal per child (memoized by callId; the seam's * dispose() is idempotent anyway, the memo keeps the bookkeeping and the * containment warn single). Lifecycle pairing is host-guaranteed the same @@ -46,7 +50,7 @@ import { renderThrown } from './realm.ts' import type { ExecutionObserver } from './runtime.ts' import { HostToWorkerType, WorkerToHostType } from './protocol.ts' import type { HostToWorkerPayloads, WorkerToHostMessage } from './protocol.ts' -import type { ChildStartRequest, WorkerInit } from './types.ts' +import type { ChildResult, ChildStartRequest, WorkerInit } from './types.ts' /** * Resolve the worker entry and spawn options for the current runtime shape. @@ -307,19 +311,49 @@ export class WorkerRun implements WorkflowRun { return } this.children.set(callId, run) - this.post(HostToWorkerType.ChildStarted, { callId, childId: run.id }) - run.result.then( + const childId = run.id + + // Observe settlement IMMEDIATELY, before readiness. A provider may reject + // result and started in the same turn; delaying this handler would make the + // result transiently unhandled. Buffer a forwarding closure so the worker + // still sees ChildStarted before ChildSettled/ChildFailed. Snapshot a + // resolved result now: a provider mutating its resolved object while + // publication is pending must not change what crosses the worker boundary. + const forwardResult = run.result.then<() => void, () => void>( (result) => { - this.post(HostToWorkerType.ChildSettled, { - callId, - result: { + try { + const snapshot: ChildResult = structuredClone({ output: result.output, ...result.structured !== undefined ? { structured: result.structured } : {}, stopReason: result.stopReason, - }, - }) + }) + return () => { this.post(HostToWorkerType.ChildSettled, { callId, result: snapshot }) } + } catch (error: unknown) { + const rendered = `workflow child result could not cross the worker boundary: ${renderThrown(error)}` + return () => { this.post(HostToWorkerType.ChildFailed, { callId, rendered }) } + } + }, + (error: unknown) => { + const rendered = renderThrown(error) + return () => { this.post(HostToWorkerType.ChildFailed, { callId, rendered }) } + }, + ) + + // The provider owns the publication boundary. Only acknowledge the child + // after it is real, then flush any result that settled unusually early. A + // readiness rejection is a START failure, not AGENT_RESULT: the worker + // never receives a handle, so the host must also dispose the registered + // attempt. A concurrent host disposal may already have removed it; the + // identity guard preserves the one-disposal memo in that race. + void run.started.then( + () => { + this.post(HostToWorkerType.ChildStarted, { callId, childId }) + void forwardResult.then((forward) => { forward() }) + }, + (error: unknown) => { + this.post(HostToWorkerType.ChildStartError, { callId, rendered: renderThrown(error) }) + if (this.children.get(callId) === run) void this.disposeChild(callId, run) }, - (error: unknown) => { this.post(HostToWorkerType.ChildFailed, { callId, rendered: renderThrown(error) }) }, ) } @@ -350,7 +384,10 @@ export class WorkerRun implements WorkflowRun { private disposeChild(callId: number, run: SubagentRun): Promise { let disposal = this.childDisposals.get(callId) if (disposal === undefined) { - disposal = run.dispose().then( + // The seam promises a Promise, but invoke inside an async boundary so a + // contract-violating synchronous throw is contained exactly like a + // rejected disposal and cannot break host quiescence. + disposal = (async () => { await run.dispose() })().then( () => { this.finishChild(callId) }, (error: unknown) => { this.ctx.logger.warn(`workflow-workerthread: child dispose failed: ${renderThrown(error)}`) diff --git a/packages/workflow/workflow-workerthread/src/protocol.ts b/packages/workflow/workflow-workerthread/src/protocol.ts index f6e85ee509..d70ad11613 100644 --- a/packages/workflow/workflow-workerthread/src/protocol.ts +++ b/packages/workflow/workflow-workerthread/src/protocol.ts @@ -69,9 +69,9 @@ export enum HostToWorkerType { Go = 'go', /** Cancel the run: hooks start throwing and the script dies at its next await. */ Cancel = 'cancel', - /** Child RPC reply: the start succeeded (exactly one of ChildStarted/ChildStartError per ChildStart). */ + /** Child RPC reply: provider publication/readiness fulfilled (exactly one start reply per ChildStart). */ ChildStarted = 'child-started', - /** Child RPC reply: the start was refused or threw. */ + /** Child RPC reply: synchronous start or asynchronous publication/readiness failed. */ ChildStartError = 'child-start-error', /** Child RPC: a started child's result RESOLVED (its JSON projection). */ ChildSettled = 'child-settled', diff --git a/packages/workflow/workflow-workerthread/src/runtime.ts b/packages/workflow/workflow-workerthread/src/runtime.ts index 645a61b6c4..2add6d537c 100644 --- a/packages/workflow/workflow-workerthread/src/runtime.ts +++ b/packages/workflow/workflow-workerthread/src/runtime.ts @@ -19,8 +19,9 @@ * (a benign-bug guard; the postMessage clone already isolated the caller). * * Failure discipline: fatal {@link WorkflowError}s (bad hook arguments, - * unsupported options/schemas, tripped caps, host start refusals and child - * result rejections, cancellation) ALWAYS propagate through + * unsupported options/schemas, tripped caps, synchronous start refusal, + * pre-publication readiness failure, ready-child result rejection, and + * cancellation) ALWAYS propagate through * `parallel`/`pipeline` — recognized by `instanceof` against this realm's * class, which a script inside the vm context cannot forge — and the per-item * `null` is reserved for child-run failures and ordinary in-stage script diff --git a/packages/workflow/workflow-workerthread/src/session.ts b/packages/workflow/workflow-workerthread/src/session.ts index 718b8ac54d..b159892021 100644 --- a/packages/workflow/workflow-workerthread/src/session.ts +++ b/packages/workflow/workflow-workerthread/src/session.ts @@ -89,24 +89,26 @@ class ChildRpcBridge implements ChildPort { settled: Promise.withResolvers(), disposed: Promise.withResolvers(), } - // Containment: when the start is refused (or the run torn down) the - // settled promise may never gain a consumer — it must not surface as an - // unhandled rejection and kill the worker. - entry.settled.promise.catch(() => { /* consumed: unconsumed child settlement after a refused start */ }) + // Containment: when synchronous start or asynchronous readiness fails (or + // the run is torn down), the settled promise may never gain a consumer — + // it must not surface as an unhandled rejection and kill the worker. + entry.settled.promise.catch(() => { /* consumed: unconsumed child settlement after failed start/readiness */ }) this.pending.set(callId, entry) this.post(WorkerToHostType.ChildStart, { callId, request }) const childId = await entry.started.promise return new RpcChildHandle(this.post, callId, entry, childId) } - /** The host started the child; releases the `startAgent` await. */ + /** The host established a ready child; releases the `startAgent` await. */ onChildStarted(callId: number, childId: string): void { this.pending.get(callId)?.started.resolve(childId) } - /** The host refused the start; `startAgent` rejects with the rendered cause. */ + /** Synchronous start or asynchronous readiness failed; reject and retire the pending RPC. */ onChildStartError(callId: number, rendered: string): void { - this.pending.get(callId)?.started.reject(new Error(rendered)) + const entry = this.pending.get(callId) + this.pending.delete(callId) + entry?.started.reject(new Error(rendered)) } /** The child's terminal result arrived. */ diff --git a/packages/workflow/workflow-workerthread/src/types.ts b/packages/workflow/workflow-workerthread/src/types.ts index a80b126a2d..ee5faccdda 100644 --- a/packages/workflow/workflow-workerthread/src/types.ts +++ b/packages/workflow/workflow-workerthread/src/types.ts @@ -91,7 +91,8 @@ export interface ChildPort { /** * Start one child agent on the host (the `agent()` hook's start half). * @param request - the prompt and validated options. - * @returns the child handle; rejects when the host refuses the start. + * @returns the ready child handle; rejects when synchronous start or the + * provider's asynchronous publication/readiness boundary fails. */ startAgent(request: ChildStartRequest): Promise } diff --git a/packages/workflow/workflow-workerthread/tests/integration.spec.ts b/packages/workflow/workflow-workerthread/tests/integration.spec.ts index d6e8237804..51ace9253b 100644 --- a/packages/workflow/workflow-workerthread/tests/integration.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/integration.spec.ts @@ -48,7 +48,12 @@ describe('dsh-workflow-workerthread over the real in-process stack', () => { toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { verdict: 'real', confidence: 0.9 }), ]) const childIds: string[] = [] - ctx.on('workflow/agent-start', (_info, agent) => { childIds.push(agent.childId) }) + ctx.on('workflow/agent-start', (_info, agent) => { + // The workflow bridge must honor SubagentRun.started: a start observer + // sees the real spawn child already published, never a reserved id. + expect(ctx.agents.get(agent.childId)).toBeDefined() + childIds.push(agent.childId) + }) const run = ctx.workflows.start({ meta: { name: 'integration', description: 'plain + structured children' }, script: `phase('Read') diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 67f3c771cb..23d4de0f07 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -8,7 +8,7 @@ import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import type { WorkflowMeta, WorkflowResult, WorkflowResultInfo, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' import * as workerEngineModule from '../src/index.ts' -import WorkerWorkflowEngine, { type Config } from '../src/index.ts' +import WorkerWorkflowEngine, { HostToWorkerType, type Config } from '../src/index.ts' /** A minimal parent stand-in: the engine only threads it through to the provider. */ function fakeParent(): Agent { @@ -47,7 +47,12 @@ const ESCAPE = "globalThis.constructor.constructor('return process')()" /** One controllable child run: the test (or auto mode) settles it. */ interface ControlledRun { request: SubagentStartRequest + /** Fulfill the provider publication/readiness boundary. */ + publish(): void + /** Reject the provider publication/readiness boundary. */ + rejectStart(error: unknown): void settle(result: SubagentResult): void + rejectResult(error: unknown): void cancelled: string | undefined disposed: boolean disposeCalls: number @@ -68,26 +73,37 @@ class StubProvider implements SubagentProvider { readonly name: string, private readonly reply?: (request: SubagentStartRequest, index: number) => SubagentResult, private readonly disposeDelayMs = 0, + private readonly deferStart = false, ) {} start(request: SubagentStartRequest): SubagentRun { - let settle!: (result: SubagentResult) => void - const result = new Promise((resolve) => { settle = resolve }) - const controlled: ControlledRun = { request, settle, cancelled: undefined, disposed: false, disposeCalls: 0 } + const readiness = Promise.withResolvers() + const terminal = Promise.withResolvers() + const controlled: ControlledRun = { + request, + publish: () => { readiness.resolve(undefined) }, + rejectStart: (error) => { readiness.reject(error) }, + settle: (result) => { terminal.resolve(result) }, + rejectResult: (error) => { terminal.reject(error) }, + cancelled: undefined, + disposed: false, + disposeCalls: 0, + } this.runs.push(controlled) const index = this.runs.length - 1 - request.signal?.addEventListener('abort', () => { settle({ output: [], stopReason: 'aborted' }) }, { once: true }) + request.signal?.addEventListener('abort', () => { terminal.resolve({ output: [], stopReason: 'aborted' }) }, { once: true }) + if (!this.deferStart) readiness.resolve(undefined) if (this.reply) { const reply = this.reply - queueMicrotask(() => { settle(reply(request, index)) }) + queueMicrotask(() => { terminal.resolve(reply(request, index)) }) } return { id: AgentId(`stub-child-${index}`), - started: Promise.resolve(), - result, + started: readiness.promise, + result: terminal.promise, cancel: (reason?: string) => { controlled.cancelled = reason ?? 'cancelled' - settle({ output: [], stopReason: 'aborted' }) + terminal.resolve({ output: [], stopReason: 'aborted' }) }, dispose: () => { controlled.disposeCalls += 1 @@ -116,6 +132,7 @@ interface SetupOptions { reply?: (request: SubagentStartRequest, index: number) => SubagentResult manual?: boolean disposeDelayMs?: number + deferStart?: boolean } async function setup(options?: SetupOptions) { @@ -125,6 +142,7 @@ async function setup(options?: SetupOptions) { 'stub', options?.manual ? undefined : options?.reply ?? (() => text('stub reply')), options?.disposeDelayMs ?? 0, + options?.deferStart ?? false, ) ctx.subagents.registerProvider(provider) // A fixed concurrency ceiling: the auto-resolved default is machine-derived @@ -214,6 +232,116 @@ describe('dsh-workflow-workerthread', () => { expect(result.error).toContain('agent() could not start a child') }) + it('waits for child readiness before announcing it and snapshots a result that settled early', async () => { + const { ctx, parent, provider } = await setup({ manual: true, deferStart: true }) + const order: string[] = [] + ctx.on('workflow/agent-start', (_info, agent) => { order.push(`start:${agent.seq}`) }) + ctx.on('workflow/agent-end', (_info, agent) => { order.push(`end:${agent.outcome}`) }) + ctx.on('workflow/end', () => { order.push('run-end') }) + + const handle = ctx.workflows.start({ ...scripted("return await agent('p')"), parent }) + await waitFor(() => { expect(provider.runs.length).toBe(1) }) + const early = text('accepted value') + provider.runs[0]!.settle(early) + // Let the host observe + snapshot result while readiness remains pending. + await new Promise(resolve => setTimeout(resolve, 0)) + const earlyText = early.output[0] as { type: 'text'; text: string } + earlyText.text = 'mutated after settlement' + expect(order).toEqual([]) + + provider.runs[0]!.publish() + const result = await handle.result + expect(result.value).toBe('accepted value') + expect(order).toEqual(['start:1', 'end:completed', 'run-end']) + await handle.dispose() + expect(provider.runs[0]!.disposeCalls).toBe(1) + }) + + it('observes an early result rejection but sends ChildStarted before ChildFailed after readiness', async () => { + const { ctx, parent, provider } = await setup({ manual: true, deferStart: true }) + const lifecycle: string[] = [] + ctx.on('workflow/agent-start', () => { lifecycle.push('start') }) + ctx.on('workflow/agent-end', (_info, agent) => { lifecycle.push(`end:${agent.outcome}`) }) + const handle = ctx.workflows.start({ + ...scripted("try { await agent('p'); return 'unreachable' } catch (e) { return { code: e.code, message: e.message } }"), + parent, + }) + const worker = (handle as unknown as { worker: { postMessage(message: unknown): void } }).worker + const post = vi.spyOn(worker, 'postMessage') + const childMessageTypes = (): HostToWorkerType[] => post.mock.calls + .map(([message]) => (message as { type: HostToWorkerType }).type) + .filter(type => type === HostToWorkerType.ChildStarted || type === HostToWorkerType.ChildFailed) + + await waitFor(() => { expect(provider.runs.length).toBe(1) }) + provider.runs[0]!.rejectResult(new Error('backend failed before publication')) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(childMessageTypes()).toEqual([]) + expect(lifecycle).toEqual([]) + + provider.runs[0]!.publish() + const result = await handle.result + expect(result.value).toMatchObject({ code: 'AGENT_RESULT' }) + expect((result.value as { message: string }).message).toContain('backend failed before publication') + expect(childMessageTypes()).toEqual([HostToWorkerType.ChildStarted, HostToWorkerType.ChildFailed]) + expect(lifecycle).toEqual(['start', 'end:failed']) + post.mockRestore() + await handle.dispose() + }) + + it('classifies readiness rejection as AGENT_START, drops an early result, and emits no false lifecycle pair', async () => { + const { ctx, parent, provider } = await setup({ manual: true, deferStart: true }) + const lifecycle: string[] = [] + ctx.on('workflow/agent-start', () => { lifecycle.push('start') }) + ctx.on('workflow/agent-end', () => { lifecycle.push('end') }) + + const handle = ctx.workflows.start({ + ...scripted("try { await agent('p'); return 'unreachable' } catch (e) { return { code: e.code, message: e.message } }"), + parent, + }) + await waitFor(() => { expect(provider.runs.length).toBe(1) }) + // ACP-style failure can settle result(error) before its session/publication + // boundary rejects. Readiness must dominate that buffered child outcome. + provider.runs[0]!.settle({ output: [], stopReason: 'error' }) + await new Promise(resolve => setTimeout(resolve, 0)) + provider.runs[0]!.rejectStart(new Error('publication rolled back')) + + const result = await handle.result + expect(result.value).toMatchObject({ code: 'AGENT_START' }) + expect((result.value as { message: string }).message).toContain('publication rolled back') + expect(lifecycle).toEqual([]) + await waitFor(() => { + expect(provider.runs[0]!.disposed).toBe(true) + expect(provider.runs[0]!.disposeCalls).toBe(1) + }) + await handle.dispose() + expect(provider.runs[0]!.disposeCalls).toBe(1) + }) + + it('cancels and disposes a readiness-pending child once without publishing workflow lifecycle', async () => { + const { ctx, parent, provider } = await setup({ manual: true, deferStart: true, config: { disposeGraceMs: 500 } }) + const lifecycle: string[] = [] + ctx.on('workflow/agent-start', () => { lifecycle.push('start') }) + ctx.on('workflow/agent-end', () => { lifecycle.push('end') }) + + const handle = ctx.workflows.start({ ...scripted("return await agent('pending')"), parent }) + await waitFor(() => { expect(provider.runs.length).toBe(1) }) + const disposal = handle.dispose() + await waitFor(() => { + expect(provider.runs[0]!.cancelled).toBe('workflow disposed') + expect(provider.runs[0]!.disposed).toBe(true) + }) + // Ensure the host-driven disposal removed the registry entry before the + // late readiness rejection; its callback must not invoke dispose again. + await new Promise(resolve => setTimeout(resolve, 0)) + provider.runs[0]!.rejectStart(new Error('cancelled before publication')) + + const result = await handle.result + await disposal + expect(result.stopReason).toBe('cancelled') + expect(lifecycle).toEqual([]) + expect(provider.runs[0]!.disposeCalls).toBe(1) + }) + it('a child result REJECTION crosses back as a fatal AGENT_RESULT error (a broken provider is not a failed child)', async () => { const ctx = new Context() await ctx.plugin(SubagentService) @@ -238,7 +366,18 @@ describe('dsh-workflow-workerthread', () => { expect((result.value as { message: string }).message).toContain('backend exploded') }) - it('a child whose dispose() rejects cannot wedge the script (the host acks anyway)', async () => { + it('maps an uncloneable ready-child result to fatal AGENT_RESULT instead of wedging the bridge', async () => { + const { ctx, parent } = await setup({ + reply: () => ({ output: [], structured: () => { /* deliberately not cloneable */ }, stopReason: 'completed' }), + }) + const result = await run(ctx, parent, scripted(` + try { await agent('p'); return 'unreachable' } catch (e) { return { code: e.code, message: e.message } } + `)) + expect(result.value).toMatchObject({ code: 'AGENT_RESULT' }) + expect((result.value as { message: string }).message).toContain('could not cross the worker boundary') + }) + + it('a child whose dispose() throws synchronously cannot wedge the script (the host acks anyway)', async () => { const ctx = new Context() await ctx.plugin(SubagentService) const provider: SubagentProvider = { @@ -250,7 +389,7 @@ describe('dsh-workflow-workerthread', () => { started: Promise.resolve(), result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }), cancel: () => { /* settled already */ }, - dispose: () => Promise.reject(new Error('dispose exploded')), + dispose: () => { throw new Error('dispose exploded') }, }), } ctx.subagents.registerProvider(provider) diff --git a/packages/workflow/workflow/README.md b/packages/workflow/workflow/README.md index 5ff8028b0d..5332243a85 100644 --- a/packages/workflow/workflow/README.md +++ b/packages/workflow/workflow/README.md @@ -22,7 +22,7 @@ All observe-only emits carrying DATA SNAPSHOTS (`WorkflowRunInfo` = id + meta) - `workflow/start`(info) / `workflow/end`(info, resultInfo) — run lifecycle; `resultInfo` deliberately omits the value. - `workflow/phase`(info, title) / `workflow/log`(info, message) — script narration. -- `workflow/agent-start`(info, agent) / `workflow/agent-end`(info, agent + outcome) — one pair per `agent()` call that STARTED a child run (a call rejected at validation or caps, refused at start, or cancelled while queued for a slot emits no pair), correlated by `seq`. +- `workflow/agent-start`(info, agent) / `workflow/agent-end`(info, agent + outcome) — ready-child lifecycle correlated by `seq`; the [generated event contract](../../../docs/cordis-catalog/events.md#workflowagent-start--emit) defines publication and pairing. ## Non-goals (this cut) diff --git a/packages/workflow/workflow/src/index.ts b/packages/workflow/workflow/src/index.ts index 91caa314dc..19f52b5d88 100644 --- a/packages/workflow/workflow/src/index.ts +++ b/packages/workflow/workflow/src/index.ts @@ -76,8 +76,10 @@ declare module 'cordis' { */ 'workflow/log'(info: WorkflowRunInfo, message: string): void /** - * One `agent()` call started a child run. Paired with - * {@link Events['workflow/agent-end']} by `agent.seq`. + * One `agent()` call established a ready child run. Paired with + * {@link Events['workflow/agent-end']} by `agent.seq`. A call that never + * crosses the provider's publication/readiness boundary emits neither + * event in this pair. * @param info - the run's identity snapshot. * @param agent - the call's sequence number, label, phase, and child id. * @mode emit @@ -129,10 +131,12 @@ export type WorkflowEventName = * - `UNSUPPORTED_SCHEMA` — an `agent()` schema outside the structured-output * subset (see dsh-tools). * - `AGENT_CAP` / `ITEM_CAP` — the run/agent caps tripped. - * - `AGENT_START` — the subagent seam refused to start a child. - * - `AGENT_RESULT` — a child's `result` REJECTED: an infrastructure fault at - * the subagent seam, distinct from a child that failed and resolved (which - * is the per-item `null`, never an error). + * - `AGENT_START` — synchronous subagent start or the provider's asynchronous + * publication/readiness boundary failed before cancellation took precedence. + * - `AGENT_RESULT` — a run whose readiness FULFILLED had its `result` REJECT: an + * infrastructure fault at the subagent seam, even if the rejection settled + * before readiness. This is distinct from a child that failed and resolved + * (which is the per-item `null`, never an error). * - `RESULT_UNSERIALIZABLE` — a value crossing the script/host value boundary * is not plain JSON data. * - `CANCELLED` — the run was cancelled; pending and future hooks reject From 36b837002718c23cd87d277e438c0fc74336bbc4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:51:55 +0800 Subject: [PATCH 39/64] fix(scope): close remaining ownership boundaries --- docs/config-catalog.md | 23 +- docs/cookbook/adding-a-tool.md | 6 +- docs/cordis-catalog/events.md | 16 +- docs/cordis-catalog/services.md | 12 +- docs/core-data-structures/skills.md | 10 +- docs/core-data-structures/tools.md | 4 +- docs/event-producer-consumer.md | 16 +- docs/module-graph.md | 43 +- docs/persistence-catalog.md | 30 +- docs/rfc/INDEX.md | 2 +- ...06-11-dev-invariants-over-deep-readonly.md | 53 +- .../2026-07-08-agent-scope-contexts.md | 76 ++- .../2026-06-21-subagent-capability-seam.md | 2 +- .../feature/2026-06-30-interception-seams.md | 6 +- .../2026-06-11-immutable-public-surfaces.md | 9 +- .../compact-basic/tests/compact-basic.spec.ts | 2 +- .../tests/compact-loop-repro.spec.ts | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/core/agent-loop/README.md | 4 +- packages/core/agent-loop/src/index.ts | 28 +- packages/core/agent-loop/tests/resume.spec.ts | 66 ++- .../agent-loop/tests/review-fixes.spec.ts | 12 +- .../agent-loop/tests/scope-lifecycle.spec.ts | 32 +- packages/core/agent/README.md | 2 +- packages/core/agent/src/index.ts | 13 +- packages/core/scope/README.md | 2 +- packages/core/scope/src/index.ts | 10 +- packages/core/scope/tests/scope.spec.ts | 10 + packages/core/session/README.md | 19 +- packages/core/session/src/index.ts | 324 ++++++++--- packages/core/session/src/json.ts | 133 ++++- packages/core/session/src/types.ts | 6 +- packages/core/session/tests/fork.spec.ts | 7 +- packages/core/session/tests/json.spec.ts | 152 +++++ packages/core/session/tests/session.spec.ts | 521 +++++++++++++++++- packages/core/system-prompt/README.md | 6 +- packages/core/system-prompt/package.json | 2 + packages/core/system-prompt/src/index.ts | 55 +- .../system-prompt/tests/system-prompt.spec.ts | 24 + .../system-prompt/tests/tool-order.spec.ts | 94 ++++ packages/core/system-prompt/tsconfig.json | 3 + packages/core/tools/README.md | 8 +- packages/core/tools/src/index.ts | 241 +++++--- packages/core/tools/src/schema.ts | 43 +- packages/core/tools/tests/scoped.spec.ts | 140 ++++- packages/core/tools/tests/tools.spec.ts | 237 +++++++- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-sqlite/README.md | 2 +- .../session-persistence/README.md | 2 +- .../session-persistence/src/coordinator.ts | 47 +- .../session-persistence/src/index.ts | 28 +- .../session-persistence/tests/contract.ts | 2 +- .../tests/coordinator-contract.ts | 7 +- .../tests/persistence.spec.ts | 15 +- packages/skill/skill/README.md | 14 +- packages/skill/skill/src/index.ts | 245 +++++++- packages/skill/skill/tests/skill.spec.ts | 502 +++++++++++++++++ .../subagent-fork/tests/subagent-fork.spec.ts | 7 +- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/src/index.ts | 81 +-- .../subagent-inprocess/src/structured.ts | 4 +- .../tests/subagent-inprocess.spec.ts | 96 +++- packages/subagent/subagent/README.md | 8 +- packages/subagent/subagent/package.json | 2 + packages/subagent/subagent/src/index.ts | 210 +++++-- .../subagent/subagent/tests/service.spec.ts | 446 ++++++++++++++- packages/subagent/subagent/tsconfig.json | 3 + packages/support/README.md | 2 +- packages/support/invariants/README.md | 21 +- packages/support/invariants/package.json | 2 +- packages/support/invariants/src/index.ts | 73 +-- .../invariants/tests/invariants.spec.ts | 154 +++--- .../subagent-mock/tests/subagent-mock.spec.ts | 3 +- .../workflow/workflow-workerthread/README.md | 4 +- .../workflow-workerthread/package.json | 1 + .../workflow-workerthread/src/host.ts | 64 ++- .../tests/workflow-workerthread.spec.ts | 134 ++++- .../workflow-workerthread/tsconfig.json | 3 + pnpm-lock.yaml | 80 +-- 79 files changed, 3957 insertions(+), 817 deletions(-) create mode 100644 packages/core/session/tests/json.spec.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 18ef659ee0..d7b7be2d88 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -343,24 +343,6 @@ export interface Config { Source: [`packages/hooks/hooks-codex/src/index.ts:43`](../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:48`](../packages/support/invariants/src/index.ts) - ## `@deepseek-ai/dsh-llm-deepseek` Requires: `llm` @@ -583,7 +565,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill/src/index.ts:112`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:114`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` @@ -808,7 +790,7 @@ export interface Config { } ``` -Source: [`packages/core/system-prompt/src/index.ts:264`](../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:265`](../packages/core/system-prompt/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -1168,6 +1150,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)) diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 5eede7ff5d..3832f44dd0 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -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 snapshots your definition.** Parameters must be losslessly JSON-serializable; the registry reads them once and materializes the detached stored value in one recursive pass, copies the scalar fields, binds each callback once to your definition as its method receiver, and freezes the stored record. Reassigning `definition.execute` after registration does not hot-swap the tool—dispose and register a new definition through the owning effect instead. Deliberate mutable state inside the callback's closure or receiver remains ordinary plugin state. +- **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: ''}})` 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). diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 0c5399b41b..f5a0533842 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -285,7 +285,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:132`](../../packages/skill/skill/src/index.ts) ### `skill/provider-removed` — emit @@ -295,7 +295,7 @@ 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:138`](../../packages/skill/skill/src/index.ts) ## `subagent/*` @@ -307,7 +307,7 @@ A started subagent run settled — emitted when SubagentRun.result resolves (any 'subagent/end'(this: Scoped, info: SubagentRunEndInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:114`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:115`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -317,7 +317,7 @@ A provider became resolvable in the SubagentService registry. Consumers that der 'subagent/provider-added'(provider: SubagentProvider): void ``` -Source: [`packages/subagent/subagent/src/index.ts:75`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:76`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -327,7 +327,7 @@ A provider left the registry (its plugin's fiber was disposed — an unload or a 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:86`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:87`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit @@ -337,7 +337,7 @@ A subagent run started — emitted only after SubagentRun.started fulfills, when 'subagent/start'(this: Scoped, info: SubagentRunInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:101`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:102`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` @@ -349,7 +349,7 @@ Waterfall around prompt assembly — mutate or extend the PromptAssembly (sectio 'system-prompt/assemble'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:45`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:46`](../../packages/core/system-prompt/src/index.ts) ### `system-prompt/change` — emit @@ -359,7 +359,7 @@ A section, tool provider, variable provider, or protection was registered or unr 'system-prompt/change'(): void ``` -Source: [`packages/core/system-prompt/src/index.ts:55`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:56`](../../packages/core/system-prompt/src/index.ts) ## `tools/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ddf2a860e4..903c368b7c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -40,7 +40,7 @@ list(): Agent[] Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:169`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:174`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` @@ -189,7 +189,7 @@ Contracts every implementation MUST honor (a DB backend asserts them inside a tr - **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 data.** `SessionEventMap` is merge-extensible and `event.data` is typed only as `SessionEventMap[K]`, so append REJECTS non-JSON-serializable data with an error naming the offending event type. A backend snapshots (serializes/clones) each event when it buffers, since `session.events` hands out the live mutable object. +- **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 @@ -220,7 +220,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:427`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:608`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` @@ -233,7 +233,7 @@ async list(options: SkillLookupOptions = {}): Promise async get(name: string, options: SkillLookupOptions = {}): Promise ``` -Source: [`packages/skill/skill/src/index.ts:157`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:159`](../../packages/skill/skill/src/index.ts) ## `ctx.subagents` — `SubagentService` @@ -246,7 +246,7 @@ list(): string[] start(name: string, request: SubagentStartRequest): SubagentRun ``` -Source: [`packages/subagent/subagent/src/index.ts:160`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:161`](../../packages/subagent/subagent/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` @@ -260,7 +260,7 @@ protect(protection: PromptProtection): () => Promise | void async assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:379`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:380`](../../packages/core/system-prompt/src/index.ts) ## `ctx.tools` — `ToolRegistry` diff --git a/docs/core-data-structures/skills.md b/docs/core-data-structures/skills.md index b356aba720..0c5bfcae13 100644 --- a/docs/core-data-structures/skills.md +++ b/docs/core-data-structures/skills.md @@ -6,7 +6,7 @@ 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()`. Each lookup snapshots its read-only options before provider work, and each provider candidate becomes registry-owned data while its opaque locator retains provider-owned identity. The registry validates candidates, resolves duplicate skill names first-wins by rank/provider order/local order, and sorts the final summaries by `name` for deterministic consumers. A provider `list()` rejection is logged and skipped without caching the degraded catalog; malformed candidates still fail fast because they violate the provider contract. ```ts type-equiv interface SkillProvider { @@ -28,7 +28,7 @@ The shipped local provider scans roots in rank order: | 400 | `user-dsh` | `/skills` | | 500 | `user-agents` | `/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 @@ -92,12 +92,12 @@ type SkillRegistration = Omit & { ## 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. The registry captures both fields once and providers receive the same read-only snapshot used for cache identity and loading. Cancellation is checked before and after catalog selection, including cache hits, and races both discovery and full-definition loading. If no git root is found, the local provider treats the supplied cwd itself as the project root. ```ts type-equiv interface SkillLookupOptions { - cwd?: string | undefined - signal?: AbortSignal | undefined + readonly cwd?: string | undefined + readonly signal?: AbortSignal | undefined } ``` diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index ad741a7522..5c11f1a07c 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -81,7 +81,7 @@ type InferArgs = Simplify< `defineTool({ name, description, parameters, execute, … })` ties it together: `parameters` is a `SchemaSpec`, `execute(args, exec)` gets `args: InferArgs`, and the helper converts the spec to JSON Schema (`schemaSpecToJsonSchema`) for the wire and validates model-generated args (`validateArgs`) before the typed body runs. A mismatch throws `ToolArgsError` (`code: 'INVALID_ARGS'`), which the registry turns into an `isError` result so the model can self-correct. Why a custom DSL and not schemastery: tool parameters need JSON Schema (the LLM wire format), not validation/transformation — the lightweight DSL gives the best authoring DX with the smallest surface. -Registration is a value boundary. `ToolRegistry.register()` 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 value boundary. `ToolRegistry.register()` reads every top-level field once, validates fixed scalar and callback types, materializes `ToolDefinition.parameters` as detached lossless JSON in one recursive pass, binds the accepted execute/presentation callbacks once to the original definition as their method receiver, and deep-freezes the stored record. Replacing a callback property on the caller-owned definition later does not change dispatch. `get()`/`visible()` expose only that frozen snapshot, while `schemas()` produces detached projections, so the model-visible and executable views cannot drift through a leaked mutable registry object. ## Execution: extensible waterfalls plus monotonic policy @@ -118,7 +118,7 @@ interface ToolExecution extends ToolExecutionInput { } ``` -`ToolExecutionToken` is a compile-time opaque type and a frozen, property-free object at runtime; identity comparison is its only operation. Before policy runs, `ctx.tools.execute()` 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 type and a frozen, property-free object at runtime; identity comparison is its only operation. Before policy runs, `ctx.tools.execute()` reads each caller-owned field once, materializes `arguments` as detached lossless JSON in one recursive pass, assigns a fresh token, and deep-freezes the accepted arguments. A mutable exotic such as `Map` is rejected and normalized to an error before policy; one-pass materialization prevents a stateful getter from supplying different values to validation and storage. `token`, `callId`, `name`, `arguments`, `agent`, and the optional `parent` token are non-writable throughout all waterfalls, so a listener cannot change which capability or scope was authorized or reach a live enclosing execution; an around-dispatch wrapper may add, replace, or remove only optional `signal`. After the complete pipeline the registry freezes the execution and exposes its stable identity to `tools/result` observers, where the execution remains usable as a `WeakMap` key without mutation races. A `ToolGuard` is scope-aware final pre-dispatch policy. Its shape deliberately has no allow result: `undefined` preserves the waterfall decision, while a returned reason can only reduce permission, so a later listener cannot undo it. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 1ed2405671..5a0c547e70 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -28,14 +28,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../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:61`](../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:79`](../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:114`](../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:75`](../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:86`](../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:101`](../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`) | - | +| `skill/provider-added` | `emit` | [`packages/skill/skill/src/index.ts:132`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | +| `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:138`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:115`](../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:76`](../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:87`](../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:102`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:46`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - | +| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:56`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:176`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:131`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:151`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 372c401954..8d0993b59a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -120,17 +120,13 @@ flowchart TD pkg_session --> pkg_brand pkg_session --> pkg_llm pkg_session --> pkg_scope - pkg_system_prompt --> pkg_llm - pkg_system_prompt --> pkg_scope pkg_fs --> pkg_brand pkg_fs --> pkg_llm pkg_web --> pkg_llm pkg_sandbox --> pkg_llm - pkg_agent --> pkg_brand - pkg_agent --> pkg_llm - pkg_agent --> pkg_scope - pkg_agent --> pkg_session - pkg_agent --> pkg_system_prompt + pkg_system_prompt --> pkg_llm + pkg_system_prompt --> pkg_scope + pkg_system_prompt --> pkg_session pkg_bash --> pkg_brand pkg_bash --> pkg_sandbox pkg_bash --> pkg_session @@ -150,18 +146,26 @@ flowchart TD pkg_llm_replay --> pkg_session pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox + pkg_agent --> pkg_brand + pkg_agent --> pkg_llm + pkg_agent --> pkg_scope + pkg_agent --> pkg_session + pkg_agent --> pkg_system_prompt pkg_bash_local --> pkg_bash pkg_bash_local --> pkg_timeout - pkg_compact_basic --> pkg_agent - pkg_compact_basic --> pkg_compact - pkg_compact_basic --> pkg_llm - pkg_compact_basic --> pkg_session pkg_hook_protocol --> pkg_bash pkg_hook_protocol --> pkg_session pkg_session_persistence_jsonl --> pkg_session pkg_session_persistence_jsonl --> pkg_session_persistence pkg_session_persistence_sqlite --> pkg_session pkg_session_persistence_sqlite --> pkg_session_persistence + pkg_bash_sandbox --> pkg_bash + pkg_bash_sandbox --> pkg_bash_local + pkg_bash_sandbox --> pkg_sandbox + pkg_compact_basic --> pkg_agent + pkg_compact_basic --> pkg_compact + pkg_compact_basic --> pkg_llm + pkg_compact_basic --> pkg_session pkg_user_approval --> pkg_agent pkg_user_approval --> pkg_brand pkg_user_approval --> pkg_llm @@ -180,9 +184,6 @@ flowchart TD pkg_tools --> pkg_session pkg_tools --> pkg_system_prompt pkg_tools --> pkg_user_approval - pkg_bash_sandbox --> pkg_bash - pkg_bash_sandbox --> pkg_bash_local - pkg_bash_sandbox --> pkg_sandbox pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_llm pkg_agent_loop --> pkg_scope @@ -209,6 +210,7 @@ flowchart TD pkg_subagent --> pkg_agent pkg_subagent --> pkg_llm pkg_subagent --> pkg_scope + pkg_subagent --> pkg_session pkg_subagent --> pkg_tools pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt @@ -289,6 +291,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 @@ -330,11 +333,10 @@ flowchart TD | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`llm`](../packages/llm/llm) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`llm`](../packages/llm/llm) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | -| [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm) | -| [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | +| [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`bash`](../packages/bash/bash) | `bash` | [`brand`](../packages/util/brand), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) | @@ -347,21 +349,22 @@ flowchart TD | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | +| [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) | -| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | +| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | +| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | -| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | @@ -378,7 +381,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) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 4bbe894338..cb0e8ce0dc 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -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:313`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:317`](../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:320`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:324`](../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:311`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:315`](../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:305`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:309`](../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:365`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:369`](../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:382`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:386`](../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:338`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:342`](../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:292`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:296`](../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:290`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) ### `todo/*` @@ -239,7 +239,7 @@ NOT a SurfaceEventType: it produces no LLM message and never reaches `deriveMess Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:352`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts) ### `tool/*` @@ -253,7 +253,7 @@ The model requested one tool invocation: `name` with the raw `arguments` JSON st Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:326`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -277,7 +277,7 @@ A completed tool call's model-facing result, plus an optional tool-private `meta Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:336`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:340`](../packages/core/session/src/types.ts) ### `turn/*` @@ -291,7 +291,7 @@ Closes turn `turn` with the TurnEndReason that ended it. The loop fires the awai Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -303,7 +303,7 @@ Opens turn `turn`. `trigger` records what started it — a drained message batch Types: [TurnTrigger](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:282`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts) ### `user/*` @@ -317,4 +317,4 @@ A user-visible prompt (queued message drained at turn start). Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 654b68ffb5..454997ef1f 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -101,7 +101,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 | diff --git a/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md index a3240153d0..aac3991f46 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md +++ b/docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md @@ -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` 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` 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` 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` 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. diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md index 9091965a59..981603cb19 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -149,18 +149,23 @@ Calling a service through `agent.ctx` does not implicitly make every later read The tool view must not change because a caller kept the object it passed to `register()` or received a definition from `get()` or `visible()`. Registration therefore creates the stored identity once; future changes happen through explicit unregister/register effects. -Tool parameters cross the model and log boundary, so the registry requires them to be lossless JSON before cloning and validates the clone again to contain unstable getters. It snapshots the scalar fields, binds each callback once to the original definition as its method receiver, and deep-freezes the stored record. Replacing `definition.execute` after registration therefore has no effect, while a callback can still deliberately read mutable state from its closure or original receiver. `get()` and `visible()` return the frozen stored definitions; `schemas()` returns detached schema projections. +Tool parameters cross the model and log boundary, so the registry materializes them with `snapshotJsonValue`: one recursive traversal reads each property once, rejects anything outside lossless JSON, and constructs the detached value that is actually stored. A check followed by `structuredClone` is not equivalent—a getter could return plain JSON to the check and a class instance to the clone, which would erase its prototype and silently accept different data. The first-party `defineTool()` helper closes the earlier authoring boundary with the same primitive: it reads every top-level option once, materializes the `SchemaSpec`, and derives an independent wire schema plus all later execute/presentation validation from that accepted snapshot. Without that split, mutating an author-owned spec after definition could make the model call a schema that the tool no longer accepts. Registration then reads every top-level definition field exactly once, validates, binds, and stores only those captured values; a stateful `parameters` or callback accessor therefore cannot make the checked definition differ from the executable one. It snapshots the scalar fields, binds each callback once to the original definition as its method receiver, and deep-freezes the stored record. Replacing `definition.execute` after registration therefore has no effect, while a callback can still deliberately read mutable state from its closure or original receiver. `get()` and `visible()` return the frozen stored definitions; `schemas()` returns detached schema projections. ```text +defineTool(options): + accepted = read each top-level option exactly once + parameterSpec = snapshotLosslessJson(accepted.parameters) + wireParameters = snapshotLosslessJson(convertToJsonSchema(parameterSpec)) + build execute and presentation validators over parameterSpec + registerTool(context, definition): - require definition.parameters is lossless JSON - parameters = clone(definition.parameters) - require parameters is still lossless JSON + accepted = read each top-level definition field exactly once + parameters = snapshotLosslessJson(accepted.parameters) stored = deepFreeze({ - copied name, description, timeout, + accepted name, description, timeout, parameters, - execute: bind definition.execute to definition, + execute: bind accepted.execute to definition, presentation callbacks: bind once when present }) @@ -173,7 +178,7 @@ The reserved Code Mode transport uses the same frozen-definition contract even t A tool restriction masks the global end-capability layer for one agent, while tools registered in that agent's own layer are explicit grants. Multiple restrictions intersect, so separately installed policies can only reduce the global surface. -The restriction snapshots its input, rejects an empty filter, and validates named tools against the pre-restriction capability universe. A restricted-away tool behaves like an unknown tool at execution, avoiding disclosure of a hidden global implementation. +The restriction reads `allow` and `deny` once, snapshots those exact values, rejects an empty filter, and validates named tools against the pre-restriction capability universe. The same captured arrays are then enforced, so a stateful accessor cannot pass one policy through validation and install another. A restricted-away tool behaves like an unknown tool at execution, avoiding disclosure of a hidden global implementation. [Code Mode](../feature/2026-06-15-code-mode.md)'s `run_code` is not an end capability. It is a reserved presentation transport that carries calls to the visible end capabilities, so the registry keeps it outside both global and scoped registration layers: restrictions cannot remove it, a scoped tool cannot shadow it, and configuration cannot explicitly allow or deny it. Without this exception, a restriction could leave the generated SDK in the prompt but remove the only way to invoke it. @@ -241,7 +246,9 @@ An agent's scope, session, registry entry, and driver form one owned transaction Programmatic create and resume reserve both the agent ID and session ID before work that can await. Create prepares a fresh or seeded session; resume first loads and reconstructs the persisted session. Both paths then construct the agent, mint `agent.ctx`, and install the complete teardown skeleton before awaiting setup. -The factory captures IDs and the setup callback and clones caller-owned agent options, session metadata, and seed events before the first asynchronous boundary. Resume does the same before persistence loading. A caller mutating its options object later therefore cannot move the transaction away from the identities it reserved or change the configuration eventually published. +The factory captures IDs and the setup callback and clones caller-owned agent options before the first asynchronous boundary. Seed events and session metadata take a stricter route: pre-cloning either could erase a class or exotic prototype before the session validator saw it, so the factory reads each reference once and hands it synchronously to `SessionStore.prepare`. That boundary rejects exotic shells, reads each accepted metadata field once, and recursively validates and copies every seed value in one pass. One-pass materialization matters because `validate(value); structuredClone(value); validate(clone)` still reads a getter twice, and the clone can erase the prototype of a class instance returned only on the second read. The accepted metadata becomes a detached, deep-frozen `SessionHeader` whose id must equal the session id. Resume applies the same rule after persistence loading by capturing `createdAt`, `cwd`, `parentSession`, and `seedLength` once before reconstruction. A caller or stateful backend therefore cannot move the transaction away from the identities it reserved, change persistence routing or lineage after publication, or sanitize invalid data into acceptance. + +The session log also closes the ownership boundary after acceptance. Seed and append paths share exact runtime surface-metadata checks: surface events require either `'append'` or an exact replace record with non-negative safe-integer bounds, provenance is an array of non-negative safe integers, and non-surface events reject both fields. Accepted events are deep-frozen, and `session.events` returns a cached frozen array snapshot rather than the mutable internal array. A later append invalidates the cache and publishes a new snapshot; any earlier snapshot remains unchanged. This preserves append-only behavior even for JavaScript callers that cast away TypeScript's readonly view or retain an event reference received from `append` or `session/event`. Reservations prevent two concurrent transactions from composing different unpublished agents under the same public identity. They remain held across persistence loading and setup and are released on every success or failure path. @@ -356,9 +363,9 @@ Cooperative waterfalls remain the general extension mechanism, but an invariant ### Prompt protection restores named canonical contributions -`systemPrompt.protect({ sections, tools })` declares that selected names must match the canonical registry/provider assembly after the complete `system-prompt/assemble` waterfall. Protections registered globally and for the current scope compose by set union, so callback order cannot weaken them. Protection finalizes a returned assembly rather than recovering from listener failure; if the waterfall throws, assembly still fails. +`systemPrompt.protect({ sections, tools })` declares that selected names must match the canonical registry/provider assembly after the complete `system-prompt/assemble` waterfall. It reads each caller array once before deduplication, so the names checked for an empty protection are the names actually installed. Protections registered globally and for the current scope compose by set union, so callback order cannot weaken them. Protection finalizes a returned assembly rather than recovering from listener failure; if the waterfall throws, assembly still fails. -For each protected name, the service restores the canonical presence and definition. If the canonical assembly omitted the name, protection removes a listener-fabricated entry; this makes mode-dependent absence enforceable as well as presence. +For each protected name, the service restores the canonical presence and definition. If the canonical assembly omitted the name, protection removes a listener-fabricated entry; this makes mode-dependent absence enforceable as well as presence. Tool providers receive the same coherence treatment: assembly reads `schemas`, optional `knownNames`, and every schema field once, detaches that record, and uses its captured names for both `toolOrder` validation and the model-visible collection. A stateful provider therefore cannot validate a phantom name while showing a different tool. A global section protection also reserves the registry name against scoped shadowing. Registering a scoped section under an already protected global name throws, and adding global protection throws if any scoped shadow already exists. Section registration copies `name`, `order`, and the text value or callback before the check and stores that record, so later mutation of the caller's object cannot rename a safe section into a reserved one. This check must happen before assembly: otherwise the ordinary scoped-over-global merge would make the shadow itself look canonical, leaving post-waterfall restoration with the wrong owner's value. Tool-schema protection does not impose a blanket schema-name reservation because providers are additive and may deliberately contribute unrelated executable schemas. @@ -392,7 +399,7 @@ Code Mode uses global protection for the `tools:sdk` section and reserved `run_c ### Tool executions have stable identity -`ctx.tools.execute(input)` accepts a caller-owned `ToolExecutionInput` and snapshots it into a distinct pipeline-owned `ToolExecution`. The registry requires `arguments` to be losslessly JSON-serializable, validates before cloning and again after cloning to contain unstable accessors, then deep-freezes the detached value. A cloneable but mutable exotic such as `Map` is rejected before policy rather than smuggled through an apparently frozen wrapper. Invalid input still produces one normalized final error notification. +`ctx.tools.execute(input)` accepts a caller-owned `ToolExecutionInput` and snapshots it into a distinct pipeline-owned `ToolExecution`. It captures the required `callId`/`name` correlation identity, then reads every other top-level caller field once before using it, so parent-token validation, scope routing, policy, dispatch, and final observation all see one coherent identity; those captured optional fields construct the normalized error shell if a later accessor or argument validation fails. The registry materializes `arguments` in one lossless-JSON traversal and deep-freezes the result, so policy and dispatch receive exactly the value that passed validation. A cloneable but mutable exotic such as `Map` or a class instance is rejected before policy rather than smuggled through an apparently frozen wrapper. Invalid input still produces one normalized final error notification; a throwing `callId` or `name` accessor is outside that guarantee because no trustworthy result correlation exists. The registry assigns each pipeline trip a frozen, property-free `ToolExecutionToken`; callers cannot choose that token. The execution's `token`, `callId`, `name`, `agent`, optional opaque `parent` token, and detached `arguments` are non-writable and non-configurable from the first policy listener onward. `signal` is the only operational field: an around-dispatch wrapper may add, replace, or remove it, and the registry freezes the complete execution before outcome observation. @@ -404,19 +411,18 @@ The input-to-execution conversion is intentionally one-way: ```text prepareExecution(input): - require input.parent is absent or a registry-minted token - require input.arguments is lossless JSON - detachedArguments = clone(input.arguments) - require detachedArguments is still lossless JSON + accepted = read callId, name, arguments, agent, parent, signal exactly once + require accepted.parent is absent or a registry-minted token + detachedArguments = snapshotLosslessJson(accepted.arguments) execution = { token: new frozen property-free object, - callId: input.callId, - name: input.name, + callId: accepted.callId, + name: accepted.name, arguments: deepFreeze(detachedArguments), - agent: input.agent, - parent: input.parent, - signal: input.signal + agent: accepted.agent, + parent: accepted.parent, + signal: accepted.signal } make every field except signal non-writable and non-configurable @@ -431,7 +437,7 @@ This one-way result makes the boundary monotonic. Pre-execution hooks can still ### `tools/result` observes the authoritative live outcome -The complete live pipeline is `tools/pre-execute` → monotonic guards → `tools/execute` → `tools/post-execute` → `tools/result`. The first three named events are transformable waterfalls; `tools/result` is an awaited, observe-only notification after all transforms and the registry's outer error normalization. Immediately before that boundary, the registry validates that the entire authoritative result can round-trip losslessly through JSON; an invalid tool or listener result becomes a normal JSON-safe `isError` outcome instead of reaching observers as apparent success and failing later at the session log. +The complete live pipeline is `tools/pre-execute` → monotonic guards → `tools/execute` → `tools/post-execute` → `tools/result`. The first three named events are transformable waterfalls; `tools/result` is an awaited, observe-only notification after all transforms and the registry's outer error normalization. At each untrusted result boundary, the registry captures every top-level field once and materializes the complete authoritative outcome as detached lossless JSON. Immediately before observation it materializes that owned outcome again and deep-freezes the shared listener snapshot. An invalid tool or listener result becomes a normal JSON-safe `isError` outcome instead of reaching observers as apparent success and failing later at the session log. Every `tools/result` listener receives the same frozen execution and deep-frozen result snapshot. Listener failures are contained independently, so they cannot change the caller's result or starve peer observers. Scope filtering derives from `execution.agent`. @@ -468,12 +474,12 @@ execute(input): result = requireValidExecutionResult(result) result = await tools/post-execute(execution, result) - result = requireLosslessJson(result) + result = snapshotLosslessJson(result) catch pipelineFailure: result = errorResult(pipelineFailure) freeze(execution) - frozenResult = deepFreeze(clone(result)) + frozenResult = deepFreeze(snapshotLosslessJson(result)) await every tools/result observer independently, containing each failure return result ``` @@ -520,11 +526,11 @@ In-process subagents demonstrate how the scope, lifecycle, and final-policy piec Provider registration first freezes an acceptance snapshot of the provider name, capability flags, parent-context descriptor, and `start` callback; the callback is bound to the original provider receiver so its intentional internal state stays live. Lookup, validation, model-facing wording, dispatch, lifecycle notifications, and HMR cleanup all use that snapshot. Mutating or reusing the caller's provider object later therefore cannot rename a live entry, change its advertised powers, replace its callback, or make its disposer delete the wrong key. -Starting a run snapshots every accepted field before asynchronous owner setup. The parent and abort signal are retained as identity capabilities but never reread from the mutable request record; tool filters, seed events, agent options, output schema, and prompt are detached. The schema is validated before cloning, while the prompt must pass the same lossless-JSON check before and after cloning that the session log requires. Later caller mutation therefore cannot change lifecycle scope, configuration, the schema enforced by the capture tool, or the prompt eventually logged and sent. +Starting a run reads every top-level request field once before capability validation, then snapshots every accepted field before asynchronous owner setup. This order makes checked and delegated capabilities identical even for a JavaScript caller with stateful accessors. Fixed scalars are checked at the same boundary: `maxDepth` must be a non-negative safe integer and `persona` must be a string. The parent and abort signal are retained as identity capabilities but never reread from the mutable request record; tool filters, seed events, agent options, output schema, and prompt are detached through the one-pass lossless-JSON materializer. The exported in-process driver repeats this boundary for direct callers before it awaits run-owner activation, including taking one seed snapshot from which it derives both the child prefix and `seedLength`. Later caller mutation therefore cannot change lifecycle scope, configuration, the schema enforced by the capture tool, or the prompt eventually logged and sent. The driver first installs provider ownership. Only after that succeeds does it attach the request's abort listener and create one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves neither a child nor an orphaned listener. The child factory runs through the owner fiber. Parent teardown, provider teardown, and manual run disposal all dispose this same node; moving it out of the active state synchronously prevents an unpublished setup from publishing afterward, while all three paths follow one quiescence promise. This structured ownership does not change the child's flat capability view. -The returned run separates acceptance from publication with `started: Promise`. For spawn and fork, it fulfills only after the child factory returns a published handle, so the service can emit `subagent/start` with `ctx.agents.get(run.id)` already live; it rejects when rollback prevents publication. The service observes `result` immediately but buffers its cloned end payload until readiness, preserving start-before-end order without leaving an early rejection unhandled. A readiness rejection emits neither lifecycle event. The result driver awaits the same boundary before sending the child prompt. +The provider's run separates acceptance from publication with `started: Promise`, but the service does not return that caller-owned handle directly. It reads `id`, `started`, `result`, and every method once, binds methods to the original provider receiver, and returns a frozen service-owned wrapper. Its `result` promise captures `output`, optional `structured`, and `stopReason` once and resolves to one detached, deeply frozen lossless-JSON value shared by the caller and lifecycle telemetry; malformed provider data rejects as an infrastructure fault and produces contained `error` telemetry. For spawn and fork, the accepted `started` promise fulfills only after the child factory returns a published handle, so the service can emit `subagent/start` with `ctx.agents.get(id)` already live; it rejects when rollback prevents publication. The service observes the normalized result immediately but buffers its end payload until readiness, preserving start-before-end order without leaving an early rejection unhandled. Both lifecycle payloads are deeply frozen before contained per-listener dispatch, so one observer cannot corrupt the caller or a peer. A readiness rejection emits neither lifecycle event. The result driver awaits the same boundary before sending the child prompt. ```text startInProcessRun(providerContext, acceptedRequest): @@ -540,7 +546,7 @@ startInProcessRun(providerContext, acceptedRequest): creation = runOwner.ctx.agents.create({ fresh ids and lineage, - cloned options and optional seed, + detached options and optional seed, setup(childCtx) => install persona, tool restriction, structured runtime }) @@ -550,9 +556,16 @@ startInProcessRun(providerContext, acceptedRequest): send the child prompt, await idle, derive the terminal result SubagentService.start(...): - attach result settlement handlers immediately - await returnedRun.started + providerRun = provider.start(detached request) + serviceRun = freeze({ + id, started, and methods captured once from providerRun, + methods bound to providerRun, + result: normalize once into detached, deeply frozen lossless JSON + }) + attach settlement handlers to serviceRun.result immediately + await serviceRun.started emit subagent/start; later emit the buffered or eventual subagent/end + return serviceRun Workflow worker bridge after receiving returnedRun: register the run so cancellation can reach pre-publication work @@ -561,9 +574,14 @@ Workflow worker bridge after receiving returnedRun: send ChildStarted; then send the buffered or eventual outcome else: send ChildStartError and dispose the attempt + +Before publishing the workflow's own result: + abort the shared child-request signal + call cancel("workflow settled") on every host-registered run + only then settle the workflow result ``` -Every downstream protocol that announces a subagent must honor the same boundary. The workflow worker bridge therefore registers the returned run before waiting, observes and snapshots `result` immediately, sends `ChildStarted` only after `started` fulfills, and sends `ChildStartError` plus host-driven disposal when readiness rejects. This keeps cancellation able to reach pending creation, prevents an early result rejection from going unhandled, and ensures `workflow/agent-start` never names an unpublished child. +Every downstream protocol that announces a subagent must honor the same boundary. The workflow worker bridge therefore registers the returned run before waiting, observes and snapshots `result` immediately, sends `ChildStarted` only after `started` fulfills, and sends `ChildStartError` plus host-driven disposal when readiness rejects. Before the workflow result becomes observable, the host also drives both permitted cancellation channels—the shared abort signal and each registered run's explicit `cancel()`—because a fire-and-forget child still waiting on readiness has no worker-side handle that could relay cancellation. Provider cancel callbacks are contained independently so one broken implementation cannot prevent peers from receiving cancellation or wedge the workflow result. This keeps cancellation able to reach pending creation, prevents an early result rejection from going unhandled, ensures `workflow/agent-start` never names an unpublished child, and prevents a child from publishing after its workflow has ended. Parent teardown reaches `runOwner` by nesting; the provider and returned run handle reach the same node through their explicit disposers. diff --git a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md index 474abd2c67..f26aa501ef 100644 --- a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -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 diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md index ba372b98b2..52d7e2b425 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md @@ -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 diff --git a/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md b/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md index 6c404a05a9..3eedd92a2d 100644 --- a/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md +++ b/docs/rfc/rejected/architecture/2026-06-11-immutable-public-surfaces.md @@ -1,25 +1,24 @@ # RFC: Deep-readonly public surfaces -Status: rejected — the pervasive `DeepReadonly` 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` 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` 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` 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` 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 diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index e3d80cd567..e47ca434e3 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -1700,7 +1700,7 @@ describe('BasicCompactService under the real invariants plugin', () => { async function setup(): Promise<{ ctx: Context; session: Session; svc: BasicCompactService }> { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(Invariants, {}) + await ctx.plugin(Invariants) await ctx.plugin(LlmService) ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter('CONDENSED')) await ctx.plugin(BasicCompactService, cfg({ auto: false })) diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index f2ca3ccdd0..1efb417b48 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -76,7 +76,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) - await ctx.plugin(Invariants, {}) + await ctx.plugin(Invariants) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 7f7882f2b7..aea7927571 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -813,7 +813,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SkillLookupOptions', - declaration: 'export interface SkillLookupOptions {\n cwd?: string | undefined;\n signal?: AbortSignal | undefined;\n}', + declaration: 'export interface SkillLookupOptions {\n readonly cwd?: string | undefined;\n readonly signal?: AbortSignal | undefined;\n}', }, { name: 'SkillProvider', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 217ba0a643..ac1a0e079f 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -8,13 +8,13 @@ This is the only package in the harness that contains concrete loop logic. Every ### Public API -Lifecycle (scoped): programmatic creation and resume snapshot caller-owned identity/configuration data, reserve both IDs, mint `agent.ctx`, and install the ordered teardown skeleton before awaiting optional `setup`. Resume installs an owner-liveness sentinel before persistence load, then hands ownership directly to the full lifecycle. After setup resolves, the factory checks its lifecycle flag, owner-fiber state, and owning agent status around one microtask checkpoint so a same-turn Cordis unload wins before publication. Successful setup inserts both session and agent before announcing either, enables driving immediately before `agent/session-start`, then starts the loop. Setup calls to `send`/`steer`/`inject`/`cancel` reject structurally; load/setup rejection or owner unload publishes nothing. Teardown runs stop/drain (including outstanding idle-injection flushes) → unregister → detach session → unwind scope. All `agent/*` dispatches go through `agentEvents(ctx, agent)`; per-step assembly through `assembleContextFor(agent)`; the turn-end durability checkpoint through `ctx.sessions.flush(session)`. +Lifecycle (scoped): programmatic creation and resume snapshot caller-owned identity/configuration data, reserve both IDs, mint `agent.ctx`, and install the ordered teardown skeleton before awaiting optional `setup`. A create hands one-read raw seed and metadata references synchronously to the session boundary, which rejects exotic shells and materializes accepted values in a single recursive pass; pre-cloning either value could incorrectly sanitize prototypes. Resume installs an owner-liveness sentinel before persistence load, captures each loaded metadata field once, then hands ownership directly to the full lifecycle. After setup resolves, the factory checks its lifecycle flag, owner-fiber state, and owning agent status around one microtask checkpoint so a same-turn Cordis unload wins before publication. Successful setup inserts both session and agent before announcing either, enables driving immediately before `agent/session-start`, then starts the loop. Setup calls to `send`/`steer`/`inject`/`cancel` reject structurally; load/setup rejection or owner unload publishes nothing. Teardown runs stop/drain (including outstanding idle-injection flushes) → unregister → detach session → unwind scope. All `agent/*` dispatches go through `agentEvents(ctx, agent)`; per-step assembly through `assembleContextFor(agent)`; the turn-end durability checkpoint through `ctx.sessions.flush(session)`. - `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — config-driven create: an agent on a fresh per-run session id `${id}-session-` with optional session metadata. Used for `cordis.yml`-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber. `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): -- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup? }): Promise` — programmatic create on a caller-supplied `sessionId`, NOT `${id}-session`. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown. +- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup? }): Promise` — programmatic create on a caller-supplied `sessionId`, NOT `${id}-session`. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and detaches each raw value in one pass. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown. - `ctx.agents.resume({ agentId, resumeSessionId, agentOptions?, setup? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`. The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle) — only the programmatic factory callers (the ACP bridge and in-process subagent backends) hold a handle and own per-agent teardown. diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index bd5b7e3188..1e5c122fc5 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -178,20 +178,21 @@ export class AgentLoop extends Service implements AgentFactory { */ async createAgent(options: CreateAgentOptions): Promise { // Snapshot every caller-owned field before the first async setup boundary. - // The callback itself is an identity capability; all data fields are - // detached so caller mutation cannot drift a reserved/published identity or - // the options the accepted agent observes. + // The callback itself is an identity capability. Agent options detach here; + // seed and metadata stay raw only until sessions.prepare() synchronously + // reads, validates, and detaches them, so structuredClone cannot erase an + // exotic prototype before the session boundary sees it. const agentId = options.agentId const sessionId = options.sessionId const setup = options.setup const agentOptions = structuredClone(options.agentOptions ?? {}) - const seed = options.seed === undefined ? undefined : structuredClone(options.seed) - const meta = structuredClone(options.meta ?? {}) + const seed = options.seed + const meta = options.meta const release = this.reserve(agentId, sessionId) try { const session = this.ctx.sessions.prepare(sessionId, { ...seed !== undefined ? { seed } : {}, - meta, + ...meta !== undefined ? { meta } : {}, }) // A seeded (forked) create is still a fresh start, NOT a resume. return await this.startOwned(agentId, agentOptions, session, 'startup', setup) @@ -281,16 +282,23 @@ export class AgentLoop extends Service implements AgentFactory { throw new Error(`agent "${agentId}" resume aborted: owner disposed during persistence load`) }), ]) + // The backend is an async boundary too. Read each loaded header field + // once so a stateful implementation cannot pass a valid presence check + // and then substitute a different value during reconstruction. + const createdAt = meta.createdAt + const cwd = meta.cwd + const parentSession = meta.parentSession + const seedLength = meta.seedLength // An out-of-band direct registry/session insertion can still race this // service's reservation, so the public enter primitives re-check exact // liveness at publication. const session = this.ctx.sessions.prepare(sessionId, { seed: events, meta: { - createdAt: meta.createdAt, - ...meta.cwd !== undefined ? { cwd: meta.cwd } : {}, - ...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {}, - ...meta.seedLength !== undefined ? { seedLength: meta.seedLength } : {}, + createdAt, + ...cwd !== undefined ? { cwd } : {}, + ...parentSession !== undefined ? { parentSession } : {}, + ...seedLength !== undefined ? { seedLength } : {}, }, }) // Calling startOwned synchronously installs the complete lifecycle diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 2a4ba7e904..8ed557faaf 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' @@ -99,6 +99,22 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx.fiber.dispose() }) + it('createAgent sends raw metadata to the session validator before cloning can sanitize it', async () => { + class ExoticMeta { + readonly cwd = '/accepted' + } + const { ctx } = await persistentHarness(new MockAdapter([textResponse('hi')])) + + await expect(ctx.agents.create({ + agentId: AgentId('exotic-meta-agent'), + sessionId: SessionId('exotic-meta-session'), + meta: new ExoticMeta(), + })).rejects.toThrow(/session metadata is not a plain JSON record/) + expect(ctx.agents.get(AgentId('exotic-meta-agent'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('exotic-meta-session'))).toBeUndefined() + await ctx.fiber.dispose() + }) + it('resume of a session with no cwd carries an undefined cwd header', async () => { // Lifecycle 1: create a no-cwd session and run a turn. const adapter1 = new MockAdapter([textResponse('a')]) @@ -411,6 +427,54 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.fiber.dispose() }) + it('reads each loaded metadata field once before reconstructing a resumed session', async () => { + const sessionId = SessionId('resume-loaded-meta-once') + const root = await persistSession(sessionId) + const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) + const loaded = await ctx.sessionPersistence.load(sessionId) + const reads = { createdAt: 0, cwd: 0, parentSession: 0, seedLength: 0 } + const meta = Object.defineProperties({ + version: loaded.meta.version, + id: loaded.meta.id, + }, { + createdAt: { + enumerable: true, + get: () => { reads.createdAt += 1; return reads.createdAt === 1 ? loaded.meta.createdAt : 1n }, + }, + cwd: { + enumerable: true, + get: () => { reads.cwd += 1; return reads.cwd === 1 ? '/loaded' : 'relative' }, + }, + parentSession: { + enumerable: true, + get: () => { reads.parentSession += 1; return reads.parentSession === 1 ? SessionId('parent') : 1n }, + }, + seedLength: { + enumerable: true, + get: () => { reads.seedLength += 1; return reads.seedLength === 1 ? 0 : 1n }, + }, + }) as unknown as SessionHeader + ctx.sessionPersistence.load = () => Promise.resolve({ meta, events: loaded.events }) + + const resumed = await ctx.agents.resume({ + agentId: AgentId('resume-loaded-meta-once'), + resumeSessionId: sessionId, + agentOptions: { model: 'mock' }, + }) + + expect(reads).toEqual({ createdAt: 1, cwd: 1, parentSession: 1, seedLength: 1 }) + expect(resumed.agent.session.header).toEqual({ + version: loaded.meta.version, + id: sessionId, + createdAt: loaded.meta.createdAt, + cwd: '/loaded', + parentSession: 'parent', + seedLength: 0, + }) + await resumed.dispose() + await ctx.fiber.dispose() + }) + it('an idle inject() is flushed durably on its own (survives without explicit flush/dispose)', async () => { // Lifecycle 1: run a turn, then inject context while idle. The idle inject // wraps its context/message in a one-shot turn AND checkpoints it (the turn-enclosure RFC) diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index e527099a88..3cfa743b65 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -605,7 +605,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(Invariants, { freeze: false }) + await ctx.plugin(Invariants) ctx.llm.registerAdapter(['mock'], adapter) return ctx } @@ -1014,7 +1014,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(Invariants, { freeze: false }) + await ctx.plugin(Invariants) ctx.llm.registerAdapter(['mock'], adapter) // Blocking listener on the parent context (survives fiber disposal). @@ -1071,7 +1071,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(Invariants, { freeze: false }) + await ctx.plugin(Invariants) ctx.llm.registerAdapter(['mock'], adapter) const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, _context, next) { @@ -1127,7 +1127,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(Invariants, { freeze: false }) + await ctx.plugin(Invariants) ctx.llm.registerAdapter(['mock'], adapter) ctx.on('agent/pre-step', async () => { @@ -1179,7 +1179,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(Invariants, { freeze: false }) + await ctx.plugin(Invariants) ctx.llm.registerAdapter(['mock'], adapter) ctx.on('agent/pre-step', async () => { @@ -1228,7 +1228,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(Invariants, { freeze: false }) + await ctx.plugin(Invariants) ctx.llm.registerAdapter(['mock'], adapter) ctx.on('system-prompt/assemble', async function (_assembly, _context, next) { diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 2bf58be832..6d0bb3107b 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' @@ -307,6 +307,36 @@ describe('agent scope lifecycle', () => { await retry.dispose() }) + it('rejects an exotic seed before publishing either reserved identity', async () => { + const ctx = await harness() + const published: string[] = [] + ctx.on('session/created', () => { published.push('session') }) + ctx.on('agent/created', () => { published.push('agent') }) + class ExoticData { readonly value = 'not durable JSON' } + const seed = [{ + seq: 0, + type: 'test/exotic-seed', + data: new ExoticData(), + }] as unknown as SessionEvent[] + + await expect(ctx.agents.create({ + agentId: AgentId('exotic-seed'), + sessionId: SessionId('exotic-seed-session'), + agentOptions: { model: 'mock' }, + seed, + })).rejects.toThrow(/seed event at index 0 is not losslessly JSON-serializable/) + + expect(published).toEqual([]) + expect(ctx.agents.get(AgentId('exotic-seed'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('exotic-seed-session'))).toBeUndefined() + const retry = await ctx.agents.create({ + agentId: AgentId('exotic-seed'), + sessionId: SessionId('exotic-seed-session'), + agentOptions: { model: 'mock' }, + }) + await retry.dispose() + }) + it('a throwing session/created listener disposes the scope (pre-nesting rollback window)', async () => { const ctx = await harness() let boom = true diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 71e2be279d..23fa073ea1 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -20,7 +20,7 @@ The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh- Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. - `ctx.agents.setFactory(factory: AgentFactory): () => Promise | void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose. -- `ctx.agents.create(options: CreateAgentOptions): Promise` — snapshot caller-owned IDs/options/metadata/seed, construct and await optional setup while unpublished, insert and announce both session and agent, open the `agent/session-start` driving boundary, then start a new loop on the caller-supplied `sessionId`. Agent/session IDs are reserved across setup; setup rejection or owner unload publishes nothing. Publication is rollback-covered: if a creation listener throws, entries and scope unwind but effects of already-delivered notifications remain observable; an agent whose announcement began emits `agent/disposed` during that rollback. Rejects if no factory is registered. +- `ctx.agents.create(options: CreateAgentOptions): Promise` — snapshot caller-owned IDs/options/metadata and hand the one-read raw seed synchronously to the session boundary for one-pass lossless-JSON materialization, construct and await optional setup while unpublished, insert and announce both session and agent, open the `agent/session-start` driving boundary, then start a new loop on the caller-supplied `sessionId`. Agent/session IDs are reserved across setup; seed rejection, setup rejection, or owner unload publishes nothing. Publication is rollback-covered: if a creation listener throws, entries and scope unwind but effects of already-delivered notifications remain observable; an agent whose announcement began emits `agent/disposed` during that rollback. Rejects if no factory is registered. - `ctx.agents.resume(options: ResumeAgentOptions): Promise` — snapshot caller-owned IDs/options, load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh agent scope, await optional setup while unpublished, then follow the same insert → announce → session-start → loop-start boundary. The IDs are reserved across persistence load and setup; load/setup rejection or owner unload publishes nothing. Rejects if no factory is registered or session persistence is unconfigured. `AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit plus every outstanding idle-injection flush (quiescence — NOT just the `disposed` status flip), unregisters the agent, removes its session from the store, and finally unwinds its scoped world. This order captures every agent-started `session/flush` before the session is detached and keeps scoped listeners alive through those checkpoints. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge and in-process subagent backends are production consumers; config-created agents are owned by the loop fiber and never need a handle. diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index c73de89e6d..959ca4784c 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -48,7 +48,10 @@ export interface CreateAgentOptions { * `cwd`/`parentSession`/`seedLength` fields of * {@link CreateSessionOptions.meta} in dsh-session (the internal-only * `createdAt`, used when reconstructing a persisted session, is deliberately - * excluded — a factory caller never sets it). + * excluded — a factory caller never sets it). The factory reads this raw + * reference once and hands it synchronously to the session boundary, which + * rejects an exotic shell and captures each accepted field once before any + * asynchronous setup. */ meta?: { cwd?: string; parentSession?: SessionId; seedLength?: number } /** @@ -57,9 +60,11 @@ export interface CreateAgentOptions { * prefix so `deriveMessages()`/`lastTurnNumber` continue from it — used by the * in-process FORK subagent backend to seed a child with a balanced * completed-turn prefix of the parent's log. The prefix MUST be contiguous - * from seq 0 and balanced (no open turn/step, no dangling tool-call), or the - * session constructor (and the dev-mode invariants replay) reject it. Absent - * for a fresh (spawn) child. + * from seq 0, carry only lossless-JSON data, and be balanced (no open + * turn/step, no dangling tool-call), or the session constructor (and the + * dev-mode invariants replay) reject it. The factory passes the raw seed to + * the synchronous one-pass validator/copier; it never pre-clones and thereby + * sanitizes exotic prototypes. Absent for a fresh (spawn) child. */ seed?: SessionEvent[] /** Per-agent options (model, …). */ diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md index e57f475816..f779dab57e 100644 --- a/packages/core/scope/README.md +++ b/packages/core/scope/README.md @@ -12,7 +12,7 @@ Scoped-context registration primitive. `createScope(ctx, key)` mints a Cordis co - `scopeTarget(base: T, key?: ScopeKey): Scoped` Build the dispatch `thisArg` for a scope-filtered event: composes `base`'s own `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). Listener `this` stays `base`-shaped. `{ global: true }` listeners bypass filtering (Cordis semantics). - `Scoped` The compile-time carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. - `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name. -- `scopeHost(ctx, services)` Test/tooling host whose shared `dispose()` waits for both the host fiber and every minted scope, including a child already tearing down through `rawDispose`. +- `scopeHost(ctx, services)` Test/tooling host that snapshots the requested service list before activation, fails loud with stable missing-service diagnostics, and whose shared `dispose()` waits for both the host fiber and every minted scope, including a child already tearing down through `rawDispose`. ## Design contract diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index 7a7032b0b6..276b406dcd 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -320,6 +320,8 @@ export interface ScopeHost { * can never be satisfied RESOLVES its fiber await without ever running the * callback — a silent no-op host. This helper fails LOUD instead: when the * callback did not run, it names the absent services and disposes the host. + * The service list is copied before plugin activation so caller mutation + * across the await cannot change dependency resolution or diagnostics. * @param ctx - the context to mount the host under. * @param services - the service names scopes minted through this host reach * (the host plugin's `inject` list). @@ -328,16 +330,20 @@ export interface ScopeHost { * Cordis dead end. */ export async function scopeHost(ctx: Context, services: string[]): Promise { + // The inject list crosses an await before missing-service diagnostics run. + // Detach it now so caller mutation cannot change either Cordis dependency + // resolution or the names reported by this helper. + const requiredServices = [...services] let hostCtx: Context | undefined // A named function statement (not Object.assign({name}) — Function.name is // read-only) so diagnostics read `scopeHost`. function scopeHostPlugin(inner: Context): void { hostCtx = inner } - const fiber = ctx.plugin(Object.assign(scopeHostPlugin, { inject: services })) + const fiber = ctx.plugin(Object.assign(scopeHostPlugin, { inject: requiredServices })) await fiber if (hostCtx === undefined) { // Dependency-pending: cordis resolves the await without running the // callback. Name the absentees and unwind the pending fiber. - const missing = services.filter(name => ctx.get(name) === undefined) + const missing = requiredServices.filter(name => ctx.get(name) === undefined) await fiber.dispose() /* v8 ignore next -- the '(unknown)' fallback is defensive: a pending * fiber with zero absent services cannot occur (an all-present inject diff --git a/packages/core/scope/tests/scope.spec.ts b/packages/core/scope/tests/scope.spec.ts index 44b9cf4442..e572584fb1 100644 --- a/packages/core/scope/tests/scope.spec.ts +++ b/packages/core/scope/tests/scope.spec.ts @@ -367,6 +367,16 @@ describe('scopeHost', () => { .rejects.toThrow('scopeHost: services "tools", "systemPrompt" not available') }) + it('snapshots missing-service diagnostics across the host activation await', async () => { + const ctx = new Context() + const services = ['tools', 'systemPrompt'] + const pending = scopeHost(ctx, services) + services.splice(0) + + await expect(pending) + .rejects.toThrow('scopeHost: services "tools", "systemPrompt" not available') + }) + it('names a single absent service in the singular', async () => { const ctx = new Context() await expect(scopeHost(ctx, ['tools'])).rejects.toThrow('scopeHost: service "tools" not available') diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 094c3073df..36052d9e7a 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -8,7 +8,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Public API -- `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage, seed boundary) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` and persisted `seedLength` to preserve them. Disposed with the calling fiber. +- `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log: the constructor reads each array entry once, then recursively validates and copies every nested value in one pass so validation and storage cannot observe different getter results or erase an exotic prototype before checking it. `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage, seed boundary) as the immutable `SessionHeader`: the store rejects an exotic metadata shell, reads every accepted field once, and constructs a detached, deep-frozen header. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` and persisted `seedLength` to preserve them. Disposed with the calling fiber. - `ctx.sessions.flush(session: Session): Promise` Dispatch the awaited `session/flush` durability checkpoint with the carrier captured at enter — THE flush entry point (the loop's turn-end checkpoint and idle injection call it; never dispatch a raw `ctx.parallel`). Rejects a prepared, detached, or stale same-id object instead of inventing a subject-less carrier. - `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata. - `ctx.sessions.get(id: SessionId): Session | undefined` @@ -18,7 +18,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall `create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before `onAppend` detaches — `create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect: -- `ctx.sessions.prepare(id?, options?): Session` — validate the id/cwd and construct the `Session`, WITHOUT entering it into the store. Same options as `create`. +- `ctx.sessions.prepare(id?, options?): Session` — read `options.seed`/`options.meta` once, validate and detach the metadata/header, and construct the `Session` WITHOUT entering it into the store. Same options as `create`. - `ctx.sessions.enter(session): () => void` — wire `onAppend` → `session/event`, capture its scope carrier, and add the session to the store; returns the idempotent DETACH disposer, which clears both notification and carrier state. Does NOT emit `session/created` (the caller installs the disposer first, then calls `announce`, so a throwing listener rolls the attach back). It re-checks the id because public `prepare`/`enter` calls may be interleaved; a stale prepared object must not overwrite a live same-id session. - `ctx.sessions.announce(session): void` — emit `session/created` for an entered session. @@ -32,12 +32,17 @@ The store announces creation, publishes each append, and provides an awaited dur Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. -- `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. **Throws** if `data` is not losslessly JSON-serializable (BigInt, function, symbol, undefined, non-finite number, circular ref, or an exotic object like Map/Set/Date) — the event log is the durable source of truth, so this invariant is enforced at the source (exported as `isJsonValue` for backends to reuse on their replay/fork entry points). A third parameter `opts: SurfaceIntent` carries surface metadata: `surfaceOp` controls how the event enters the surface linked list, and `sourceEventSeqs` records provenance (the seq numbers of events this one derives from). It is **required** for the five `SurfaceEventType` events (every message-producing event must declare how it joins the surface) and rejected by the compiler for non-surface types. The marker requirement is enforced two ways: the typed overload makes `opts` mandatory when `type` is a specific `SurfaceEventType` literal, AND `append` **throws** at runtime if a surface-eligible event arrives with no `surfaceOp` — covering the case where `type` widens to the `SessionEventType` union (a caller iterating raw events, where the conditional overload collapses to optional) so a marker-less message event can never silently land in the log and vanish from `deriveMessages()`. +- `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. **Throws** if `data` or surface metadata is not losslessly JSON-serializable (BigInt, function, symbol, undefined, `-0`, non-finite number, circular ref, or an exotic object like Map/Set/Date/class instance). One recursive validate-and-copy pass reads each nested value exactly once and produces the detached value that enters the log, so validation and durability cannot diverge through a stateful getter or a prototype-erasing clone. The accepted event and every nested value are deep-frozen before publication; the returned event and observer notification share that immutable owned record. A third parameter `opts: SurfaceIntent` carries surface metadata: `surfaceOp` and `sourceEventSeqs` are each read once, then the former controls how the event enters the surface linked list and the latter records provenance. Runtime validation accepts only `'append'` or the exact `{ op: 'replace', start, end }` record with non-negative safe-integer bounds, and provenance must be an array of non-negative safe integers; non-surface events reject either field. The marker is **required** for the five `SurfaceEventType` events (every message-producing event must declare how it joins the surface) and rejected by the compiler for non-surface types. The contract is enforced two ways: the typed overload handles a specific event literal, AND runtime checks cover widened unions and raw seed/load logs so invalid metadata can never silently enter or disappear from `deriveMessages()`. - `session.deriveMessages(): Message[]` — the LLM message history, CACHED: each surface node is projected exactly once, when first seen (O(new nodes) per call; a surface rewrite rebuilds via `surface.replaceGeneration`). Returns a fresh array snapshot per call over SHARED, deep-frozen `Message` objects — cloned once off the log at projection time, so a consumer can never mutate logged data (mutation throws). The surface is the single source of derived history — there is no raw-log fallback. - `session.deriveEventMessage(event): Message | null` — the per-event projection `deriveMessages()` folds: one event's derived message (an unfrozen clone), or `null` when it produces none (a non-surface event, or an empty-content `assistant/message` hosting only usage). External reconstructors and the dev invariant fold the same function over a log prefix's surface, so no two paths can disagree about what a request's messages were (the reconstructability RFC). - `session.surface: SurfaceManager` — the derived surface, lazily rebuilt from `surfaceOp` markers in the log. Processes only new events (delta) on each access — the log is append-only, so prior events never change. `surface.replaceGeneration` is the rewrite signal: bumped by every folded `replace` and by `invalidate()`, never reset, so an incremental consumer comparing generations cannot be fooled. -- `session.events`, `session.seq`, `session.id` -- `session.header: SessionHeader` — immutable creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Kept out of the event log (a storage concern, not replayable state); a minimal header (stamped with the current `SESSION_FORMAT_VERSION`) is synthesized for bare `Session` construction. +- `session.events` — a cached, frozen array snapshot over deep-frozen events. Repeated reads without an append return the same array; an append invalidates the cache and the next read returns a new snapshot, while earlier snapshots stay unchanged. Neither a cast nor a retained reference can push into the live log or rewrite an accepted event. +- `session.seq`, `session.id` +- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Construction validates its lossless-JSON shape and requires the header id to match `session.id`, so a caller cannot later mutate persistence routing or lineage through an aliased header. Kept out of the event log (a storage concern, not replayable state); a minimal header (stamped with the current `SESSION_FORMAT_VERSION`) is synthesized for bare `Session` construction. + +### Lossless JSON utilities + +Durable values need one accepted representation, not a check followed by a second read. `isJsonValue(value)` is the boolean predicate; `snapshotJsonValue(value)` recursively validates and copies a plain value in one pass, returning `undefined` for invalid input and propagating a throwing getter. The snapshot helper accepts finite JSON numbers except `-0` (JSON rewrites it to `0`), dense ordinary arrays, and plain or null-prototype objects; it rejects cycles, unsupported scalars, and exotic prototypes before normalization. ### Surface types @@ -65,12 +70,12 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) ### Metadata types (`types.ts`) -- `SessionHeader` — immutable session metadata, written once: `{ version, id, createdAt, cwd?, parentSession?, seedLength? }`. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle). +- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle). ### Extension points - Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log. -- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The seed is validated to the SAME always-on invariants `append` enforces — contiguous seqs, JSON-serializable data, and required `surfaceOp` markers on surface-eligible events — so marker-less message events are rejected at construction rather than silently vanishing from `deriveMessages()`. Broader turn-enclosure checks stay in `dsh-invariants` and persistence repair. Ordinary live-session forks use `ctx.sessions.fork(source, boundary?, childSessionId?)`, where `boundary` is the inclusive source event seq to fork through. +- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The constructor reads each seed entry once and uses the same one-pass lossless-JSON snapshot and exact surface-metadata shape checks as `append`, then enforces contiguous seqs and deep-freezes every accepted record; a stateful caller, exotic nested value, marker-less or malformed surface event, metadata on a non-surface event, or retained seed reference therefore cannot silently change the reconstructed history. Broader turn-enclosure checks stay in `dsh-invariants` and persistence repair. Ordinary live-session forks use `ctx.sessions.fork(source, boundary?, childSessionId?)`, where `boundary` is the inclusive source event seq to fork through. - Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes behind a summary checkpoint. ### What is NOT here (TODO) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index d3f4ca4890..51500e9e1b 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -14,12 +14,12 @@ import type { Scoped } from '@deepseek-ai/dsh-scope' import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionId } from './types.ts' import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts' -import { isJsonValue } from './json.ts' +import { snapshotJsonValue } from './json.ts' import { SurfaceManager, isSurfaceEligibleType } from './surface.ts' import { foldRequestHeader } from './request-header.ts' export * from './types.ts' -export { isJsonValue } from './json.ts' +export { isJsonValue, snapshotJsonValue } from './json.ts' export type { JsonValue } from './json.ts' export { interruptedTurnClosers } from './repair.ts' export type { SurfaceNode } from './surface.ts' @@ -99,6 +99,160 @@ function renderTagged(tag: string, content: ContentBlock[], source: MessageSourc ] } +/** Reject a record shell that cloning or spreading would otherwise sanitize. */ +function assertPlainRecord(value: unknown, label: string): asserts value is Record { + if (value === null || typeof value !== 'object') { + throw new Error(`${label} is not a plain JSON record`) + } + const prototype = Object.getPrototypeOf(value) as unknown + if (prototype !== Object.prototype && prototype !== null) { + throw new Error(`${label} is not a plain JSON record`) + } +} + +/** Capture and validate the caller-owned fields that become a session header. */ +function snapshotSessionMeta(source: CreateSessionOptions['meta']): NonNullable { + if (source === undefined) return {} + assertPlainRecord(source, 'session metadata') + + // Read each accepted field exactly once. The metadata vocabulary is scalar, + // so this plain record is already detached from the caller; cloning the + // caller's shell first would erase a class prototype before validation. + const cwd = source.cwd + const parentSession = source.parentSession + const createdAt = source.createdAt + const seedLength = source.seedLength + const accepted = { + ...cwd !== undefined ? { cwd } : {}, + ...parentSession !== undefined ? { parentSession } : {}, + ...createdAt !== undefined ? { createdAt } : {}, + ...seedLength !== undefined ? { seedLength } : {}, + } + const snapshot = snapshotJsonValue(accepted) + if (snapshot === undefined) throw new Error('session metadata is not losslessly JSON-serializable') + if (snapshot.cwd !== undefined) { + if (typeof snapshot.cwd !== 'string') throw new Error('session cwd must be a string') + if (!isAbsolute(snapshot.cwd)) { + throw new Error(`session cwd must be an absolute path, got "${snapshot.cwd}"`) + } + } + if (snapshot.parentSession !== undefined && typeof snapshot.parentSession !== 'string') { + throw new Error('session parentSession must be a string') + } + if (snapshot.createdAt !== undefined + && (typeof snapshot.createdAt !== 'number' || !Number.isFinite(snapshot.createdAt))) { + throw new Error('session createdAt must be a finite number') + } + if (snapshot.seedLength !== undefined + && (typeof snapshot.seedLength !== 'number' || !Number.isSafeInteger(snapshot.seedLength) || snapshot.seedLength < 0)) { + throw new Error('session seedLength must be a non-negative safe integer') + } + return snapshot +} + +/** Detach, validate, and freeze the creation metadata published by a session. */ +function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader { + const input: SessionHeader = source === undefined + ? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() } + : source + assertPlainRecord(input, 'session header') + + // Capture each property once before validation. A stateful accessor therefore + // cannot present one identity or storage location to a check and publish a + // different one afterward. + const version = input.version + const headerId = input.id + const createdAt = input.createdAt + const cwd = input.cwd + const parentSession = input.parentSession + const seedLength = input.seedLength + const accepted = { + version, + id: headerId, + createdAt, + ...cwd !== undefined ? { cwd } : {}, + ...parentSession !== undefined ? { parentSession } : {}, + ...seedLength !== undefined ? { seedLength } : {}, + } + const snapshot = snapshotJsonValue(accepted) + if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable') + if (snapshot.version !== SESSION_FORMAT_VERSION) { + throw new Error(`session header version must be ${SESSION_FORMAT_VERSION}, got ${String(snapshot.version)}`) + } + if (snapshot.id !== id) { + throw new Error(`session header id "${String(snapshot.id)}" does not match session id "${id}"`) + } + if (typeof snapshot.createdAt !== 'number' || !Number.isFinite(snapshot.createdAt)) { + throw new Error('session header createdAt must be a finite number') + } + if (snapshot.cwd !== undefined) { + if (typeof snapshot.cwd !== 'string') throw new Error('session header cwd must be a string') + if (!isAbsolute(snapshot.cwd)) { + throw new Error(`session header cwd must be an absolute path, got "${snapshot.cwd}"`) + } + } + if (snapshot.parentSession !== undefined && typeof snapshot.parentSession !== 'string') { + throw new Error('session header parentSession must be a string') + } + if (snapshot.seedLength !== undefined + && (typeof snapshot.seedLength !== 'number' || !Number.isSafeInteger(snapshot.seedLength) || snapshot.seedLength < 0)) { + throw new Error('session header seedLength must be a non-negative safe integer') + } + return deepFreeze(snapshot) +} + +/** Validate the runtime shape of surface metadata after its JSON snapshot. */ +function assertSurfaceMetadataShape( + type: string, + surfaceOp: unknown, + sourceEventSeqs: unknown, +): void { + const eligible = isSurfaceEligibleType(type) + if (!eligible) { + if (surfaceOp !== undefined || sourceEventSeqs !== undefined) { + throw new Error(`session event "${type}" is not surface-eligible and cannot carry surface metadata`) + } + return + } + if (surfaceOp === undefined) { + throw new Error(`session event "${type}" is surface-eligible and requires a surfaceOp marker`) + } + if (surfaceOp !== 'append') { + if (surfaceOp === null || typeof surfaceOp !== 'object' || Array.isArray(surfaceOp)) { + throw new Error(`session event "${type}" carries an invalid surfaceOp`) + } + const op = surfaceOp as Record + const keys = Object.keys(op) + if (keys.length !== 3 || !Object.hasOwn(op, 'op') || !Object.hasOwn(op, 'start') || !Object.hasOwn(op, 'end') + || op['op'] !== 'replace' + || typeof op['start'] !== 'number' || !Number.isSafeInteger(op['start']) || op['start'] < 0 + || typeof op['end'] !== 'number' || !Number.isSafeInteger(op['end']) || op['end'] < 0) { + throw new Error(`session event "${type}" carries an invalid replace surfaceOp`) + } + } + if (sourceEventSeqs !== undefined) { + if (!Array.isArray(sourceEventSeqs) + || sourceEventSeqs.some(seq => typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0)) { + throw new Error(`session event "${type}" sourceEventSeqs must contain non-negative safe integers`) + } + } +} + +/** Validate the fixed event envelope after one-pass JSON materialization. */ +function assertSessionEventEnvelope(value: Record, index: number): asserts value is SessionEvent { + const event = value + const allowed = new Set(['type', 'seq', 'time', 'data', 'surfaceOp', 'sourceEventSeqs']) + if (Object.keys(event).some(key => !allowed.has(key)) + || !Object.hasOwn(event, 'type') || typeof event['type'] !== 'string' + || !Object.hasOwn(event, 'seq') || typeof event['seq'] !== 'number' + || !Number.isSafeInteger(event['seq']) || event['seq'] < 0 + || !Object.hasOwn(event, 'time') || typeof event['time'] !== 'number' + || !Number.isSafeInteger(event['time']) || event['time'] < 0 + || !Object.hasOwn(event, 'data')) { + throw new Error(`seed event at index ${index} has an invalid event envelope`) + } +} + /** * An event-sourced session: an append-only log of {@link SessionEvent}s. * @@ -126,10 +280,10 @@ export class Session { } /** - * Immutable creation metadata (format version, cwd, lineage, seed boundary). - * Supplied by the store via `ctx.sessions.create()`. When a `Session` is - * constructed bare (tests, ad-hoc replay), a minimal header is synthesized - * (stamped with the current {@link SESSION_FORMAT_VERSION}) so + * Detached, deep-frozen creation metadata (format version, cwd, lineage, + * seed boundary). Supplied by the store via `ctx.sessions.create()`. When a + * `Session` is constructed bare (tests, ad-hoc replay), a minimal header is + * synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so * `session.header` is always present. Kept out of the event log — it is a * storage concern, not replayable conversation state. */ @@ -144,12 +298,27 @@ export class Session { // `seq = log.length` contract the whole system relies on). Without this, // a bad seed would surface only later as a backend rejection or a silent // divergence between the live log and disk. - seed.forEach((event, index) => { - if (event.seq !== index) { - throw new Error(`seed event at index ${index} has seq ${event.seq} (expected ${index}); seed must be contiguous from 0`) + this.log = Array.from(seed, (source, index) => { + // Spreading would erase a class instance's prototype. Reject an exotic + // event shell before that normalization can turn it into an apparently + // valid plain record; field values are still captured by the one spread + // below, so their accessors are not read twice. + assertPlainRecord(source, `seed event at index ${index}`) + // Read every enumerable event field once. Validation and snapshot + // construction must consume this same captured record: a stateful seed + // index or event getter cannot present one record to the checks and + // another to the durable log. + const event = { ...source } + // Materialize the complete accepted record in one recursive pass. A + // validate-then-structuredClone sequence would reread nested getters and + // could sanitize a class instance returned only to the clone. + const snapshot = snapshotJsonValue(event) + if (snapshot === undefined) { + throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`) } - if (!isJsonValue(event.data)) { - throw new Error(`seed event "${event.type}" (seq ${event.seq}) carries non-JSON-serializable data`) + assertSessionEventEnvelope(snapshot, index) + if (snapshot.seq !== index) { + throw new Error(`seed event at index ${index} has seq ${snapshot.seq} (expected ${index}); seed must be contiguous from 0`) } // Surface-eligible events MUST carry a surfaceOp marker — the surface is // the sole source of derived history, so a marker-less message event @@ -157,30 +326,30 @@ export class Session { // this at compile time via its typed overload; a seed arrives as raw // SessionEvent[] (replay/fork/load), bypassing that, so re-check at // runtime here rather than silently resuming with empty history. - if (isSurfaceEligibleType(event.type) - && (event as SessionEvent).surfaceOp === undefined) { - throw new Error(`seed event "${event.type}" (seq ${event.seq}) is surface-eligible but carries no surfaceOp marker`) + const structural = snapshot as SessionEvent & { surfaceOp?: unknown; sourceEventSeqs?: unknown } + try { + assertSurfaceMetadataShape(snapshot.type, structural.surfaceOp, structural.sourceEventSeqs) + } catch (error: unknown) { + throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`) } + return deepFreeze(snapshot) }) - // Deep-clone each seed event, NOT just the array: the seed events and - // their `data` are still owned by the caller (or the source session of a - // fork), so keeping the references would let a post-create mutation of the - // original rewrite this session's durable log — or reintroduce a - // non-JSON-serializable value AFTER the validation above. Snapshotting at - // the boundary makes `session.events` independent and keeps it equal to - // what was validated. Serializability is guaranteed by the check above, so - // structuredClone can never hit a non-cloneable value here. - this.log = seed.map(event => structuredClone(event)) } - this.header = header ?? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() } + this.header = snapshotSessionHeader(id, header) } + /** Cached immutable public snapshot of the private append-only log. */ + private eventsSnapshot: readonly SessionEvent[] | undefined + /** - * The append-only event log, exposed live by reference (readonly-typed, not - * a snapshot): later appends are visible through the same array. + * An immutable snapshot of the append-only event log. The snapshot is reused + * until the next append; a previously returned array does not grow later. + * Events and their nested data are deep-frozen at acceptance, so neither a + * cast nor ordinary JavaScript can rewrite durable history. */ get events(): readonly SessionEvent[] { - return this.log + this.eventsSnapshot ??= Object.freeze([...this.log]) + return this.eventsSnapshot } /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */ @@ -205,23 +374,27 @@ export class Session { * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of * `data` that entered the log, so reading `event.data` back sees the logged * value, never the caller's still-mutable input. - * @throws if `data` is not losslessly JSON-serializable (BigInt, function, - * symbol, undefined, non-finite number, circular ref, or an exotic object - * like Map/Set/Date). The event log is the durable source of truth, so this - * invariant is enforced at the source — a bad event never enters the log, - * keeping `session.events` always equal to what a backend can persist. The - * throw surfaces at the buggy caller's append site, not asynchronously in a - * backend flush. + * @throws if `type` is not a string, or if `data` or surface metadata is not + * losslessly JSON-serializable + * (BigInt, function, symbol, undefined, negative zero, non-finite number, + * circular reference, sparse array, or an exotic object such as + * Map/Set/Date/class instance). One recursive pass reads, validates, and + * copies each nested value once, so a stateful getter cannot supply one value + * to validation and another to storage. The event log is the durable source + * of truth, so a bad event fails at the append site rather than later during + * a backend flush. */ append( type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : [] ): SessionEvent { - if (!isJsonValue(data)) { - throw new Error(`session event "${type}" carries non-JSON-serializable data`) + if (typeof type !== 'string') { + throw new TypeError('session event type must be a string') } const surfaceOpts: SurfaceIntent | undefined = opts[0] + const sourceEventSeqs = surfaceOpts?.sourceEventSeqs + const surfaceOp = surfaceOpts?.surfaceOp // Surface-eligible events MUST carry a surfaceOp marker — the surface is the // sole source of derived history, so a marker-less message event would be // logged yet vanish from deriveMessages(). The typed `opts` overload makes @@ -230,41 +403,49 @@ export class Session { // events: `for (const e of log) append(e.type, e.data)`), the conditional // rest collapses to optional and the compiler stops enforcing it. Re-check // at runtime so that loophole can't silently drop history. - if (isSurfaceEligibleType(type) && surfaceOpts?.surfaceOp === undefined) { - throw new Error(`session event "${type}" is surface-eligible and requires a surfaceOp marker`) + const surfaceMetadata = { + ...sourceEventSeqs !== undefined ? { sourceEventSeqs } : {}, + ...surfaceOp !== undefined ? { surfaceOp } : {}, } - // Snapshot `data` into the log, NOT the caller's reference: the validation - // above proves it is JSON-serializable AT THIS MOMENT, but the caller still - // owns the object and could mutate it afterwards (before a persistence - // flush, or permanently in the in-memory history) — making `session.events` - // diverge from the value that passed validation, or reintroducing a - // non-serializable value. Cloning here keeps the log equal to what was - // validated. structuredClone is safe because serializability was just - // checked. The returned event carries the SAME snapshot, so a caller reading - // back `event.data` sees the logged value, not its own mutable input. + // The caller still owns the data and metadata objects and could mutate them + // after append. Materialize each accepted value exactly once while checking + // its JSON vocabulary, so the log cannot drift and a stateful getter cannot + // show one value to validation and another to a prototype-erasing clone. The + // returned event carries these SAME snapshots. // - // Surface metadata is snapshot separately: sourceEventSeqs (number[] — - // primitives, so array spread is a complete copy) and surfaceOp (a string - // primitive, or cloned if it's a replace object). + // Surface metadata accessors are read once into one plain record; the + // recursive snapshot then reads each nested value once as it copies it. // Build the event shape with conditional surface fields via spreading. // The result is cast through `unknown` because the conditional spreads // produce an intersection type that the assignability checker can't // narrow to a specific discriminated-union member when T is generic. - // This is a safe internal boundary: data was validated above, and - // surface metadata was snapshot from primitive/clone-safe values. + // This is a safe internal boundary: data and surface metadata are + // materialized below before the event enters the log. + const dataSnapshot = snapshotJsonValue(data) + if (dataSnapshot === undefined) { + throw new Error(`session event "${type}" carries non-JSON-serializable data`) + } + const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata) + if (surfaceMetadataSnapshot === undefined) { + throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`) + } + assertSurfaceMetadataShape( + type, + (surfaceMetadataSnapshot as { surfaceOp?: unknown }).surfaceOp, + (surfaceMetadataSnapshot as { sourceEventSeqs?: unknown }).sourceEventSeqs, + ) const event = { type, seq: this.log.length, time: Date.now(), - data: structuredClone(data), - ...surfaceOpts?.sourceEventSeqs !== undefined ? { sourceEventSeqs: [...surfaceOpts.sourceEventSeqs] } : {}, - ...surfaceOpts?.surfaceOp !== undefined ? { - surfaceOp: typeof surfaceOpts.surfaceOp === 'string' ? surfaceOpts.surfaceOp : structuredClone(surfaceOpts.surfaceOp), - } : {}, + data: dataSnapshot, + ...surfaceMetadataSnapshot, } as unknown as SessionEvent - this.log.push(event as unknown as SessionEvent) - this.onAppend?.(event as unknown as SessionEvent) - return event + const acceptedEvent = deepFreeze(event) + this.log.push(acceptedEvent as unknown as SessionEvent) + this.eventsSnapshot = undefined + this.onAppend?.(acceptedEvent as unknown as SessionEvent) + return acceptedEvent } /** Cached fold of the request-header events — see {@link requestHeader}. */ @@ -457,7 +638,8 @@ export class SessionStore extends Service { * @param id - the session id; omitted, the store mints `session-`. * @param options - seed events and/or creation metadata for the header. * @returns the live session, already entered and announced. - * @throws if a session with `id` already exists, or if `meta.cwd` is a + * @throws if a session with `id` already exists, metadata is not a plain + * lossless-JSON record with valid scalar fields, or `meta.cwd` is a * non-absolute path (storage backends key directories off it). */ create(id?: SessionId, options?: CreateSessionOptions): Session { @@ -485,25 +667,27 @@ export class SessionStore extends Service { * @param id - the session id; omitted, the store mints `session-`. * @param options - seed events and/or creation metadata for the header. * @returns the constructed session, NOT yet in the store. - * @throws if a session with `id` already exists, or if `meta.cwd` is a + * @throws if a session with `id` already exists, metadata is not a plain + * lossless-JSON record with valid scalar fields, or `meta.cwd` is a * non-absolute path. */ prepare(id?: SessionId, options?: CreateSessionOptions): Session { const sessionId = SessionId(id ?? `session-${++this.counter}`) if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`) - const cwd = options?.meta?.cwd - if (cwd !== undefined && !isAbsolute(cwd)) { - throw new Error(`session cwd must be an absolute path, got "${cwd}"`) - } + const seed = options?.seed + const meta = snapshotSessionMeta(options?.meta) + const cwd = meta.cwd + const parentSession = meta.parentSession + const seedLength = meta.seedLength const header: SessionHeader = { version: SESSION_FORMAT_VERSION, id: sessionId, - createdAt: options?.meta?.createdAt ?? Date.now(), + createdAt: meta.createdAt ?? Date.now(), ...cwd !== undefined ? { cwd } : {}, - ...options?.meta?.parentSession !== undefined ? { parentSession: options.meta.parentSession } : {}, - ...options?.meta?.seedLength !== undefined ? { seedLength: options.meta.seedLength } : {}, + ...parentSession !== undefined ? { parentSession } : {}, + ...seedLength !== undefined ? { seedLength } : {}, } - return new Session(sessionId, options?.seed, header) + return new Session(sessionId, seed, header) } /** diff --git a/packages/core/session/src/json.ts b/packages/core/session/src/json.ts index 22303c6c61..2ec36087dd 100644 --- a/packages/core/session/src/json.ts +++ b/packages/core/session/src/json.ts @@ -1,45 +1,127 @@ /** - * JSON-serializability validation for session event data. + * Lossless-JSON validation and snapshot materialization for session data. * * The session event log is the durable source of truth (the event-sourcing / session-persistence RFCs): every * `event.data` must round-trip losslessly through JSON so any persistence * backend can store and reload it byte-identically. This invariant belongs to * the log itself — `Session.append` enforces it at the source, so a * non-serializable event never enters `session.events` and the live log can - * never diverge from what a backend can persist. Backends re-use the same - * predicate to validate their own `append(events)` entry point (replay/fork - * paths that do not go through a live `Session`). + * never diverge from what a backend can persist. Other public boundaries use + * {@link snapshotJsonValue} when they must validate and detach in one pass; + * {@link isJsonValue} remains the non-copying structural predicate. * * @module @deepseek-ai/dsh-session/json */ /** * A value that round-trips losslessly through JSON: `null`, a boolean, a finite - * number, a string, an array of such values, or a plain object whose values are - * such values. The static type companion to {@link isJsonValue} (which validates - * the same shape at runtime). Use it to type a payload that must survive - * session-log persistence and replay byte-identically — e.g. a tool's private - * presentation `meta`. + * number other than negative zero, a string, an array of such values, or a + * plain object whose values are such values. TypeScript cannot distinguish + * `-0` from `number`, so {@link isJsonValue} and {@link snapshotJsonValue} + * enforce that last numeric detail at runtime. Use this type for a payload that + * must survive session-log persistence and replay byte-identically — e.g. a + * tool's private presentation `meta`. */ export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } /** - * Whether `value` is losslessly JSON-serializable: only `null`, finite numbers, - * booleans, strings, plain arrays, and plain objects of such values. Rejects - * `BigInt`, function, symbol, `undefined`, non-finite numbers (`NaN`/`Infinity`, - * which `JSON.stringify` turns into `null`), and exotic objects (`Map`/`Set`/ - * `Date`/class instances) — anything `JSON.stringify` would drop, throw on, or - * convert lossily. Sparse arrays are rejected too: a hole serializes to `null`, - * so `[1, , 3]` would not round-trip. Detects circular references (which would - * throw) and reports them as non-serializable rather than propagating the throw. + * Materialize one detached lossless-JSON snapshot in a SINGLE recursive pass. + * Each array slot or own enumerable string-keyed object value is read exactly + * once, validated, and copied immediately. This is intentionally not + * `isJsonValue(value)` followed by `structuredClone(value)`: a stateful getter + * could return plain JSON to the check and an exotic class instance to the + * clone, whose prototype `structuredClone` would erase before a later check. * - * Scope — matches `JSON.stringify` exactly: only an object's OWN ENUMERABLE - * STRING-keyed properties are inspected (`Object.values`). Symbol-keyed and - * non-enumerable properties are NOT examined, because `JSON.stringify` likewise - * drops them — they never reach the durable form, so a non-serializable value - * hiding under a symbol/non-enumerable key cannot make the round-trip lossy. - * Getters are invoked during the check (again as `JSON.stringify` would), so the - * contract is for plain data records, not objects with side-effecting accessors. + * Accepts the same scalar/object vocabulary as {@link isJsonValue}: arrays use + * the ordinary `Array.prototype` (subclass instances are not plain JSON + * containers), while null-prototype objects are accepted and normalized to + * ordinary plain objects. Sparse arrays, cycles, negative zero, non-finite + * numbers, unsupported scalar types, and exotic object or array shells return + * `undefined`. A throwing getter is a caller failure and propagates unchanged. + * + * @param value - the candidate value to validate and detach. + * @returns the detached snapshot, or `undefined` when the value is not + * losslessly JSON-serializable. + */ +export function snapshotJsonValue(value: T): T | undefined { + const ancestors = new Set() + + const visit = (current: unknown): JsonValue | undefined => { + if (current === null) return null + switch (typeof current) { + case 'boolean': + case 'string': + return current + case 'number': + return Number.isFinite(current) && !Object.is(current, -0) ? current : undefined + case 'bigint': + case 'function': + case 'symbol': + case 'undefined': + return undefined + case 'object': + break + } + + if (ancestors.has(current)) return undefined + ancestors.add(current) + try { + if (Array.isArray(current)) { + if (Object.getPrototypeOf(current) !== Array.prototype) return undefined + const length = current.length + const snapshot: JsonValue[] = [] + for (let index = 0; index < length; index++) { + if (!Object.prototype.hasOwnProperty.call(current, index)) return undefined + const item = visit(current[index]) + if (item === undefined) return undefined + snapshot.push(item) + } + return snapshot + } + + const prototype = Object.getPrototypeOf(current) as unknown + if (prototype !== Object.prototype && prototype !== null) return undefined + const snapshot: { [key: string]: JsonValue } = {} + for (const key of Object.keys(current)) { + const item = visit((current as Record)[key]) + if (item === undefined) return undefined + // Define the key as data so a JSON field literally named "__proto__" + // cannot mutate the snapshot's prototype through ordinary assignment. + Object.defineProperty(snapshot, key, { + value: item, + enumerable: true, + configurable: true, + writable: true, + }) + } + return snapshot + } finally { + ancestors.delete(current) + } + } + + return visit(value) as T | undefined +} + +/** + * Whether `value` is losslessly JSON-serializable: only `null`, finite numbers + * other than negative zero, booleans, strings, plain arrays, and plain objects + * of such values. Rejects `BigInt`, function, symbol, `undefined`, `-0` (which + * JSON rewrites to `0`), non-finite numbers (`NaN`/`Infinity`, which JSON turns + * into `null`), and exotic objects (`Map`/`Set`/`Date`/class instances) — + * anything `JSON.stringify` would drop, throw on, or convert lossily. Sparse + * arrays are rejected too: a hole serializes to `null`, so `[1, , 3]` would not + * round-trip. Detects circular references (which would throw) and reports them + * as non-serializable rather than propagating the throw. + * + * Scope — this is a structural plain-data predicate, not an invocation of + * `JSON.stringify`: only an object's OWN ENUMERABLE STRING-keyed properties are + * inspected (`Object.values`). Symbol-keyed and non-enumerable properties are + * omitted from the durable data surface. Custom `toJSON` behavior is not + * executed; boundaries that persist a value first materialize a new plain-data + * record with {@link snapshotJsonValue}. Getters are invoked during this check, + * so callers that need a stable detached value use that one-pass materializer + * instead of checking and then rereading a side-effecting record. * @param value - the candidate event data to test. * @param seen - objects on the current descent path, for circular-reference * detection; the recursion threads it — callers omit it. @@ -52,7 +134,7 @@ export function isJsonValue(value: unknown, seen: Set = new Set()): bool case 'string': return true case 'number': - return Number.isFinite(value) + return Number.isFinite(value) && !Object.is(value, -0) case 'bigint': case 'function': case 'symbol': @@ -66,6 +148,7 @@ export function isJsonValue(value: unknown, seen: Set = new Set()): bool seen.add(value) try { if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype) return false // Reject sparse arrays: a hole is skipped by `every`/`forEach` but // JSON.stringify writes it as `null`, so `[1, , 3]` would round-trip // lossily. Require every index 0..length-1 to be an OWN property. diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index ca6779e1dc..e564929ae0 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -32,6 +32,9 @@ export const SESSION_FORMAT_VERSION = 0 /** * Immutable session metadata — written once at creation and never rewritten. + * {@link Session} enforces that contract at runtime: it validates and detaches + * the accepted scalar fields, requires this header's id to match the session + * id, and deep-freezes the published record. * * Kept SEPARATE from the event log deliberately: format-version, cwd, and * lineage are storage concerns, not conversation events, so they stay out of @@ -75,7 +78,8 @@ export interface CreateSessionOptions { /** Events to seed the new session with (replay/fork). */ seed?: SessionEvent[] /** - * Creation metadata. The store fills in `version`/`id` and defaults + * Creation metadata. The store reads this plain record and each accepted + * field once, then fills in `version`/`id` and defaults * `createdAt` to now; the caller supplies the storage-level fields (validated * absolute `cwd`, `parentSession` lineage, the seed boundary `seedLength`, and * — when reconstructing a persisted session — the original `createdAt` to diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index 25328294cf..af143ea5ee 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -60,7 +60,7 @@ describe('SessionStore.fork', () => { }) }) - it('forks the latest completed boundary by default and deep-clones seed events', async () => { + it('forks the latest completed boundary by default into detached frozen seed events', async () => { const { ctx, sessions } = await setup() const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } }) appendClosedTurn(source, 1, 'hello') @@ -70,8 +70,11 @@ describe('SessionStore.fork', () => { expect(child.events).toEqual(source.events) expect(child.events).not.toBe(source.events) expect(child.events[1]).not.toBe(source.events[1]) - firstUserMessage(child.events).data.content[0] = { type: 'text', text: 'child mutation' } + expect(() => { + firstUserMessage(child.events).data.content[0] = { type: 'text', text: 'child mutation' } + }).toThrow(TypeError) expect(firstUserMessage(source.events).data.content).toEqual([{ type: 'text', text: 'hello' }]) + expect(firstUserMessage(child.events).data.content).toEqual([{ type: 'text', text: 'hello' }]) expect(child.header).toMatchObject({ id: SessionId('child'), cwd: '/workspace', diff --git a/packages/core/session/tests/json.spec.ts b/packages/core/session/tests/json.spec.ts new file mode 100644 index 0000000000..4fb06fd744 --- /dev/null +++ b/packages/core/session/tests/json.spec.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest' +import { isJsonValue, snapshotJsonValue } from '@deepseek-ai/dsh-session' + +describe('snapshotJsonValue', () => { + it('copies the complete JSON scalar vocabulary and rejects unsupported scalars', () => { + const unsupportedFunction = (): void => {} + + expect(snapshotJsonValue(null)).toBeNull() + expect(snapshotJsonValue(true)).toBe(true) + expect(snapshotJsonValue('text')).toBe('text') + expect(snapshotJsonValue(1.25)).toBe(1.25) + expect(snapshotJsonValue(-0)).toBeUndefined() + expect(isJsonValue(-0)).toBe(false) + expect(snapshotJsonValue(Number.NaN)).toBeUndefined() + expect(snapshotJsonValue(Number.POSITIVE_INFINITY)).toBeUndefined() + expect(snapshotJsonValue(1n)).toBeUndefined() + expect(snapshotJsonValue(unsupportedFunction)).toBeUndefined() + expect(snapshotJsonValue(Symbol('value'))).toBeUndefined() + const unsupportedUndefined: unknown = undefined + expect(snapshotJsonValue(unsupportedUndefined)).toBeUndefined() + }) + + it('recursively detaches dense arrays and plain or null-prototype objects', () => { + const shared = { value: 1 } + const nullPrototype = Object.assign(Object.create(null) as Record, { shared }) + const source = { list: [nullPrototype, shared], alias: shared } + + const snapshot = snapshotJsonValue(source)! + shared.value = 2 + + expect(snapshot).toEqual({ list: [{ shared: { value: 1 } }, { value: 1 }], alias: { value: 1 } }) + expect(snapshot).not.toBe(source) + expect(snapshot.list).not.toBe(source.list) + expect(snapshot.alias).not.toBe(shared) + expect(snapshot.list[0]).not.toBe(nullPrototype) + expect(Object.getPrototypeOf(snapshot.list[0])).toBe(Object.prototype) + }) + + it('reads each object value and array slot once while materializing', () => { + class Exotic { + readonly accepted = false + } + let objectReads = 0 + let arrayReads = 0 + const nested = Object.defineProperty({}, 'value', { + enumerable: true, + get: () => { + objectReads += 1 + return objectReads === 1 ? { accepted: true } : new Exotic() + }, + }) + const array = new Array(1) + Object.defineProperty(array, 0, { + enumerable: true, + get: () => { + arrayReads += 1 + return arrayReads === 1 ? nested : new Exotic() + }, + }) + + expect(snapshotJsonValue(array)).toEqual([{ value: { accepted: true } }]) + expect(objectReads).toBe(1) + expect(arrayReads).toBe(1) + }) + + it('rejects exotic containers, sparse arrays, cycles, and invalid children', () => { + class ExoticObject { + readonly value = 1 + } + class ExoticArray extends Array {} + const sparse = new Array(1) + const cyclic: Record = {} + cyclic.self = cyclic + + expect(snapshotJsonValue(new ExoticObject())).toBeUndefined() + expect(snapshotJsonValue(new Map([['value', 1]]))).toBeUndefined() + expect(snapshotJsonValue(new ExoticArray(1))).toBeUndefined() + expect(snapshotJsonValue(sparse)).toBeUndefined() + expect(snapshotJsonValue(cyclic)).toBeUndefined() + expect(snapshotJsonValue([undefined])).toBeUndefined() + expect(snapshotJsonValue({ value: undefined })).toBeUndefined() + }) + + it('preserves a literal __proto__ JSON key without changing the snapshot prototype', () => { + const source = Object.create(null) as Record + source.__proto__ = { safe: true } + + const snapshot = snapshotJsonValue(source)! + + expect(Object.getPrototypeOf(snapshot)).toBe(Object.prototype) + expect(Object.prototype.hasOwnProperty.call(snapshot, '__proto__')).toBe(true) + expect(snapshot.__proto__).toEqual({ safe: true }) + }) + + it('propagates a throwing getter after reading it once', () => { + const failure = new Error('getter failed') + let reads = 0 + const source = Object.defineProperty({}, 'value', { + enumerable: true, + get: () => { + reads += 1 + throw failure + }, + }) + + expect(() => snapshotJsonValue(source)).toThrow(failure) + expect(reads).toBe(1) + }) +}) + +describe('isJsonValue', () => { + it('recognizes supported scalars and rejects every lossy scalar case', () => { + const unsupportedFunction = (): void => {} + const unsupportedUndefined: unknown = undefined + + expect(isJsonValue(null)).toBe(true) + expect(isJsonValue(false)).toBe(true) + expect(isJsonValue('text')).toBe(true) + expect(isJsonValue(1.25)).toBe(true) + expect(isJsonValue(-0)).toBe(false) + expect(isJsonValue(Number.NaN)).toBe(false) + expect(isJsonValue(1n)).toBe(false) + expect(isJsonValue(unsupportedFunction)).toBe(false) + expect(isJsonValue(Symbol('value'))).toBe(false) + expect(isJsonValue(unsupportedUndefined)).toBe(false) + }) + + it('accepts dense arrays and plain objects, including null-prototype records', () => { + const nullPrototype = Object.assign(Object.create(null) as Record, { value: true }) + + expect(isJsonValue([1, { nested: null }, nullPrototype])).toBe(true) + expect(isJsonValue({ value: [1, 2] })).toBe(true) + expect(isJsonValue(nullPrototype)).toBe(true) + }) + + it('rejects sparse arrays, invalid children, exotic objects, and cycles', () => { + class Exotic { + readonly value = 1 + } + class ExoticArray extends Array {} + const sparse = new Array(1) + const cyclic: Record = {} + cyclic.self = cyclic + + expect(isJsonValue(sparse)).toBe(false) + expect(isJsonValue(new ExoticArray(1))).toBe(false) + expect(isJsonValue([undefined])).toBe(false) + expect(isJsonValue({ value: undefined })).toBe(false) + expect(isJsonValue(new Exotic())).toBe(false) + expect(isJsonValue(cyclic)).toBe(false) + }) +}) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index f63353af9b..f7037609e6 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -1,8 +1,8 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' -import type { SessionEventType, TodoItem } from '@deepseek-ai/dsh-session' +import type { CreateSessionOptions, SessionEventType, SessionHeader, TodoItem } from '@deepseek-ai/dsh-session' describe('Session', () => { it('derives message history from the event log', () => { @@ -129,6 +129,16 @@ describe('Session', () => { expect(session.events).toHaveLength(0) }) + it('rejects a non-string event type without retaining or freezing caller data', () => { + const session = new Session(SessionId('invalid-event-type')) + const type = { tag: 'caller-owned' } + const appendRaw = session.append.bind(session) as unknown as (type: unknown, data: unknown) => SessionEvent + + expect(() => appendRaw(type, {})).toThrow(/event type must be a string/) + expect(Object.isFrozen(type)).toBe(false) + expect(session.events).toEqual([]) + }) + it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => { const session = new Session(SessionId('s5b')) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -156,7 +166,7 @@ describe('Session', () => { const badSeed = [ { type: 'user/message' as const, seq: 0, time: 1, data: { content: [{ type: 'text' as const, text: 'x' }], source: { kind: 'user' as const }, bad: 1n } }, ] as unknown as SessionEvent[] - expect(() => new Session(SessionId('seed-bad'), badSeed)).toThrow(/non-JSON-serializable/) + expect(() => new Session(SessionId('seed-bad'), badSeed)).toThrow(/losslessly JSON-serializable/) }) it('validates seed events: rejects a non-contiguous seq', () => { @@ -177,7 +187,7 @@ describe('Session', () => { { type: 'user/message' as const, seq: 1, time: 2, data: { content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const } } }, { type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } }, ] as SessionEvent[] - expect(() => new Session(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/surface-eligible but carries no surfaceOp/) + expect(() => new Session(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/requires a surfaceOp marker/) }) it('accepts a well-formed contiguous serializable seed', () => { @@ -190,6 +200,151 @@ describe('Session', () => { expect(session.events).toHaveLength(3) }) + it('reads each seed array entry once so validation and storage use the same event', () => { + const accepted = { + type: 'turn/start' as const, + seq: 0, + time: 1, + data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } }, + } + const drifted = { ...accepted, seq: 99, data: { invalid: 1n } } + let reads = 0 + const seed = new Array(1) + Object.defineProperty(seed, 0, { + enumerable: true, + get: () => { + reads += 1 + return reads === 1 ? accepted : drifted + }, + }) + + const session = new Session(SessionId('seed-entry-snapshot'), seed) + + expect(reads).toBe(1) + expect(session.events).toEqual([accepted]) + }) + + it('reads a nested seed-data getter once and stores its first JSON value', () => { + let reads = 0 + const data = Object.defineProperty({}, 'value', { + enumerable: true, + get: () => { + reads += 1 + return reads === 1 ? 'accepted' : 1n + }, + }) + const seed = [{ type: 'test/unstable', seq: 0, time: 1, data }] as unknown as SessionEvent[] + + const session = new Session(SessionId('seed-nested-drift'), seed) + + expect(reads).toBe(1) + expect(session.events[0]!.data).toEqual({ value: 'accepted' }) + }) + + it('rejects non-JSON surface metadata in a seed event', () => { + const seed = [{ + type: 'user/message', + seq: 0, + time: 1, + data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, + surfaceOp: { op: 'replace', start: 1n, end: 2 }, + }] as unknown as SessionEvent[] + + expect(() => new Session(SessionId('seed-bad-metadata'), seed)) + .toThrow(/losslessly JSON-serializable/) + }) + + it('rejects exotic seed metadata before cloning can erase its prototype', () => { + class ReplaceOp { + readonly op = 'replace' as const + readonly start = 0 + readonly end = 0 + } + const seed = [{ + type: 'user/message', + seq: 0, + time: 1, + data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, + surfaceOp: new ReplaceOp(), + }] as unknown as SessionEvent[] + + expect(() => new Session(SessionId('seed-exotic-metadata'), seed)) + .toThrow(/losslessly JSON-serializable/) + }) + + it('rejects an exotic seed event shell before spreading erases its prototype', () => { + class SeedEvent { + readonly type = 'turn/start' as const + readonly seq = 0 + readonly time = 1 + readonly data = { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } + } + const seed: SessionEvent[] = [new SeedEvent()] + + expect(() => new Session(SessionId('seed-exotic-shell'), seed)) + .toThrow(/not a plain JSON record/) + }) + + it('accepts a null-prototype seed event shell as a plain JSON record', () => { + const event = Object.assign(Object.create(null) as Record, { + type: 'turn/start' as const, + seq: 0, + time: 1, + data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } }, + }) as unknown as SessionEvent + + const session = new Session(SessionId('seed-null-prototype'), [event]) + + expect(session.events).toEqual([{ ...event }]) + }) + + it('reads a nested seed-metadata getter once and stores its first JSON value', () => { + let reads = 0 + const surfaceOp = Object.defineProperty({ op: 'replace', end: 0 }, 'start', { + enumerable: true, + get: () => { + reads += 1 + return reads === 1 ? 0 : 1n + }, + }) + const seed = [{ + type: 'user/message', + seq: 0, + time: 1, + data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, + surfaceOp, + }] as unknown as SessionEvent[] + + const session = new Session(SessionId('seed-unstable-metadata'), seed) + const event = session.events[0]! + if (event.type !== 'user/message') throw new Error('test fixture must remain a user/message') + + expect(reads).toBe(1) + expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 }) + }) + + it('adds seed context when surface validation throws a non-Error value', () => { + const originalHasOwn = Object.hasOwn + const hasOwn = vi.spyOn(Object, 'hasOwn').mockImplementation((object: object, property: PropertyKey): boolean => { + if ((object as Record)['op'] === 'replace') throw 'validator failed' + return originalHasOwn(object, property) + }) + const seed = [{ + type: 'user/message', + seq: 0, + time: 1, + data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, + surfaceOp: { op: 'replace', start: 0, end: 0 }, + }] as unknown as SessionEvent[] + + try { + expect(() => new Session(SessionId('seed-non-error-metadata-failure'), seed)) + .toThrow('invalid seed event at index 0: invalid surface metadata') + } finally { + hasOwn.mockRestore() + } + }) + it('snapshots the seed: mutating the original after construction does not affect session.events', () => { const seed = [ { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, @@ -222,6 +377,304 @@ describe('Session', () => { // The returned event carries the same snapshot, not the caller's input. expect((event.data.content[0] as { text: string }).text).toBe('original') }) + + it('reads a nested append-data getter once and stores its first JSON value', () => { + const session = new Session(SessionId('append-nested-drift')) + let reads = 0 + const data = Object.defineProperty({}, 'value', { + enumerable: true, + get: () => { + reads += 1 + return reads === 1 ? 'accepted' : 1n + }, + }) + + const event = session.append('todo/write', data as never) + + expect(reads).toBe(1) + expect(event.data).toEqual({ value: 'accepted' }) + expect(session.events).toEqual([event]) + }) + + it('reads surface metadata accessors once so a validated marker is logged', () => { + const session = new Session(SessionId('surface-intent-snapshot')) + let reads = 0 + const intent = { + get surfaceOp(): 'append' | undefined { + reads += 1 + return reads === 1 ? 'append' : undefined + }, + } + + const event = session.append( + 'user/message', + { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, + intent as { surfaceOp: 'append' }, + ) + + expect(reads).toBe(1) + expect(event.surfaceOp).toBe('append') + }) + + it('rejects non-JSON surface metadata before appending the event', () => { + const session = new Session(SessionId('append-bad-metadata')) + + expect(() => session.append( + 'user/message', + { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, + { surfaceOp: { op: 'replace', start: 1n, end: 2 } } as never, + )).toThrow(/non-JSON-serializable surface metadata/) + expect(session.events).toEqual([]) + }) + + it('rejects exotic surface metadata before cloning can erase its prototype', () => { + class ReplaceOp { + readonly op = 'replace' as const + readonly start = 0 + readonly end = 0 + } + const session = new Session(SessionId('append-exotic-metadata')) + + expect(() => session.append( + 'user/message', + { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, + { surfaceOp: new ReplaceOp() }, + )).toThrow(/non-JSON-serializable surface metadata/) + expect(session.events).toEqual([]) + }) + + it('reads a nested append-metadata getter once and stores its first JSON value', () => { + const session = new Session(SessionId('append-unstable-metadata')) + let reads = 0 + const surfaceOp = Object.defineProperty({ op: 'replace', end: 0 }, 'start', { + enumerable: true, + get: () => { + reads += 1 + return reads === 1 ? 0 : 1n + }, + }) + + const event = session.append( + 'user/message', + { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, + { surfaceOp } as never, + ) + + expect(reads).toBe(1) + expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 }) + expect(session.events).toEqual([event]) + }) + + it('rejects invalid plain surface metadata shapes at append', () => { + const session = new Session(SessionId('append-invalid-surface-shape')) + const appendRaw = session.append.bind(session) as unknown as ( + type: SessionEventType, + data: unknown, + opts?: unknown, + ) => SessionEvent + const data = { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } } + + expect(() => appendRaw('user/message', data, { surfaceOp: 'invalid' })) + .toThrow(/invalid surfaceOp/) + expect(() => appendRaw('user/message', data, { + surfaceOp: { op: 'replace', start: -1, end: 0 }, + })).toThrow(/invalid replace surfaceOp/) + expect(() => appendRaw('user/message', data, { + surfaceOp: 'append', + sourceEventSeqs: [0, -1], + })).toThrow(/non-negative safe integers/) + expect(session.events).toEqual([]) + }) + + it('rejects surface metadata on non-surface append and seed events', () => { + const session = new Session(SessionId('non-surface-metadata')) + const appendRaw = session.append.bind(session) as unknown as ( + type: SessionEventType, + data: unknown, + opts?: unknown, + ) => SessionEvent + + expect(() => appendRaw( + 'turn/start', + { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + { surfaceOp: 'append' }, + )).toThrow(/not surface-eligible and cannot carry surface metadata/) + expect(() => new Session(SessionId('non-surface-metadata-seed'), [{ + type: 'turn/start', + seq: 0, + time: 1, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + surfaceOp: 'append', + } as unknown as SessionEvent])).toThrow(/invalid seed event.*not surface-eligible/) + expect(session.events).toEqual([]) + }) + + it('deep-freezes seeded and appended event snapshots', () => { + const seeded = new Session(SessionId('seed-frozen'), [{ + type: 'turn/start', + seq: 0, + time: 1, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + }]) + const seededEvent = seeded.events[0]! + if (seededEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start') + expect(Object.isFrozen(seededEvent)).toBe(true) + expect(Object.isFrozen(seededEvent.data)).toBe(true) + expect(Object.isFrozen(seededEvent.data.trigger)).toBe(true) + expect(() => { seededEvent.data.turn = 99 }).toThrow(TypeError) + + const appended = new Session(SessionId('append-frozen')) + const appendedEvent = appended.append('todo/write', { + todos: [{ content: 'first', status: 'pending' }], + }) + expect(Object.isFrozen(appendedEvent)).toBe(true) + expect(Object.isFrozen(appendedEvent.data)).toBe(true) + expect(Object.isFrozen(appendedEvent.data.todos)).toBe(true) + expect(Object.isFrozen(appendedEvent.data.todos[0])).toBe(true) + expect(() => { appendedEvent.data.todos[0]!.content = 'mutated' }).toThrow(TypeError) + }) + + it('returns cached frozen event-array snapshots that do not grow after append', () => { + const session = new Session(SessionId('events-snapshot')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const before = session.events + const beforeEvent = before[0]! + if (beforeEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start') + + expect(session.events).toBe(before) + expect(Object.isFrozen(before)).toBe(true) + expect(() => { (before as SessionEvent[]).push(beforeEvent) }).toThrow(TypeError) + expect(() => { beforeEvent.data.turn = 99 }).toThrow(TypeError) + + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const after = session.events + expect(before).toHaveLength(1) + expect(after).toHaveLength(2) + expect(after).not.toBe(before) + expect(session.events).toBe(after) + }) + + it('detaches and freezes an explicitly supplied session header', () => { + const input = { + version: SESSION_FORMAT_VERSION, + id: SessionId('header-owned'), + createdAt: 123, + cwd: '/accepted', + parentSession: SessionId('parent'), + seedLength: 2, + } + + const session = new Session(SessionId('header-owned'), undefined, input) + input.cwd = '/caller-mutated' + + expect(session.header).toEqual({ + version: SESSION_FORMAT_VERSION, + id: 'header-owned', + createdAt: 123, + cwd: '/accepted', + parentSession: 'parent', + seedLength: 2, + }) + expect(session.header).not.toBe(input) + expect(Object.isFrozen(session.header)).toBe(true) + expect(Reflect.set(session.header, 'cwd', '/published-mutated')).toBe(false) + expect(session.header.cwd).toBe('/accepted') + }) + + it('reads each supplied header field once before validation and publication', () => { + const reads = { version: 0, id: 0, createdAt: 0, cwd: 0, parentSession: 0, seedLength: 0 } + const header = { + get version() { reads.version += 1; return reads.version === 1 ? SESSION_FORMAT_VERSION : 99 }, + get id() { reads.id += 1; return reads.id === 1 ? SessionId('header-once') : SessionId('drifted') }, + get createdAt() { reads.createdAt += 1; return reads.createdAt === 1 ? 123 : Number.NaN }, + get cwd() { reads.cwd += 1; return reads.cwd === 1 ? '/accepted' : 'relative' }, + get parentSession() { reads.parentSession += 1; return reads.parentSession === 1 ? SessionId('parent') : 1n }, + get seedLength() { reads.seedLength += 1; return reads.seedLength === 1 ? 0 : 1n }, + } as unknown as SessionHeader + + const session = new Session(SessionId('header-once'), undefined, header) + + expect(reads).toEqual({ version: 1, id: 1, createdAt: 1, cwd: 1, parentSession: 1, seedLength: 1 }) + expect(session.header).toEqual({ + version: SESSION_FORMAT_VERSION, + id: 'header-once', + createdAt: 123, + cwd: '/accepted', + parentSession: 'parent', + seedLength: 0, + }) + }) + + it('rejects an exotic, non-JSON, or mismatched supplied header', () => { + class ExoticHeader implements SessionHeader { + readonly version = SESSION_FORMAT_VERSION + readonly id = SessionId('header-invalid') + readonly createdAt = 123 + } + + expect(() => new Session(SessionId('header-invalid'), undefined, new ExoticHeader())) + .toThrow(/not a plain JSON record/) + expect(() => new Session(SessionId('header-invalid'), undefined, { + version: SESSION_FORMAT_VERSION, + id: SessionId('header-invalid'), + createdAt: 123, + parentSession: 1n, + } as unknown as SessionHeader)).toThrow(/not losslessly JSON-serializable/) + expect(() => new Session(SessionId('header-invalid'), undefined, { + version: SESSION_FORMAT_VERSION, + id: SessionId('other'), + createdAt: 123, + })).toThrow(/does not match session id/) + }) + + it('rejects invalid scalar fields in an explicitly supplied header', () => { + const base = { + version: SESSION_FORMAT_VERSION, + id: SessionId('header-shape'), + createdAt: 123, + } + const cases: Array<{ header: unknown; error: RegExp }> = [ + { header: 1, error: /not a plain JSON record/ }, + { header: null, error: /not a plain JSON record/ }, + { header: { ...base, version: 1 }, error: /header version/ }, + { header: { ...base, createdAt: '123' }, error: /createdAt must be a finite number/ }, + { header: { ...base, cwd: 1 }, error: /header cwd must be a string/ }, + { header: { ...base, cwd: 'relative' }, error: /header cwd must be an absolute path/ }, + { header: { ...base, parentSession: 1 }, error: /header parentSession must be a string/ }, + { header: { ...base, seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ }, + { header: { ...base, seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ }, + { header: { ...base, seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ }, + ] + + for (const { header, error } of cases) { + expect(() => new Session(SessionId('header-shape'), undefined, header as SessionHeader)).toThrow(error) + } + }) + + it('rejects seed records with invalid fixed-envelope fields', () => { + const base = { + type: 'turn/start', + seq: 0, + time: 1, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + } + const cases: unknown[] = [ + { ...base, extra: true }, + { ...base, type: 1 }, + { ...base, seq: '0' }, + { ...base, seq: 0.5 }, + { ...base, seq: -1 }, + { ...base, time: '1' }, + { ...base, time: 0.5 }, + { ...base, time: -1 }, + { type: base.type, seq: base.seq, time: base.time }, + ] + + for (const [index, event] of cases.entries()) { + expect(() => new Session(SessionId(`bad-envelope-${index}`), [event as SessionEvent])) + .toThrow(/invalid event envelope/) + } + }) }) @@ -317,6 +770,66 @@ describe('SessionStore', () => { }) }) + it('reads session options and each metadata field once in prepare()', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const reads = { seed: 0, meta: 0, cwd: 0, parentSession: 0, createdAt: 0, seedLength: 0 } + const meta = { + get cwd() { reads.cwd += 1; return reads.cwd === 1 ? '/accepted' : 'relative' }, + get parentSession() { reads.parentSession += 1; return reads.parentSession === 1 ? SessionId('parent') : 1n }, + get createdAt() { reads.createdAt += 1; return reads.createdAt === 1 ? 123 : Number.NaN }, + get seedLength() { reads.seedLength += 1; return reads.seedLength === 1 ? 0 : 1n }, + } + const options = { + get seed() { reads.seed += 1; return reads.seed === 1 ? undefined : [] }, + get meta() { reads.meta += 1; return reads.meta === 1 ? meta : undefined }, + } as unknown as CreateSessionOptions + + const session = ctx.sessions.prepare(SessionId('metadata-once'), options) + + expect(reads).toEqual({ seed: 1, meta: 1, cwd: 1, parentSession: 1, createdAt: 1, seedLength: 1 }) + expect(session.header).toEqual({ + version: SESSION_FORMAT_VERSION, + id: 'metadata-once', + createdAt: 123, + cwd: '/accepted', + parentSession: 'parent', + seedLength: 0, + }) + }) + + it('rejects exotic metadata before cloning can erase its prototype', async () => { + class ExoticMeta { + readonly cwd = '/accepted' + } + const ctx = new Context() + await ctx.plugin(SessionStore) + + expect(() => ctx.sessions.prepare(SessionId('exotic-meta'), { meta: new ExoticMeta() })) + .toThrow(/session metadata is not a plain JSON record/) + }) + + it('rejects non-JSON and invalid scalar session metadata', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const cases: Array<{ meta: unknown; error: RegExp }> = [ + { meta: 1, error: /metadata is not a plain JSON record/ }, + { meta: { parentSession: 1n }, error: /metadata is not losslessly JSON-serializable/ }, + { meta: { cwd: 1 }, error: /session cwd must be a string/ }, + { meta: { parentSession: 1 }, error: /parentSession must be a string/ }, + { meta: { createdAt: '123' }, error: /createdAt must be a finite number/ }, + { meta: { seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ }, + { meta: { seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ }, + { meta: { seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ }, + ] + + for (const [index, { meta, error }] of cases.entries()) { + expect(() => ctx.sessions.prepare(SessionId(`bad-meta-${index}`), { + meta: meta as NonNullable, + })).toThrow(error) + } + }) + it('rejects a non-absolute meta.cwd', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index d712cd4b5b..439ebebfc7 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -14,10 +14,10 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool- ### Public API - `ctx.systemPrompt.section(section: PromptSection): () => Promise | void` Contribute a section. The registry snapshots `name`, `order`, and the text value/callback, so later caller-object mutation cannot rename a stored section. The layer is the CALLING context's scope: `agent.ctx` contributes to that agent alone, SHADOWING a same-named global section there (the per-agent persona mechanism — a scoped `deployment:persona`). Duplicate names within one layer throw, and a globally protected section name cannot be shadowed. Disposed with the calling fiber. -- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise | void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set for `context.scope`; `knownNames` (defaulting to the schemas' names) is the pre-restriction universe `toolOrder` validates against. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber. +- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise | void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set for `context.scope`; `knownNames` (defaulting to the same captured schemas' names) is the pre-restriction universe `toolOrder` validates against. Assembly reads the result, each schema field, and the optional known-name list once before detaching them, rejects non-string schema names/descriptions or known names, and uses those same accepted strings for validation and the model-visible collection. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber. - `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => Promise | void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables (via `agent.ctx`) shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber. -- `ctx.systemPrompt.protect(protection: PromptProtection): () => Promise | void` Make named section/tool contributions authoritative after the assembly waterfall. Protection restores canonical registry/provider presence and definition; restored entries keep canonical order with one another and anchor before their first surviving later unprotected canonical neighbor (or at the end), without undoing listener reordering of unprotected entries. Canonical absence is authoritative too, so a mode-hidden tool cannot be fabricated by a listener. Calling through `agent.ctx` protects only that agent's assemblies. A global section protection additionally reserves its name against scoped shadows; registering either side of that conflict fails loudly instead of treating the shadow as canonical. Inputs are snapshotted, empty protections throw, and disposal removes the protection. -- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer (scoped shadows global). Runs through the scope-filtered `system-prompt/assemble` waterfall, then restores protected contributions from the pre-waterfall canonical assembly. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe (a restricted-away KNOWN tool is a normal absence), or when a provider returns the reserved rest-entry name. +- `ctx.systemPrompt.protect(protection: PromptProtection): () => Promise | void` Make named section/tool contributions authoritative after the assembly waterfall. Protection restores canonical registry/provider presence and definition; restored entries keep canonical order with one another and anchor before their first surviving later unprotected canonical neighbor (or at the end), without undoing listener reordering of unprotected entries. Canonical absence is authoritative too, so a mode-hidden tool cannot be fabricated by a listener. Calling through `agent.ctx` protects only that agent's assemblies. A global section protection additionally reserves its name against scoped shadows; registering either side of that conflict fails loudly instead of treating the shadow as canonical. Each input array is read once and snapshotted, empty protections throw, and disposal removes the protection. +- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer (scoped shadows global). Provider output becomes one coherent detached snapshot before `toolOrder` validation. Runs through the scope-filtered `system-prompt/assemble` waterfall, then restores protected contributions from the pre-waterfall canonical assembly. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe (a restricted-away KNOWN tool is a normal absence), or when a provider returns the reserved rest-entry name. ### Live events diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index 120e10ef11..69af28f3b5 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -24,6 +24,7 @@ "peerDependencies": { "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "dependencies": { @@ -32,6 +33,7 @@ "devDependencies": { "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 6d3ed3af58..8d3cc501a8 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -18,6 +18,7 @@ import z from 'schemastery' import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope' import type { ToolSchema } from '@deepseek-ai/dsh-llm' +import { snapshotJsonValue } from '@deepseek-ai/dsh-session' declare module 'cordis' { interface Context { @@ -598,8 +599,9 @@ export class SystemPrompt extends Service { * restored AFTER the whole waterfall, so listener registration order cannot * strip, replace, duplicate, or fabricate it. Canonical absence is restored * too: if the protected name is intentionally absent for an assembly, a - * listener-injected entry with that name is removed. The input arrays are - * snapshotted; an empty protection throws because it cannot affect output. + * listener-injected entry with that name is removed. Each input array is + * read once and snapshotted; an empty protection throws because it cannot + * affect output. * Removed with the calling fiber and emits `system-prompt/change` on * registration/unregistration. A global section protection also reserves the * name against scoped section shadows; registering protection when such a @@ -609,9 +611,11 @@ export class SystemPrompt extends Service { */ protect(protection: PromptProtection): () => Promise | void { const scope = scopeOf(this.ctx) + const sections = protection.sections + const tools = protection.tools const snapshot: PromptProtection = { - ...protection.sections !== undefined ? { sections: [...new Set(protection.sections)] } : {}, - ...protection.tools !== undefined ? { tools: [...new Set(protection.tools)] } : {}, + ...sections !== undefined ? { sections: [...new Set(sections)] } : {}, + ...tools !== undefined ? { tools: [...new Set(tools)] } : {}, } if ((snapshot.sections?.length ?? 0) === 0 && (snapshot.tools?.length ?? 0) === 0) { throw new Error('systemPrompt.protect() requires at least one section or tool name') @@ -669,6 +673,8 @@ export class SystemPrompt extends Service { * the providers' `knownNames` universe rejects the assembly, while a known * name restricted away for this scope is a normal absence), and every * visible variable resolved against `context` into `assembly.variables`. + * Each provider result and schema field is read once; those same captured + * names drive both `toolOrder` validation and the model-visible collection. * Tool schemas are deep-cloned because adapters and request waterfalls may * mutate schema objects. Runs through the `system-prompt/assemble` * waterfall, giving listeners the opportunity to mutate or replace the @@ -724,12 +730,43 @@ export class SystemPrompt extends Service { const knownNames = new Set() for (const provider of providers) { const result = provider(context) - for (const tool of result.schemas) { - collected.push({ ...tool, parameters: structuredClone(tool.parameters) }) - } - for (const name of result.knownNames ?? result.schemas.map(tool => tool.name)) { - knownNames.add(name) + // One provider result snapshot: `schemas`, `knownNames`, and each schema + // field may be accessor-backed. The same captured names must drive both + // toolOrder validation and the model-visible collection. + const inputSchemas = result.schemas + const inputKnownNames = result.knownNames + const schemas = inputSchemas.map((tool, index): ToolSchema => { + const name = tool.name + const description = tool.description + const inputParameters = tool.parameters + if (typeof name !== 'string') { + throw new TypeError(`system prompt tool schema at index ${index} name must be a string`) + } + if (typeof description !== 'string') { + throw new TypeError(`system prompt tool "${name}" description must be a string`) + } + const parameters = snapshotJsonValue(inputParameters) + if (parameters === undefined) { + throw new TypeError(`system prompt tool "${name}" parameters must be losslessly JSON-serializable`) + } + return { name, description, parameters } + }) + let acceptedKnownNames: string[] + if (inputKnownNames === undefined) { + acceptedKnownNames = schemas.map(tool => tool.name) + } else { + if (!Array.isArray(inputKnownNames)) { + throw new TypeError('system prompt tool provider knownNames must be an array of strings') + } + acceptedKnownNames = Array.from(inputKnownNames, (name) => { + if (typeof name !== 'string') { + throw new TypeError('system prompt tool provider knownNames must be an array of strings') + } + return name + }) } + collected.push(...schemas) + for (const name of acceptedKnownNames) knownNames.add(name) } const assembly: PromptAssembly = { sections: [...sectionByName.values()] diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index 95d46b705e..9eef467a8e 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -258,6 +258,30 @@ describe('SystemPrompt', () => { expect(assembly.tools.map(tool => tool.name)).toEqual(['alpha', 'protected', 'zulu']) }) + it('reads protection accessors once so the checked names are the protected names', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + ctx.systemPrompt.section({ name: 'protected', order: 10, text: 'canonical' }) + let reads = 0 + const protection = { + get sections(): string[] { + reads += 1 + return reads === 1 ? ['protected'] : undefined as unknown as string[] + }, + } + ctx.systemPrompt.protect(protection) + ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { + const result = await next() + result.sections = result.sections.filter(section => section.name !== 'protected') + return result + }) + + const assembly = await ctx.systemPrompt.assemble() + + expect(reads).toBe(1) + expect(assembly.sections).toContainEqual({ name: 'protected', order: 10, text: 'canonical' }) + }) + it('protects canonical absence and rejects an empty protection', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) diff --git a/packages/core/system-prompt/tests/tool-order.spec.ts b/packages/core/system-prompt/tests/tool-order.spec.ts index 16eff6e354..088c6b70ed 100644 --- a/packages/core/system-prompt/tests/tool-order.spec.ts +++ b/packages/core/system-prompt/tests/tool-order.spec.ts @@ -48,6 +48,100 @@ describe('SystemPrompt tool order', () => { expect(names(await ctx.systemPrompt.assemble())).toEqual(['todo_write', 'echo_a', 'echo_b', 'bash']) }) + it('reads provider schemas once so toolOrder validates the model-visible collection', async () => { + const ctx = await mount({ toolOrder: ['actual', TOOL_ORDER_REST] }) + let reads = 0 + ctx.systemPrompt.tools(() => ({ + get schemas(): ToolSchema[] { + reads += 1 + return reads === 1 ? [tool('actual')] : [tool('phantom')] + }, + })) + + const assembly = await ctx.systemPrompt.assemble() + + expect(reads).toBe(1) + expect(names(assembly)).toEqual(['actual']) + }) + + it('reads each provider schema field once before detaching it', async () => { + const ctx = await mount() + const accepted = { type: 'object', properties: { accepted: { type: 'string' } } } + let reads = 0 + const schema = { + name: 'stable', + description: 'stable', + get parameters(): object { + reads += 1 + return reads === 1 ? accepted : { type: 'object', properties: { drifted: { type: 'number' } } } + }, + } as ToolSchema + ctx.systemPrompt.tools(() => ({ schemas: [schema] })) + + const assembly = await ctx.systemPrompt.assemble() + + expect(reads).toBe(1) + expect(assembly.tools[0]?.parameters).toEqual(accepted) + }) + + it('rejects exotic provider parameters before model-visible assembly', async () => { + const ctx = await mount() + class ExoticParameters { + readonly type = 'object' + readonly properties = { value: { type: 'string' } } + } + ctx.systemPrompt.tools(() => ({ + schemas: [{ + name: 'exotic', + description: 'must not be sanitized', + parameters: new ExoticParameters() as unknown as ToolSchema['parameters'], + }], + })) + + await expect(ctx.systemPrompt.assemble()) + .rejects.toThrow(/parameters must be losslessly JSON-serializable/) + }) + + it('rejects malformed fixed provider fields without freezing caller objects', async () => { + const ctx = await mount() + const badName = { value: 'object-name' } + const badDescription = { value: 'object-description' } + ctx.systemPrompt.tools(() => ({ + schemas: [{ + name: badName as unknown as string, + description: 'bad name', + parameters: {}, + }], + })) + await expect(ctx.systemPrompt.assemble()).rejects.toThrow('name must be a string') + expect(Object.isFrozen(badName)).toBe(false) + + const descriptions = await mount() + descriptions.systemPrompt.tools(() => ({ + schemas: [{ + name: 'bad-description', + description: badDescription as unknown as string, + parameters: {}, + }], + })) + await expect(descriptions.systemPrompt.assemble()).rejects.toThrow('description must be a string') + expect(Object.isFrozen(badDescription)).toBe(false) + + const knownNames = await mount() + knownNames.systemPrompt.tools(() => ({ + schemas: [tool('valid')], + knownNames: [{} as unknown as string], + })) + await expect(knownNames.systemPrompt.assemble()).rejects.toThrow('knownNames must be an array of strings') + + const nonArrayKnownNames = await mount() + nonArrayKnownNames.systemPrompt.tools(() => ({ + schemas: [tool('valid')], + knownNames: 'valid' as unknown as string[], + })) + await expect(nonArrayKnownNames.systemPrompt.assemble()).rejects.toThrow('knownNames must be an array of strings') + }) + it('rejects the assembly when toolOrder names a tool that is not registered (misconfiguration blocks work)', async () => { const ctx = await mount({ toolOrder: ['todo_write', 'ghost', TOOL_ORDER_REST, 'wraith'] }) ctx.systemPrompt.tools(() => ({ schemas: [tool('bash'), tool('todo_write')] })) diff --git a/packages/core/system-prompt/tsconfig.json b/packages/core/system-prompt/tsconfig.json index 91e7bf1ba4..a66ece4854 100644 --- a/packages/core/system-prompt/tsconfig.json +++ b/packages/core/system-prompt/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../../core/scope" + }, + { + "path": "../../core/session" } ] } diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 2a678408e0..0776ff39f6 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -15,14 +15,14 @@ tools: ### Public API -- `ctx.tools.register(definition: ToolDefinition): () => Promise | void` Register a tool as a frozen snapshot. Parameters must survive lossless-JSON validation before and after cloning; scalar fields are copied, and execute/presentation callbacks are bound once to the original definition as their method receiver, so later callback-property replacement cannot change dispatch. The layer is the CALLING context's scope (`dsh-scope`): a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, SHADOWING a same-named global tool there (per-agent tool variants). Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Disposed with the calling fiber (= the agent, for scoped registrations). -- `ctx.tools.restrict(filter: ToolRestriction): () => Promise | void` Scoped-only (throws on a plain context): mask the GLOBAL end-capability surface for the calling agent — `allow` keeps only the listed tools, `deny` removes them; multiple restrictions intersect; scoped registrations bypass restriction as explicit grants. The reserved `run_code` transport remains available automatically and cannot be named explicitly. Snapshot-at-registration, loud unknown-name validation, `restrict({})` rejects (the materialized-empty-config trap). +- `ctx.tools.register(definition: ToolDefinition): () => Promise | void` Register a tool as a frozen snapshot. Every top-level caller field is read once into one coherent acceptance record; `name`/`description` must be strings and `timeoutMs`, when present, must be positive and finite before the snapshot can own them. Parameters are validated and detached by one recursive lossless-JSON traversal, so a stateful getter cannot show one value to a check and another to a prototype-erasing clone. Execute/presentation callbacks are bound once to the original definition as their method receiver, so later caller mutation cannot change the executable definition. The layer is the CALLING context's scope (`dsh-scope`): a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, SHADOWING a same-named global tool there (per-agent tool variants). Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Disposed with the calling fiber (= the agent, for scoped registrations). +- `ctx.tools.restrict(filter: ToolRestriction): () => Promise | void` Scoped-only (throws on a plain context): mask the GLOBAL end-capability surface for the calling agent — `allow` keeps only the listed tools, `deny` removes them; multiple restrictions intersect; scoped registrations bypass restriction as explicit grants. The registry reads `allow`/`deny` once, so the values checked for an empty filter and unknown names are exactly the values enforced. The reserved `run_code` transport remains available automatically and cannot be named explicitly. Snapshot-at-registration, loud unknown-name validation, `restrict({})` rejects (the materialized-empty-config trap). - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. Returned definitions are the registry's frozen snapshots. - `ctx.tools.visible(scope?: ScopeKey): ToolDefinition[]` The canonical executable view — restricted global layer ∪ the scope's own layer, plus the reserved transport in non-native modes — feeding prompt assembly, `get`, and `execute`, so presentation and dispatch resolve the same frozen definitions. - `ctx.tools.knownNames(scope?: ScopeKey): string[]` The PRE-restriction end-capability name universe `restrict` validates against: a typo fails loud while a restricted-away tool stays a normal absence. Presentation providers add reserved transport names separately when validating `toolOrder`. - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)). - `ctx.tools.guard(guard: ToolGuard): () => Promise | void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber. -- `ctx.tools.execute(exec: ToolExecutionInput): Promise` Snapshot one single-use call input into a pipeline-owned execution, assign its opaque correlation token, require `arguments` to be losslessly JSON-serializable before and after cloning, deep-freeze the detached arguments, and protect its identity before running `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute`; optional `signal` is the only operational field an around-dispatch wrapper may add, replace, or remove. Validate the final result as losslessly JSON-serializable and freeze the complete execution before `tools/result` observers run. Invalid or unstable input—including cloneable mutable exotics—and malformed or non-JSON listener/tool results normalize to `isError` outcomes rather than bypassing policy or failing later at the session log. +- `ctx.tools.execute(exec: ToolExecutionInput): Promise` Read each caller-owned top-level field once, snapshot the single-use call into a pipeline-owned execution, assign its opaque correlation token, materialize `arguments` through one lossless-JSON traversal, deep-freeze them, and protect identity before running `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute`; optional `signal` is the only operational field an around-dispatch wrapper may add, replace, or remove. After the required `callId`/`name` correlation identity is captured, the same captured optional fields build the normalized error shell if a later accessor or validation fails, so policy, dispatch, routing, and `tools/result` cannot observe different caller values. Every top-level result field is likewise captured once and the complete result or post-decision is losslessly materialized before final observation. Invalid input—including cloneable mutable exotics—and malformed or non-JSON listener/tool results normalize to `isError` outcomes rather than bypassing policy or failing later at the session log. A throwing `callId` or `name` accessor rejects because no trustworthy result identity exists yet. ### Injected services @@ -77,7 +77,7 @@ ctx.tools.register(defineTool({ })) ``` -The helper converts the author-facing `SchemaSpec` (with `required: true` as a per-property boolean) to standard JSON Schema for the wire format. Raw JSON-Schema tool definitions (from MCP servers) are still accepted by the registry directly. +The helper converts the author-facing `SchemaSpec` (with `required: true` as a per-property boolean) to standard JSON Schema for the wire format. Definition is a snapshot boundary: `defineTool` reads every top-level option once, detaches the schema, and derives both an independent wire schema and every later execute/presentation validation from that accepted snapshot. Stateful accessors or later caller mutation therefore cannot make the schema shown to the model disagree with the schema enforced at runtime. Raw JSON-Schema tool definitions (from MCP servers) are still accepted by the registry directly. A `defineTool` tool also **validates the model-generated arguments against its `SchemaSpec` before `execute` runs** (`validateArgs`). The model's JSON is untrusted — `InferArgs` is a compile-time claim, not a runtime guarantee — so on a mismatch (missing required key, wrong primitive, bad enum member, nested violation) the tool throws a `ToolArgsError` (`code: 'INVALID_ARGS'`); the registry turns it into an `isError` result whose text lists the violations, which the model sees and self-corrects from. Validation mirrors the JSON Schema conversion exactly: extra keys are allowed, `default` is not applied, and an `object`/`array` prop without `properties`/`items` only type-checks. Raw-registered tools (MCP) are **not** validated by the harness — they validate their own input. diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 71dd465dd6..b466875c75 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -23,7 +23,7 @@ import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope' import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm' import type { Agent, HookContext } from '@deepseek-ai/dsh-agent' -import { isJsonValue } from '@deepseek-ai/dsh-session' +import { snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt' import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' // Type-only: makes `ctx.get('approval')` resolve to the ApprovalService @@ -275,10 +275,10 @@ export interface ToolExecutionInput { /** * One pending tool call inside the registry pipeline. Call identity, the - * registry-assigned {@link token}, and a lossless-JSON-validated, deep-frozen - * clone of the parsed arguments are immutable from the first policy listener onward, while an - * around-dispatch wrapper may set, replace, or remove only `signal`. The - * registry freezes the complete object before `tools/result` observers run. + * registry-assigned {@link token}, and a deep-frozen lossless-JSON snapshot of + * the parsed arguments are immutable from the first policy listener onward, + * while an around-dispatch wrapper may set, replace, or remove only `signal`. + * The registry freezes the complete object before `tools/result` observers run. */ export interface ToolExecution extends ToolExecutionInput { /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ @@ -590,12 +590,13 @@ export class ToolRegistry extends Service { * the shadowing feature, not an error; the global-duplicate message names * `agent.ctx` as the per-agent alternative), or if a non-native mode reserves * the `run_code` name for its presentation transport. The visible schema set - * flows into prompt assembly automatically. Registration validates and - * clones the JSON parameters, copies scalar fields, binds each callback once - * to the caller's definition as its method receiver, and freezes the stored - * snapshot; later mutation or callback replacement on the input object does - * not rewrite the registry. Disposed with the calling fiber. Emits - * `tools/change` on register/unregister. + * flows into prompt assembly automatically. Registration materializes the JSON + * parameters in one pass, copies scalar fields, binds each callback once to the + * caller's definition as its method receiver, and freezes the stored snapshot; + * later mutation or callback replacement on the input object does not rewrite + * the registry. Every top-level field is read once into one coherent acceptance + * snapshot, so stateful accessors cannot make validation and storage use + * different values. Emits `tools/change` on register/unregister. * @param definition - the tool's schema plus its execute (and optional * presentation) functions. * @returns the disposer that unregisters the tool. The exact @@ -604,31 +605,57 @@ export class ToolRegistry extends Service { */ register(definition: ToolDefinition): () => Promise | void { const scope = scopeOf(this.ctx) - // A schema crosses the same model/log boundary as execution arguments. - // Validate BEFORE cloning because structuredClone silently turns some - // forbidden values (for example class instances) into plain records, then - // validate the detached value again to contain hostile getters that change - // between inspection and snapshotting. A frozen Map is still mutable, so - // deepFreeze alone is not a sufficient registration boundary. - if (!isJsonValue(definition.parameters)) { - throw new TypeError('tool parameters must be losslessly JSON-serializable') + // One coherent acceptance snapshot: a caller may expose fields through + // accessors, so every top-level value is read exactly once before any + // validation or binding. Checked parameters and stored parameters must be + // the same reference, and a callback cannot change between lookup/bind. + const name = definition.name + const description = definition.description + const inputParameters = definition.parameters + const timeoutMs = definition.timeoutMs + // eslint-disable-next-line @typescript-eslint/unbound-method + const inputExecute = definition.execute + // eslint-disable-next-line @typescript-eslint/unbound-method + const inputPresentCall = definition.presentCall + // eslint-disable-next-line @typescript-eslint/unbound-method + const inputPresentResult = definition.presentResult + // Reject malformed fixed fields before any caller-owned value can enter the + // frozen snapshot. In particular, a boxed string/object must not become a + // Map key or get recursively frozen as though it were a scalar. + if (typeof name !== 'string') throw new TypeError('tool name must be a string') + if (typeof description !== 'string') throw new TypeError(`tool "${name}" description must be a string`) + if (timeoutMs !== undefined + && (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0)) { + throw new TypeError(`tool "${name}" timeoutMs must be a positive finite number`) } - const parameters = structuredClone(definition.parameters) - if (!isJsonValue(parameters)) { - throw new TypeError('tool parameters must be stable losslessly JSON-serializable data') + if (typeof inputExecute !== 'function') throw new TypeError(`tool "${name}" execute must be a function`) + if (inputPresentCall !== undefined && typeof inputPresentCall !== 'function') { + throw new TypeError(`tool "${name}" presentCall must be a function when provided`) + } + if (inputPresentResult !== undefined && typeof inputPresentResult !== 'function') { + throw new TypeError(`tool "${name}" presentResult must be a function when provided`) + } + const execute = inputExecute.bind(definition) + const presentCall = inputPresentCall?.bind(definition) + const presentResult = inputPresentResult?.bind(definition) + // A schema crosses the same model/log boundary as execution arguments. + // Validate and detach it in one traversal: validate-then-structuredClone + // would reread getters and could erase an exotic prototype returned only to + // the clone. A frozen Map is still mutable, so deepFreeze alone is not a + // sufficient registration boundary. + const parameters = snapshotJsonValue(inputParameters) + if (parameters === undefined) { + throw new TypeError('tool parameters must be losslessly JSON-serializable') } // Bind once so replacing a callback on the caller-owned definition after // registration cannot change dispatch, while preserving the historical // method receiver (`this === definition`) for callbacks that use it. - const execute = definition.execute.bind(definition) - const presentCall = definition.presentCall?.bind(definition) - const presentResult = definition.presentResult?.bind(definition) const snapshot: ToolDefinition = deepFreeze({ - name: definition.name, - description: definition.description, + name, + description, parameters, execute, - ...definition.timeoutMs !== undefined ? { timeoutMs: definition.timeoutMs } : {}, + ...timeoutMs !== undefined ? { timeoutMs } : {}, ...presentCall !== undefined ? { presentCall } : {}, ...presentResult !== undefined ? { presentResult } : {}, }) @@ -677,8 +704,9 @@ export class ToolRegistry extends Service { * global tools they mask exist (the agent-creation `setup` window satisfies * this). A non-native mode's reserved `run_code` presentation transport is * not a filterable capability; naming it explicitly throws, while omitting - * it from an allow-list cannot remove it. The filter is SNAPSHOT at - * registration: later caller mutation of the arrays changes nothing. + * it from an allow-list cannot remove it. `allow` and `deny` are each read + * once, then the filter is SNAPSHOT at registration: the values checked are + * the values enforced, and later caller mutation of the arrays changes nothing. * Multiple restrictions compose by intersection. Scoped registrations * bypass restrictions (explicit grants win). Disposed with the calling * fiber (revocable independently); emits `tools/change`. @@ -692,13 +720,19 @@ export class ToolRegistry extends Service { if (scope === undefined) { throw new Error('tools.restrict() requires a scoped context (agent.ctx): a context-global restriction would mask every agent — deny the tool for the intended agent instead') } - if (filter.allow === undefined && filter.deny === undefined) { + // Read each caller-owned accessor once. The same values must decide + // whether the filter is meaningful AND become the enforced snapshot: a + // stateful getter must not pass the no-op check as `allow: []` and then + // disappear when the snapshot is built. + const allow = filter.allow + const deny = filter.deny + if (allow === undefined && deny === undefined) { throw new Error('tools.restrict({}) is a no-op: pass `allow` and/or `deny` (an empty filter is almost always a materialized-empty-config bug)') } // Snapshot BEFORE validation so what was checked is what is enforced. const snapshot: ToolRestriction = { - ...filter.allow !== undefined ? { allow: [...filter.allow] } : {}, - ...filter.deny !== undefined ? { deny: [...filter.deny] } : {}, + ...allow !== undefined ? { allow: [...allow] } : {}, + ...deny !== undefined ? { deny: [...deny] } : {}, } if (this.codeTransport !== undefined && [...snapshot.allow ?? [], ...snapshot.deny ?? []].includes(RUN_CODE_NAME)) { @@ -909,35 +943,62 @@ export class ToolRegistry extends Service { * restricted-away global is exactly as absent as a nonexistent one), the * result is an `isError` carrying a `UNKNOWN_TOOL` structured error. A thrown * {@link HarnessError} surfaces its `{ name, code }` on the result. Before - * the final observe-only notification, the authoritative outcome must survive - * a lossless JSON round trip; an invalid outcome is normalized to an error. + * the final observe-only notification, the authoritative outcome is + * materialized as a detached lossless-JSON snapshot; an invalid outcome is + * normalized to an error. * A malformed runtime/casted `tools/pre-execute` decision likewise normalizes * to an error before approval, guards, or the tool body. - * Caller-owned arguments must survive lossless-JSON validation before and - * after cloning; a violation normalizes to an error before policy or dispatch. - * @param exec - the single-use call input; its identity is snapshotted and - * protected before policy runs. - * @returns the final result after every waterfall; failures resolve as - * `isError` results, never rejections. + * Caller-owned arguments are validated and detached in one recursive + * lossless-JSON traversal; a violation normalizes to an error before policy + * or dispatch. + * @param exec - the single-use call input; every top-level field is read once + * and that identity snapshot is protected before policy runs (and reused by + * the normalized error shell if validation fails). + * @returns the final result after every waterfall. Once the required + * `callId` and `name` correlation identity has been captured, later + * accessor, validation, listener, and tool failures resolve as `isError` + * results rather than rejections. A throwing `callId` or `name` accessor + * rejects because no trustworthy result identity exists yet. */ async execute(exec: ToolExecutionInput): Promise { + // callId/name are the minimum correlation identity needed to construct a + // result at all. Every other caller-controlled accessor is read once + // INSIDE the normalization boundary; if one throws, the error shell uses + // the fields captured before it and never rereads the hostile record. + const callId = exec.callId + const name = exec.name + let agent: Agent | undefined + let parent: ToolExecutionToken | undefined + let signal: AbortSignal | undefined let execution: ToolExecution try { - execution = this.prepareExecution(exec) + agent = exec.agent + parent = exec.parent + signal = exec.signal + const args = exec.arguments + const input: Readonly = Object.freeze({ + callId, + name, + arguments: args, + ...agent !== undefined ? { agent } : {}, + ...parent !== undefined ? { parent } : {}, + ...signal !== undefined ? { signal } : {}, + }) + execution = this.prepareExecution(input) } catch (error: unknown) { - // Contract-violating non-JSON or non-cloneable arguments cannot enter a - // pipeline whose logged and executed forms must agree. Still publish one - // scoped final outcome, using an immutable identity shell, so result - // observers retain their every-call guarantee without seeing the invalid - // value. + // Contract-violating arguments outside the lossless-JSON vocabulary cannot + // enter a pipeline whose logged and executed forms must agree. Still + // publish one scoped final outcome, using an immutable identity shell, so + // result observers retain their every-call guarantee without seeing the + // invalid value. execution = Object.freeze({ token: createExecutionToken(), - callId: exec.callId, - name: exec.name, + callId, + name, arguments: undefined, - ...exec.agent !== undefined ? { agent: exec.agent } : {}, - ...isExecutionToken(exec.parent) ? { parent: exec.parent } : {}, - ...exec.signal !== undefined ? { signal: exec.signal } : {}, + ...agent !== undefined ? { agent } : {}, + ...isExecutionToken(parent) ? { parent } : {}, + ...signal !== undefined ? { signal } : {}, }) const result = toolErrorResult(execution.callId, error) await this.notifyResult(execution, result) @@ -945,11 +1006,11 @@ export class ToolRegistry extends Service { } let result: ToolExecutionResult try { - // Validate the authoritative FINAL result, not merely the tool body's + // Materialize the authoritative FINAL result, not merely the tool body's // intermediate return. Post-policy may replace content or attach context, - // and every one of these fields is session-bound. Reject anything that - // cannot round-trip losslessly through the durable JSON log before the - // observe-only `tools/result` commit point sees success. + // and every one of these fields is session-bound. Reject anything outside + // the lossless-JSON vocabulary before the observe-only `tools/result` + // commit point sees success. result = this.snapshotExecutionResult(execution, await this.executePipeline(execution)) } catch (error: unknown) { // Outer backstop: a throwing pre/post-execute listener, guard, or the @@ -961,17 +1022,14 @@ export class ToolRegistry extends Service { } /** Snapshot one call into a shared pipeline object with immutable identity and mutable cancellation. */ - private prepareExecution(input: ToolExecutionInput): ToolExecution { + private prepareExecution(input: Readonly): ToolExecution { if (input.parent !== undefined && !isExecutionToken(input.parent)) { throw new TypeError('tool execution parent must be a registry-minted opaque token') } - if (!isJsonValue(input.arguments)) { + const args = snapshotJsonValue(input.arguments) + if (args === undefined) { throw new TypeError('tool execution arguments must be losslessly JSON-serializable') } - const args = structuredClone(input.arguments) - if (!isJsonValue(args)) { - throw new TypeError('tool execution arguments must be stable losslessly JSON-serializable data') - } const execution: ToolExecution = { token: createExecutionToken(), callId: input.callId, @@ -1100,10 +1158,13 @@ export class ToolRegistry extends Service { // The pipeline is over: freeze the remaining mutable signal slot so every // observer sees the SAME WeakMap-keyable execution without a mutation race. Object.freeze(exec) - // postExecute clones every accepted result/decision before rebuilding the - // outcome; all error paths construct plain data. The final result is thus - // structurally cloneable before it reaches this observe-only boundary. - const snapshot = deepFreeze(structuredClone(result)) + // Materialize once more at the observer boundary so every listener receives + // the same detached result even when an internal error path constructed it. + const detached = snapshotJsonValue(result) + if (detached === undefined) { + throw new TypeError('tool result notification must be losslessly JSON-serializable') + } + const snapshot = deepFreeze(detached) const callbacks = this.ctx.events.dispatch('parallel', [ scopeTarget(this, exec.agent), 'tools/result', exec, snapshot, ]) @@ -1169,13 +1230,16 @@ export class ToolRegistry extends Service { // authoritative-call-id requirement and the "preserve the dispatched // isError/error" contract. The decision is the ONLY sanctioned channel for a // listener to change the outcome (block, or accept-with-replacement); the - // call id is always the authoritative `exec.callId`. Deep cloning protects - // nested content, error, and meta data from in-place listener mutation. + // call id is always the authoritative `exec.callId`. The one-pass snapshot + // protects nested content, error, and meta from in-place listener mutation. const dispatched = this.snapshotExecutionResult(exec, result) - const decision = structuredClone(await this.ctx.waterfall( + const decision = snapshotJsonValue(await this.ctx.waterfall( scopeTarget(this, exec.agent), 'tools/post-execute', exec, result, () => Promise.resolve({ kind: 'accept' }), )) + if (decision === undefined) { + throw new TypeError('tools/post-execute must return a losslessly JSON-serializable decision') + } this.assertPostDecision(decision) const additionalContext = decision.additionalContext if (decision.kind === 'block') { @@ -1200,31 +1264,36 @@ export class ToolRegistry extends Service { throw new TypeError('tools/execute must return a ToolExecutionResult object') } const result = value as Partial - if (!Array.isArray(result.content) || typeof result.isError !== 'boolean') { + // Capture the provider/listener-owned result exactly once. The same values + // must pass shape/correlation checks and become the detached final outcome; + // a stateful accessor cannot validate one result and publish another. + const callId = result.callId + const content = result.content + const isError = result.isError + const error = result.error + const additionalContext = result.additionalContext + const meta = result.meta + if (!Array.isArray(content) || typeof isError !== 'boolean') { throw new TypeError('tools/execute must return a ToolExecutionResult with content[] and boolean isError') } - if (result.callId !== exec.callId) { - throw new TypeError(`tools/execute returned callId "${String(result.callId)}" for authoritative call "${exec.callId}"`) + if (callId !== exec.callId) { + throw new TypeError(`tools/execute returned callId "${String(callId)}" for authoritative call "${exec.callId}"`) } const candidate = { callId: exec.callId, - content: result.content, - isError: result.isError, - ...result.error !== undefined ? { error: result.error } : {}, - ...result.additionalContext !== undefined ? { additionalContext: result.additionalContext } : {}, - ...result.meta !== undefined ? { meta: result.meta } : {}, + content, + isError, + ...error !== undefined ? { error } : {}, + ...additionalContext !== undefined ? { additionalContext } : {}, + ...meta !== undefined ? { meta } : {}, } - // Validate BEFORE cloning: structuredClone turns some forbidden exotic or - // class instances into plain objects, which would hide a lossy JSON - // boundary violation. Validate the detached clone again to contain hostile - // getters whose value changes between inspection and snapshotting. - if (!isJsonValue(candidate)) { + // One traversal both validates and detaches the accepted result. A separate + // check followed by structuredClone would reread getters and could sanitize + // a class instance into an apparently valid plain record. + const snapshot = snapshotJsonValue(candidate) + if (snapshot === undefined) { throw new TypeError('tools/execute must return a losslessly JSON-serializable ToolExecutionResult') } - const snapshot = structuredClone(candidate) - if (!isJsonValue(snapshot)) { - throw new TypeError('tools/execute must return a stable losslessly JSON-serializable ToolExecutionResult') - } return snapshot } diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index 1a428ffd40..add9c29e61 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -20,6 +20,7 @@ */ import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm' +import { snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { ToolDefinition, ToolExecuteReturn, ToolExecution, ToolResult } from './index.ts' import type { ToolCallView, ToolResultView } from './presentation.ts' @@ -353,6 +354,12 @@ export interface DefineToolOptions { * Raw JSON-Schema tool definitions (from MCP servers) are still accepted * by `ToolRegistry.register()` directly — `defineTool` is sugar for * first-party plugin authors. + * + * Definition is an acceptance boundary: every top-level option is read once, + * and the parameter spec is detached before either the wire schema or the + * runtime validators are built. Later mutation of the caller's options or + * schema therefore cannot make the model-visible schema disagree with execute + * or presentation validation. * @param options - the tool's name, description, typed parameter schema, * execute body, and optional presenters. * @returns a registry-ready {@link ToolDefinition}: its `execute` validates the @@ -362,6 +369,13 @@ export interface DefineToolOptions { * args). */ export function defineTool(options: DefineToolOptions): ToolDefinition { + // Capture every caller-owned top-level field before inspecting any nested + // schema value. Accessors may be stateful, so validation, presentation, and + // the returned definition must all derive from this one accepted record. + const name = options.name + const description = options.description + const inputParameters = options.parameters + const timeoutMs = options.timeoutMs // Object-literal execute methods don't use `this`; the reference is safe. // eslint-disable-next-line @typescript-eslint/unbound-method const userExecute = options.execute @@ -369,20 +383,31 @@ export function defineTool(options: DefineToolOptions): const userPresentCall = options.presentCall // eslint-disable-next-line @typescript-eslint/unbound-method const userPresentResult = options.presentResult - if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) { - throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`) + if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) { + throw new Error(`defineTool(${name}): timeoutMs must be a positive finite number`) + } + // The internal SchemaSpec and public wire schema must not share mutable + // subobjects. Each is materialized through the lossless one-pass boundary; + // structuredClone alone could sanitize an exotic default or nested getter. + const parameterSpec = snapshotJsonValue(inputParameters) + if (parameterSpec === undefined) { + throw new Error(`defineTool(${name}): parameters must be losslessly JSON-serializable`) + } + const wireParameters = snapshotJsonValue(schemaSpecToJsonSchema(parameterSpec)) + if (wireParameters === undefined) { + throw new Error(`defineTool(${name}): generated parameters must be losslessly JSON-serializable`) } const tool: ToolDefinition = { - name: options.name, - description: options.description, - parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record, - ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + name, + description, + parameters: wireParameters as unknown as Record, + ...(timeoutMs !== undefined ? { timeoutMs } : {}), async execute(args: unknown, exec: ToolExecution): Promise { // Validate the model-generated args before the typed body runs. On // mismatch we throw ToolArgsError; the registry turns it into an // isError result so the model can self-correct. After this guard, the // cast to InferArgs reflects the validated shape. - const violations = validateArgs(options.parameters, args) + const violations = validateArgs(parameterSpec, args) if (violations.length > 0) throw new ToolArgsError(violations) return userExecute(args as InferArgs, exec) }, @@ -393,13 +418,13 @@ export function defineTool(options: DefineToolOptions): // than the hard `ToolArgsError` the execute path raises. if (userPresentCall) { tool.presentCall = (args: unknown): ToolCallView | undefined => { - if (validateArgs(options.parameters, args).length > 0) return undefined + if (validateArgs(parameterSpec, args).length > 0) return undefined return userPresentCall(args as InferArgs) } } if (userPresentResult) { tool.presentResult = (args: unknown, result: ToolResult): ToolResultView | undefined => { - if (validateArgs(options.parameters, args).length > 0) return undefined + if (validateArgs(parameterSpec, args).length > 0) return undefined return userPresentResult(args as InferArgs, result) } } diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index cac4a7e29b..ea4ef57ddd 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -4,7 +4,7 @@ import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools' +import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionInput, ToolExecutionToken, ToolRestriction } from '@deepseek-ai/dsh-tools' import type { Agent, AgentId } from '@deepseek-ai/dsh-agent' import { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' @@ -141,6 +141,25 @@ describe('restrict()', () => { expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['b']) }) + it('reads restriction accessors once so the checked filter is the enforced filter', async () => { + const ctx = await mount() + const { scope, key } = await mintAgentScope(ctx, 'a') + ctx.tools.register(tool('global')) + let allowReads = 0 + const filter = { + get allow(): string[] | undefined { + allowReads += 1 + return allowReads === 1 ? [] : undefined + }, + } as ToolRestriction + + scope.ctx.tools.restrict(filter) + + expect(allowReads).toBe(1) + expect(ctx.tools.schemas(key)).toEqual([]) + expect(await run(ctx, 'global', key)).toBe('Error: unknown tool "global"') + }) + it('fails loud on an unscoped call, an empty filter, and unknown names', async () => { const ctx = await mount() const { scope } = await mintAgentScope(ctx, 'a') @@ -372,6 +391,116 @@ describe('scoped execution dispatch', () => { expect(Object.isFrozen(forged)).toBe(false) }) + it('reads a stateful parent accessor once before policy, dispatch, and result observation', async () => { + const ctx = await mount() + const observed: (ToolExecutionToken | undefined)[] = [] + ctx.tools.register({ + ...tool('t'), + execute: (_args, exec) => { + observed.push(exec.parent) + return Promise.resolve([{ type: 'text', text: 'ran:t' }]) + }, + }) + ctx.on('tools/pre-execute', (exec, next) => { + observed.push(exec.parent) + return next() + }) + ctx.on('tools/execute', (exec, next) => { + observed.push(exec.parent) + return next() + }) + ctx.on('tools/result', (exec) => { observed.push(exec.parent) }) + const forged = { fake: true } as unknown as ToolExecutionToken + let parentReads = 0 + const input = { + callId: CallId('stateful-parent'), + name: 't', + arguments: {}, + get parent(): ToolExecutionToken | undefined { + parentReads += 1 + return parentReads === 1 ? undefined : forged + }, + } as ToolExecutionInput + + const result = await ctx.tools.execute(input) + + expect(result.isError).toBe(false) + expect(parentReads).toBe(1) + expect(observed).toEqual([undefined, undefined, undefined, undefined]) + }) + + it('uses one input snapshot for the normalized error shell', async () => { + const ctx = await mount() + const { scope, key } = await mintAgentScope(ctx, 'accepted') + const driftAgent = { id: 'drift' as AgentId } as Agent + ctx.tools.register(tool('parent')) + ctx.tools.register(tool('t')) + let parent!: ToolExecutionToken + const stopCapture = ctx.on('tools/pre-execute', (exec, next) => { + if (exec.name === 'parent') parent = exec.token + return next() + }) + await ctx.tools.execute({ callId: CallId('parent'), name: 'parent', arguments: {} }) + stopCapture() + const acceptedSignal = new AbortController().signal + const driftSignal = new AbortController().signal + const forged = { fake: true } as unknown as ToolExecutionToken + const reads = { callId: 0, name: 0, arguments: 0, agent: 0, parent: 0, signal: 0 } + const input = { + get callId() { reads.callId += 1; return CallId('unstable-error') }, + get name() { reads.name += 1; return 't' }, + get arguments(): unknown { reads.arguments += 1; return { invalid: () => undefined } }, + get agent() { reads.agent += 1; return reads.agent === 1 ? key : driftAgent }, + get parent() { reads.parent += 1; return reads.parent <= 2 ? parent : forged }, + get signal() { reads.signal += 1; return reads.signal === 1 ? acceptedSignal : driftSignal }, + } as ToolExecutionInput + let observed: Readonly | undefined + let scopedObserved = 0 + ctx.on('tools/result', (exec) => { observed = exec }) + scope.ctx.on('tools/result', () => { scopedObserved += 1 }) + + const result = await ctx.tools.execute(input) + + expect(result.isError).toBe(true) + expect(reads).toEqual({ callId: 1, name: 1, arguments: 1, agent: 1, parent: 1, signal: 1 }) + expect(scopedObserved).toBe(1) + expect(observed).toMatchObject({ + callId: CallId('unstable-error'), + name: 't', + agent: key, + parent, + signal: acceptedSignal, + }) + expect(Object.isFrozen(observed)).toBe(true) + }) + + it('normalizes a throwing arguments accessor without rereading it or losing the final notification', async () => { + const ctx = await mount() + ctx.tools.register(tool('t')) + let argumentReads = 0 + let observed = 0 + ctx.on('tools/result', (exec, result) => { + observed += 1 + expect(exec.arguments).toBeUndefined() + expect(result.isError).toBe(true) + }) + const input = { + callId: CallId('throwing-arguments'), + name: 't', + get arguments(): unknown { + argumentReads += 1 + throw new Error('getter exploded') + }, + } as ToolExecutionInput + + const result = await ctx.tools.execute(input) + + expect(result.isError).toBe(true) + expect(result.content).toEqual([{ type: 'text', text: 'Error: getter exploded' }]) + expect(argumentReads).toBe(1) + expect(observed).toBe(1) + }) + it.each([ ['Map', new Map([['mutable', true]])], ['class instance', new (class Arguments { value = 1 })()], @@ -408,7 +537,7 @@ describe('scoped execution dispatch', () => { expect({ policyCalls, bodyCalls, observed }).toEqual({ policyCalls: 0, bodyCalls: 0, observed: 1 }) }) - it('rejects arguments that change to non-JSON data while being snapshotted', async () => { + it('reads nested arguments once into the executed snapshot', async () => { const ctx = await mount() ctx.tools.register(tool('t')) let reads = 0 @@ -421,12 +550,11 @@ describe('scoped execution dispatch', () => { callId: CallId('unstable-arguments'), name: 't', arguments: argumentsValue, }) + expect(reads).toBe(1) expect(result).toEqual({ callId: CallId('unstable-arguments'), - content: [{ - type: 'text', text: 'Error: tool execution arguments must be stable losslessly JSON-serializable data', - }], - isError: true, + content: [{ type: 'text', text: 'ran:t' }], + isError: false, }) }) diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index deb53b7dc7..5bff94c960 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -6,8 +6,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval' import ToolRegistry, { defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, - type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision, - type ToolExecution, type ToolExecutionResult, type ToolGuard, + type DefineToolOptions, type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision, + type ToolDefinition, type ToolExecution, type ToolExecutionResult, type ToolGuard, } from '@deepseek-ai/dsh-tools' async function setup() { @@ -135,7 +135,7 @@ describe('ToolRegistry', () => { expect(observedError).toBe(true) }) - it('normalizes a result that changes to non-JSON data while being snapshotted', async () => { + it('reads each result value once so later getter drift cannot change the snapshot', async () => { const ctx = await setup() ctx.tools.register(echoTool) let reads = 0 @@ -153,15 +153,59 @@ describe('ToolRegistry', () => { callId: CallId('unstable-result'), name: 'echo', arguments: {}, }) + expect(reads).toBe(1) expect(result).toEqual({ callId: CallId('unstable-result'), - content: [{ - type: 'text', text: 'Error: tools/execute must return a stable losslessly JSON-serializable ToolExecutionResult', - }], - isError: true, + content: [{ type: 'text', text: 'safe' }], + isError: false, }) }) + it('reads every top-level execution result field once before validation', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + const reads = { callId: 0, content: 0, isError: 0, error: 0, additionalContext: 0, meta: 0 } + ctx.on('tools/execute', async exec => Object.defineProperties({}, { + callId: { enumerable: true, get: () => { reads.callId += 1; return reads.callId === 1 ? exec.callId : CallId('drifted') } }, + content: { enumerable: true, get: () => { reads.content += 1; return reads.content === 1 ? [{ type: 'text', text: 'accepted' }] : [] } }, + isError: { enumerable: true, get: () => { reads.isError += 1; return reads.isError !== 1 } }, + error: { enumerable: true, get: () => { reads.error += 1; return undefined } }, + additionalContext: { enumerable: true, get: () => { reads.additionalContext += 1; return undefined } }, + meta: { enumerable: true, get: () => { reads.meta += 1; return undefined } }, + }) as ToolExecutionResult) + + const result = await ctx.tools.execute({ + callId: CallId('one-read-result'), name: 'echo', arguments: {}, + }) + + expect(reads).toEqual({ callId: 1, content: 1, isError: 1, error: 1, additionalContext: 1, meta: 1 }) + expect(result).toEqual({ + callId: CallId('one-read-result'), + content: [{ type: 'text', text: 'accepted' }], + isError: false, + }) + }) + + it('rejects an exotic nested result before its prototype can be sanitized', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + class ExoticText { readonly value = 'not text' } + ctx.on('tools/execute', exec => Promise.resolve({ + callId: exec.callId, + content: [{ type: 'text', text: new ExoticText() }], + isError: false, + } as unknown as ToolExecutionResult)) + + const result = await ctx.tools.execute({ + callId: CallId('exotic-result'), name: 'echo', arguments: {}, + }) + + expect(result.isError).toBe(true) + expect(result.content).toEqual([{ + type: 'text', text: 'Error: tools/execute must return a losslessly JSON-serializable ToolExecutionResult', + }]) + }) + it('returns isError results for unknown tools and throwing tools', async () => { const ctx = await setup() ctx.tools.register({ @@ -729,6 +773,29 @@ describe('ToolRegistry', () => { expect(observedError).toBe(true) }) + it('rejects non-JSON data at the defensive final-result notification boundary', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + let execution: ToolExecution | undefined + ctx.on('tools/execute', async (exec, next) => { + execution = exec + return next() + }) + await ctx.tools.execute({ callId: CallId('capture-execution'), name: 'echo', arguments: {} }) + if (execution === undefined) throw new Error('test fixture did not capture the execution') + + const internal = ctx.tools as unknown as { + notifyResult(exec: ToolExecution, result: ToolExecutionResult): Promise + } + const invalid = { + callId: CallId('capture-execution'), + content: new Map() as unknown as ToolExecutionResult['content'], + isError: false, + } + await expect(internal.notifyResult(execution, invalid)) + .rejects.toThrow('tool result notification must be losslessly JSON-serializable') + }) + it.each([ { name: 'non-object result', @@ -780,6 +847,11 @@ describe('ToolRegistry', () => { replacement: { kind: 'defer' }, message: 'tools/post-execute must return an accept or block decision', }, + { + name: 'non-JSON decision', + replacement: { kind: 'accept', content: new Map() }, + message: 'tools/post-execute must return a losslessly JSON-serializable decision', + }, ])('normalizes a tools/post-execute $name', async ({ replacement, message }) => { const ctx = await setup() ctx.tools.register(echoTool) @@ -886,7 +958,7 @@ describe('ToolRegistry', () => { expect(ctx.tools.get('invalid-parameters')).toBeUndefined() }) - it('rejects tool parameters that change to non-JSON data while being snapshotted', async () => { + it('reads nested tool parameters once into the accepted snapshot', async () => { const ctx = await setup() let reads = 0 const parameters = Object.defineProperty({}, 'properties', { @@ -898,8 +970,58 @@ describe('ToolRegistry', () => { ...echoTool, name: 'unstable-parameters', parameters, - })).toThrow('tool parameters must be stable losslessly JSON-serializable data') - expect(ctx.tools.get('unstable-parameters')).toBeUndefined() + })).not.toThrow() + expect(reads).toBe(1) + expect(ctx.tools.get('unstable-parameters')?.parameters).toEqual({ properties: {} }) + }) + + it('reads a top-level parameters accessor once so validation and storage use one value', async () => { + const ctx = await setup() + const accepted = { type: 'object', properties: { accepted: { type: 'string' } } } + class DriftedParameters { + readonly type = 'object' + readonly properties = { drifted: { type: 'number' } } + } + let reads = 0 + const definition = { ...echoTool, name: 'top-level-parameters' } + Object.defineProperty(definition, 'parameters', { + enumerable: true, + get: () => { + reads += 1 + return reads === 1 ? accepted : new DriftedParameters() + }, + }) + + ctx.tools.register(definition) + + expect(reads).toBe(1) + expect(ctx.tools.get('top-level-parameters')?.parameters).toEqual(accepted) + }) + + it('rejects malformed fixed definition fields without freezing caller objects', async () => { + const ctx = await setup() + const badName = { value: 'object-name' } + const badDescription = { value: 'object-description' } + const badTimeout = { value: 100 } + + expect(() => ctx.tools.register({ ...echoTool, name: badName as unknown as string })) + .toThrow('tool name must be a string') + expect(() => ctx.tools.register({ ...echoTool, name: 'bad-description', description: badDescription as unknown as string })) + .toThrow('description must be a string') + expect(() => ctx.tools.register({ ...echoTool, name: 'bad-timeout', timeoutMs: badTimeout as unknown as number })) + .toThrow('timeoutMs must be a positive finite number') + expect(() => ctx.tools.register({ ...echoTool, name: 'zero-timeout', timeoutMs: 0 })) + .toThrow('timeoutMs must be a positive finite number') + expect(() => ctx.tools.register({ ...echoTool, name: 'bad-execute', execute: { bind() {} } as unknown as typeof echoTool.execute })) + .toThrow('execute must be a function') + expect(() => ctx.tools.register({ ...echoTool, name: 'bad-present-call', presentCall: 1 as unknown as NonNullable })) + .toThrow('presentCall must be a function') + expect(() => ctx.tools.register({ ...echoTool, name: 'bad-present-result', presentResult: 1 as unknown as NonNullable })) + .toThrow('presentResult must be a function') + expect(Object.isFrozen(badName)).toBe(false) + expect(Object.isFrozen(badDescription)).toBe(false) + expect(Object.isFrozen(badTimeout)).toBe(false) + expect(ctx.tools.schemas()).toEqual([]) }) it('snapshots callbacks while preserving their registration-time method receiver', async () => { @@ -1094,6 +1216,101 @@ describe('defineTool / schema DSL', () => { expect(result.content).toEqual([{ type: 'text', text: 'HELLO' }]) }) + it('reads defineTool options once and keeps wire and runtime schemas on one detached snapshot', async () => { + const accepted: SchemaSpec = { value: { type: 'string', required: true, enum: ['accepted'] } } + const drifted: SchemaSpec = { count: { type: 'number', required: true } } + const reads = { + name: 0, + description: 0, + parameters: 0, + timeoutMs: 0, + execute: 0, + presentCall: 0, + presentResult: 0, + } + const options = {} as DefineToolOptions + Object.defineProperties(options, { + name: { enumerable: true, get: () => { reads.name += 1; return reads.name === 1 ? 'accepted' : 'drifted' } }, + description: { enumerable: true, get: () => { reads.description += 1; return reads.description === 1 ? 'accepted description' : 'drifted description' } }, + parameters: { enumerable: true, get: () => { reads.parameters += 1; return reads.parameters === 1 ? accepted : drifted } }, + timeoutMs: { enumerable: true, get: () => { reads.timeoutMs += 1; return reads.timeoutMs === 1 ? 250 : 0 } }, + execute: { + enumerable: true, + get: () => { + reads.execute += 1 + return (args: Record) => Promise.resolve([{ type: 'text' as const, text: String(args['value']) }]) + }, + }, + presentCall: { + enumerable: true, + get: () => { + reads.presentCall += 1 + return (args: Record) => ({ card: 'generic' as const, title: String(args['value']) }) + }, + }, + presentResult: { + enumerable: true, + get: () => { + reads.presentResult += 1 + return (args: Record) => ({ card: 'generic' as const, title: String(args['value']) }) + }, + }, + }) + + const tool = defineTool(options) + accepted.value!.type = 'number' + accepted.value!.enum!.push('mutated') + + expect(tool).toMatchObject({ + name: 'accepted', + description: 'accepted description', + timeoutMs: 250, + parameters: { + type: 'object', + properties: { value: { type: 'string', enum: ['accepted'] } }, + required: ['value'], + }, + }) + await expect(tool.execute({ value: 'accepted' }, {} as ToolExecution)) + .resolves.toEqual([{ type: 'text', text: 'accepted' }]) + expect(tool.presentCall?.({ value: 'accepted' })).toEqual({ card: 'generic', title: 'accepted' }) + expect(tool.presentResult?.( + { value: 'accepted' }, + { content: [], isError: false }, + )).toEqual({ card: 'generic', title: 'accepted' }) + expect(reads).toEqual({ + name: 1, + description: 1, + parameters: 1, + timeoutMs: 1, + execute: 1, + presentCall: 1, + presentResult: 1, + }) + }) + + it('rejects an exotic defineTool schema before it can be normalized for the wire', () => { + class ExoticDefault { readonly value = 'not JSON' } + + expect(() => defineTool({ + name: 'exotic-schema', + description: 'must reject exotic defaults', + parameters: { + value: { type: 'string', default: new ExoticDefault() }, + }, + execute: () => Promise.resolve([]), + })).toThrow(/parameters must be losslessly JSON-serializable/) + }) + + it('rejects a malformed defineTool spec whose generated wire schema is not JSON', () => { + expect(() => defineTool({ + name: 'malformed-schema', + description: 'missing property type', + parameters: { value: {} } as unknown as SchemaSpec, + execute: () => Promise.resolve([]), + })).toThrow(/generated parameters must be losslessly JSON-serializable/) + }) + it('type-level: InferArgs maps required properties to non-optional', () => { // Compile-time check: if this compiles, InferArgs is correct. // args.a is string (required), args.b is number|undefined (optional). diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 9a76381614..990ad2fcc4 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -29,4 +29,4 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence ## Write path -The plugin generalizes the example `session-jsonl.ts`: it subscribes to `session/created` (capture the header; persist a fork's seed once), `session/event` (snapshot each event when buffering — the live `session.events` object is mutable), and `session/flush`/dispose (drain the write-behind buffer through `append`). A per-session write cursor means a resumed session never re-appends already-stored events. Existing live sessions are seeded on plugin apply (HMR does not replay `session/created`). All backend operations for one session are serialized, and disposal awaits quiescence (every init + final drain) before returning, so no write lands after teardown. +The plugin generalizes the example `session-jsonl.ts`: it subscribes to `session/created` (capture the header; persist a fork's seed once), `session/event` (copy each already-frozen event into the persistence-owned write-behind buffer), and `session/flush`/dispose (drain that buffer through `append`). A per-session write cursor means a resumed session never re-appends already-stored events. Existing live sessions are seeded on plugin apply (HMR does not replay `session/created`). All backend operations for one session are serialized, and disposal awaits quiescence (every init + final drain) before returning, so no write lands after teardown. diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index c14d3a70ea..082b60af88 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -27,4 +27,4 @@ interface Config { ## Write path -Like the JSONL backend, the plugin also installs the `session/event` → buffer → `session/flush` drain: it snapshots each event when buffered (the live `session.events` object is mutable), persists a fork's seed once on `session/created`, keeps a per-session write cursor so a resumed session never re-appends stored events, and seeds existing live sessions on apply (HMR does not replay `session/created`). Dispose awaits every in-flight init + final drain and then closes the database, so no write lands after teardown. +Like the JSONL backend, the plugin also installs the `session/event` → buffer → `session/flush` drain: it copies each already-frozen event into a persistence-owned buffer, persists a fork's seed once on `session/created`, keeps a per-session write cursor so a resumed session never re-appends stored events, and seeds existing live sessions on apply (HMR does not replay `session/created`). Dispose awaits every in-flight init + final drain and then closes the database, so no write lands after teardown. diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 8bd3fed568..bf7da03757 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -17,7 +17,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l - **Append-only; a crashed turn is closed, not truncated.** Committed events (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 durably appends synthetic closers (an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}`) to balance the log and keep the rehydrated history a valid provider transcript. Only a never-fully-written torn tail fragment is discarded. - **Contiguous seq.** `load` rejects a `seq` gap/parse error in the MIDDLE of the log; `append`'s first `seq` must equal the stored next-seq. -- **JSON-serializable data.** `append` rejects non-serializable `event.data`; backends snapshot each event when buffering (the live `session.events` object is mutable). +- **JSON-serializable data.** `append` materializes each direct/replay batch through the shared one-pass lossless-JSON boundary. Live `Session` events are already deep-frozen, but the write coordinator still copies each event into a persistence-owned buffer. - **Durability.** `append` returns only once the batch is durable. ## The write coordinator diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index 0180999842..b1fc118a21 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -25,9 +25,9 @@ */ import { Context } from 'cordis' -import { interruptedTurnClosers, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' +import { interruptedTurnClosers, SESSION_FORMAT_VERSION, snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' -import { assertSerializable, seedCoversPrefix } from './index.ts' +import { seedCoversPrefix } from './index.ts' /** * A stored session's durable prefix as read back from a backend: its @@ -186,14 +186,18 @@ export class PersistenceCoordinator { /** * Register a new session's metadata (lazy: no physical write until the first * {@link append}). Rejects if the id is already tracked or already persisted. - * @param meta - the immutable header (id, version, cwd, lineage) to record; snapshotted at call time. + * @param meta - the header (id, version, cwd, lineage) to record; materialized + * as a detached lossless-JSON snapshot at call time. */ create(meta: SessionHeader): Promise { // Snapshot the metadata at call time: the op runs later (behind the // per-session chain) and the snapshot is stored as the lazy state, so keeping // the caller's object by reference would let a later mutation of `id`/`cwd` // register under one key but materialize under a different path/header. - const snapshot: SessionHeader = { ...meta } + const snapshot = snapshotJsonValue(meta) + if (snapshot === undefined) { + return Promise.reject(new TypeError('session metadata must be losslessly JSON-serializable')) + } return this.serialize(snapshot.id, () => this.createCore(snapshot)) } @@ -212,23 +216,25 @@ export class PersistenceCoordinator { this.states.set(meta.id, { meta, cursor: 0, materialized: false }) } - // `async` so the synchronous validate/clone below reject (not throw) per the - // Promise contract — callers use `await expect(...).rejects`. + // `async` so synchronous materialization failures below reject (not throw) per + // the Promise contract — callers use `await expect(...).rejects`. /** * Durably persist a batch of events. Honors the append-only and contiguous-seq * contracts; rejects non-JSON-serializable `event.data`. * @param id - the session the batch belongs to. - * @param events - the contiguous batch to persist, in seq order; deep-cloned at call time. + * @param events - the contiguous batch to persist, in seq order; materialized + * as a detached lossless-JSON snapshot at call time. */ async append(id: SessionId, events: readonly SessionEvent[]): Promise { - // Validate serializability BEFORE cloning so a bad event surfaces the typed - // error rather than an opaque DataCloneError from structuredClone. - assertSerializable(events) - // Deep-snapshot the batch HERE, before the op waits behind the per-session - // chain: a caller that mutates a live array (e.g. session.events) — or an - // event inside it — before the op runs would otherwise have those changes - // persisted. The clone is taken synchronously (at call time). - const batch = events.map(e => structuredClone(e)) + // Validate and deep-snapshot the complete batch HERE, in one traversal, + // before the op waits behind the per-session chain. A check followed by + // structuredClone would reread accessors and could sanitize an exotic value + // into an apparently valid record; the single-pass materializer makes the + // checked value exactly the value persisted. + const batch = snapshotJsonValue(events) + if (batch === undefined) { + throw new TypeError('session event batch is not losslessly JSON-serializable because it contains non-JSON-serializable data') + } return this.serialize(id, () => this.appendCore(id, batch)) } @@ -338,9 +344,10 @@ export class PersistenceCoordinator { // promise so flush/dispose can await it (onCreated is async). ctx.on('session/created', (session) => { void this.initFor(session) }) - // Snapshot + buffer every event (the live object is mutable; clone so a later - // in-place mutation cannot rewrite a buffered event). Serializability is - // guaranteed at the source (Session.append), so structuredClone is safe. + // Session emits an owned frozen event. Keep a persistence-owned copy anyway + // so the write-behind queue owns exactly the record it will flush rather than + // retaining a product-layer record by identity. Serializability is guaranteed + // at the source, so structuredClone is safe. ctx.on('session/event', (session, event) => { let buffer = this.buffers.get(session) if (!buffer) this.buffers.set(session, buffer = []) @@ -391,8 +398,8 @@ export class PersistenceCoordinator { const existing = this.inits.get(session) if (existing) return existing // Snapshot the seed SYNCHRONOUSLY — initFor runs inside the `session/created` - // emit, before any later `append` adds non-seed events. A clone freezes it - // against later mutation of the live event objects. + // emit, before any later append invalidates the public array snapshot. Events + // are already frozen; cloning gives persistence independent ownership. const seed = session.events.map(e => structuredClone(e)) const p = this.onCreated(session, seed) // Attach a no-op rejection handler so a failing init does not surface as an diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 1588ed2526..b03691d17b 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -22,7 +22,7 @@ */ import { Context, Service } from 'cordis' -import { isJsonValue } from '@deepseek-ai/dsh-session' +import { snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' // Re-export the metadata vocabulary so consumers import it from the seam. @@ -58,16 +58,16 @@ export function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly } /** - * Reject non-JSON-serializable event data before a backend serializes a batch. - * Live session appends already enforce this; persistence append paths also - * accept replay/fork batches that may bypass a live session instance. - * @param events - the batch to validate; throws naming the offending event's type and seq. + * Reject a batch that is not wholly losslessly JSON-serializable. Live session + * appends already enforce this; persistence append paths also accept replay or + * direct batches that may bypass a live session instance. Validation uses the + * same one-pass materializer as the coordinator, so getters are read once. + * @param events - the complete event batch to validate. */ export function assertSerializable(events: readonly SessionEvent[]): void { - for (const event of events) { - if (!isJsonValue(event.data)) { - throw new Error(`event "${event.type}" carries non-JSON-serializable data (seq ${event.seq})`) - } + const snapshot = snapshotJsonValue(events) + if (snapshot === undefined) { + throw new Error('session event batch is not losslessly JSON-serializable because it contains non-JSON-serializable data') } } @@ -90,11 +90,11 @@ export function assertSerializable(events: readonly SessionEvent[]): void { * {@link load} rejects a parse error or a `seq` gap in the COMMITTED region * (unloadable); {@link append}'s first event `seq` MUST equal the backend's * stored next-seq (after `load` has balanced any interrupted turn). - * - **JSON-serializable data.** `SessionEventMap` is merge-extensible and - * `event.data` is typed only as `SessionEventMap[K]`, so {@link append} - * REJECTS non-JSON-serializable data with an error naming the offending - * event type. A backend snapshots (serializes/clones) each event when it - * buffers, since `session.events` hands out the live mutable object. + * - **JSON-serializable events.** `SessionEventMap` is merge-extensible, so + * {@link 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.** {@link append} returns only once the batch is durable * (the file backend fsyncs; a DB commits). {@link create} MAY defer the * physical write until the first {@link append} (lazy materialization). diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index 9f2facb827..789aa72c91 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -245,7 +245,7 @@ export function runPersistenceContract(name: string, make: () => Promise Promise< } }) - it('snapshot-on-buffer: mutating an event after session/event does not corrupt the persisted copy', async () => { + it('source-frozen events cannot be mutated after buffering and persist unchanged', async () => { const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) try { const session = ctx.sessions.create(SessionId('mutate'), { meta: { cwd: WORK } }) const ev = session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - // Mutate the live event object AFTER it was buffered by session/event. - ;(ev.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED' + expect(() => { + ;(ev.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED' + }).toThrow(TypeError) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.parallel('session/flush', session) diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 4e4cf67822..9c766d4b66 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -158,6 +158,17 @@ describe('SessionPersistence service registration', () => { expect(loaded.events).toHaveLength(6) await fiber.dispose() }) + + it('rejects non-JSON session metadata before registering lazy state', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(MemoryPersistence) + const invalid = { ...meta('invalid-meta'), createdAt: 1n as unknown as number } + + await expect(ctx.sessionPersistence.create(invalid)) + .rejects.toThrow('session metadata must be losslessly JSON-serializable') + await fiber.dispose() + }) }) describe('shared persistence helpers', () => { @@ -187,10 +198,10 @@ describe('shared persistence helpers', () => { expect(() => { assertSerializable(oneTurnLog()) }).not.toThrow() }) - it('rejects non-JSON-serializable event data with type and seq context', () => { + it('rejects a batch containing non-JSON-serializable event data', () => { const bad = [ { type: 'user/message', seq: 0, time: 1, data: { content: 1n } }, ] as unknown as SessionEvent[] - expect(() => { assertSerializable(bad) }).toThrow(/"user\/message".*seq 0/) + expect(() => { assertSerializable(bad) }).toThrow(/batch is not losslessly JSON-serializable/) }) }) diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index 33d16e1b69..b035acf767 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -9,9 +9,9 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ### Public API - `ctx.skills.registerProvider(provider): () => Promise | void` Registers a provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registry snapshots the name and callback identities at registration, so replacing those fields later cannot change lookup or HMR cleanup; callbacks remain bound to the original provider object and can still read its mutable state. The registration is effect-scoped and HMR-safe, and the exact Cordis disposer supports ordered composite teardown. -- `ctx.skills.list({ cwd?, signal? })` Returns model-invocable skill summaries for the current workspace, merged across providers and sorted by name. -- `ctx.skills.get(name, { cwd?, signal? })` Returns the full winning skill, including disabled-for-model skills. -- `ctx.skills.register(skill): () => Promise | void` Registers a runtime embedded skill. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown. +- `ctx.skills.list({ cwd?, signal? })` Snapshots the lookup options, then returns detached model-invocable summaries for the current workspace, merged across providers and sorted by name. +- `ctx.skills.get(name, { cwd?, signal? })` Uses one lookup-options snapshot to select and load the winner, rechecks cancellation after discovery or a cache hit, races provider loading against the same signal, then returns a detached full definition, including disabled-for-model skills. +- `ctx.skills.register(skill): () => Promise | void` Registers a detached runtime embedded skill. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown. ### Config @@ -21,13 +21,15 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ## Provider Contract -A provider registers synchronously from its `apply()` and returns `SkillCandidate[]` from `list(options)` when discovery is requested. Registration copies `name` and binds the current `list` and `get` methods once; replacing those fields on the caller-owned object later does not rewrite the live registry entry, and disposal always removes the original name. Remote setup, authentication, and discovery belong in the awaited `list()` call rather than plugin registration. Providers should stop promptly when `options.signal` aborts; the registry also stops awaiting an uncooperative provider so agent cancellation cannot hang prefix composition. The provider later receives the winning candidate back in `get(candidate, options)`. The candidate's `locator` is opaque to the registry, so a local provider can store a file path while a remote provider can store a URL, id, or version token. +A provider registers synchronously from its `apply()` and returns `SkillCandidate[]` from `list(options)` when discovery is requested. Registration copies `name` and binds the current `list` and `get` methods once; replacing those fields on the caller-owned object later does not rewrite the live registry entry, and disposal always removes the original name. Remote setup, authentication, and discovery belong in the awaited `list()` call rather than plugin registration. Providers should stop promptly when `options.signal` aborts; the registry also stops awaiting uncooperative discovery and loading work so agent cancellation cannot hang prefix composition or skill loading. -The registry validates candidate names, descriptions, ranks, and provider ownership. Candidate contract violations fail fast because the provider plugin is malformed; a provider `list()` rejection is treated as a transient source failure, logged, skipped for that request, and not cached. Only completed catalogs are cached, and a provider/runtime revision change during discovery discards the stale result and retries. Duplicate skill names are resolved first-wins by `rank`, provider registration order, then the provider's own local order. The final summary list is sorted by skill `name` for deterministic consumers. +Each public lookup captures `cwd` and the abort-signal identity once before cache or provider work, and providers receive that frozen lookup record. The registry reads each returned candidate once, validates that snapshot, and detaches its resource metadata before caching it. The winning provider receives another detached candidate in `get(candidate, options)`, while `candidate.locator` preserves the exact provider-owned identity originally returned by `list()`; a local provider can therefore use a file-path handle while a remote provider can use a URL, id, or version token. A loaded definition is detached again before it reaches the caller. + +The registry validates fixed provider, candidate, runtime-registration, and loaded-definition fields before detachment: names/descriptions/content use their declared string types, ranks are finite numbers, and `disableModelInvocation` is boolean when present. Caller-owned objects masquerading as scalars are rejected without being frozen. Candidate contract violations fail fast because the provider plugin is malformed; a provider `list()` rejection is treated as a transient source failure, logged, skipped for that request, and not cached. Only completed, registry-owned catalogs are cached, and a provider/runtime revision change during discovery discards the stale result and retries. Duplicate skill names are resolved first-wins by `rank`, provider registration order, then the provider's own local order. The final summary list is sorted by skill `name` for deterministic consumers. ## Runtime Skills -`ctx.skills.register(...)` is a convenience for embedded runtime skills. Runtime skills use rank `250`: project providers can override them, while they override the shipped local provider's custom and user roots. Runtime registration is also first-wins within runtime contributions, so a duplicate contribution cannot remove the active one through its disposer. +`ctx.skills.register(...)` is a convenience for embedded runtime skills. Runtime skills use rank `250`: project providers can override them, while they override the shipped local provider's custom and user roots. Runtime registration detaches the accepted definition and nested resource metadata; later mutation of the registration object or a returned list/get value cannot rewrite the live skill. Registration is also first-wins within runtime contributions, so a duplicate contribution cannot remove the active one through its disposer. ## Consumer boundary diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index 20ac717e9c..f06788cde7 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -81,9 +81,10 @@ export type SkillRegistration = Omit & { provider?: /** Caller context used for cwd-sensitive and abortable provider work. */ export interface SkillLookupOptions { - cwd?: string | undefined + /** Workspace selector captured at lookup entry; providers receive a read-only snapshot. */ + readonly cwd?: string | undefined /** Abort discovery or loading work for the current caller. */ - signal?: AbortSignal | undefined + readonly signal?: AbortSignal | undefined } /** Provider interface for one source of skills, such as local directories or a remote registry. */ @@ -101,7 +102,8 @@ export interface SkillProvider { list(options: SkillLookupOptions): Promise /** * Load a complete skill body for a previously listed candidate. - * @param candidate - the winning candidate originally returned by this provider. + * @param candidate - a detached snapshot of the winning candidate; its opaque + * `locator` retains the exact identity originally returned by this provider. * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. * @returns the full skill body, or `undefined` if it is no longer loadable. */ @@ -194,10 +196,18 @@ export class SkillService extends Service { // replacement of `provider.list`/`provider.get` after registration inert. // In particular, cleanup must never re-read caller-owned `provider.name`: // an HMR host may mutate or reuse that object before its old fiber unloads. + const name = provider.name + // eslint-disable-next-line @typescript-eslint/unbound-method + const inputList = provider.list + // eslint-disable-next-line @typescript-eslint/unbound-method + const inputGet = provider.get + if (typeof name !== 'string') throw new TypeError('skill provider name must be a string') + if (typeof inputList !== 'function') throw new TypeError(`skill provider "${name}" list must be a function`) + if (typeof inputGet !== 'function') throw new TypeError(`skill provider "${name}" get must be a function`) const snapshot: SkillProvider = Object.freeze({ - name: provider.name, - list: provider.list.bind(provider), - get: provider.get.bind(provider), + name, + list: inputList.bind(provider), + get: inputGet.bind(provider), }) const dispose = this.ctx.effect(function* (this: SkillService) { if (snapshot.name === RUNTIME_PROVIDER) { @@ -223,7 +233,9 @@ export class SkillService extends Service { * Register a runtime skill contribution. Runtime registrations are treated as * embedded provider entries with project-over-user priority. Same-name runtime * registrations are first-wins: a duplicate logs a warning and gets a no-op - * disposer so it cannot remove the active contribution. + * disposer so it cannot remove the active contribution. The registry detaches + * the accepted definition, including nested resource metadata, so later caller + * mutation cannot rewrite the live contribution. * @param skill - the complete skill definition to expose for discovery. * @returns the exact Cordis effect disposer that removes this runtime * contribution and invalidates caches; composite effects may yield it @@ -250,12 +262,15 @@ export class SkillService extends Service { } /** - * List model-invocable skill summaries for a workspace. + * List model-invocable skill summaries for a workspace. The lookup options are + * snapshotted before discovery, and every returned summary is detached from the + * cached provider catalog. * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. * @returns sorted summaries, excluding skills disabled for model invocation. */ async list(options: SkillLookupOptions = {}): Promise { - return (await this.collect(options)) + const accepted = snapshotLookupOptions(options) + return (await this.collect(accepted)) .map(entry => entry.candidate) .filter(skill => skill.disableModelInvocation !== true) .map(toSummary) @@ -263,20 +278,32 @@ export class SkillService extends Service { } /** - * Load one full skill definition by name. + * Load one full skill definition by name. One lookup-options snapshot selects + * and loads the winner; the provider receives detached candidate metadata with + * its opaque locator identity preserved, and the returned definition is also + * detached from provider-owned data. Cancellation is rechecked after catalog + * selection (including a cache hit), and provider loading is raced against the + * same signal so an uncooperative provider cannot hang the caller. * @param name - kebab-case skill name. * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. * @returns the full skill, including body content, or `undefined`. */ async get(name: string, options: SkillLookupOptions = {}): Promise { if (!isSkillName(name)) return undefined - const match = (await this.collect(options)).find(entry => entry.candidate.name === name) + const accepted = snapshotLookupOptions(options) + const collected = await this.collect(accepted) + throwIfAborted(accepted.signal) + const match = collected.find(entry => entry.candidate.name === name) if (match === undefined) return undefined - return await match.provider.get(match.candidate, options) + const definition = await waitWithAbort( + match.provider.get(copyCandidate(match.candidate), accepted), + accepted.signal, + ) + return definition === undefined ? undefined : snapshotDefinition(definition) } private async collect(options: SkillLookupOptions): Promise { - options.signal?.throwIfAborted() + throwIfAborted(options.signal) while (true) { const providerRevision = this.providerRevision const runtimeRevision = this.runtimeRevision @@ -285,7 +312,7 @@ export class SkillService extends Service { if (cached !== undefined) return cached const result = await this.collectFresh(options) - options.signal?.throwIfAborted() + throwIfAborted(options.signal) if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) continue if (result.cacheable) { this.collectCache.set(key, result.entries) @@ -316,7 +343,7 @@ export class SkillService extends Service { } private async listAllCandidates(options: SkillLookupOptions): Promise { - options.signal?.throwIfAborted() + throwIfAborted(options.signal) const candidates: IndexedCandidate[] = [] let cacheable = true let runtimeOrder = 0 @@ -340,9 +367,12 @@ export class SkillService extends Service { this.ctx.logger.warn(`skill provider "${provider.name}" skipped: ${errorMessage(error)}`) } if (listed === undefined) continue + if (!Array.isArray(listed)) { + throw new TypeError(`skill provider "${provider.name}" list() must return an array`) + } for (const candidate of listed) { - validateCandidate(candidate, provider.name) - candidates.push({ candidate, provider, providerOrder: order, localOrder }) + const snapshot = snapshotCandidate(candidate, provider.name) + candidates.push({ candidate: snapshot, provider, providerOrder: order, localOrder }) localOrder += 1 } } @@ -377,28 +407,161 @@ function runtimeCandidate(skill: SkillDefinition): SkillCandidate { } } +/** Read provider candidate data once and detach it while preserving its opaque locator identity. */ +function copyCandidate(candidate: SkillCandidate, providerName?: string): SkillCandidate { + const name = candidate.name + const description = candidate.description + const whenToUse = candidate.whenToUse + const disableModelInvocation = candidate.disableModelInvocation + const source = candidate.source + const provider = candidate.provider + const resourceBase = candidate.resourceBase + const rank = candidate.rank + const locator = candidate.locator + const path = candidate.path + const metadata = candidate.metadata + const accepted: SkillCandidate = { + name, + description, + ...whenToUse !== undefined ? { whenToUse } : {}, + ...disableModelInvocation !== undefined ? { disableModelInvocation } : {}, + source, + provider, + ...resourceBase !== undefined ? { resourceBase } : {}, + rank, + // `locator` is the one deliberately provider-owned capability in a + // candidate. Its exact identity must round-trip back to provider.get(). + locator, + ...path !== undefined ? { path } : {}, + ...metadata !== undefined ? { metadata } : {}, + } + // Validate the exact scalar snapshot before cloning nested data. This keeps a + // malformed candidate's provider-contract error from being masked by an + // unrelated DataCloneError in its metadata. + if (providerName !== undefined) validateCandidate(accepted, providerName) + return { + ...accepted, + ...resourceBase !== undefined ? { resourceBase: structuredClone(resourceBase) } : {}, + ...metadata !== undefined ? { metadata: structuredClone(metadata) } : {}, + } +} + +/** Normalize one provider result into the registry-owned catalog snapshot. */ +function snapshotCandidate(candidate: SkillCandidate, providerName: string): SkillCandidate { + return copyCandidate(candidate, providerName) +} + function validateCandidate(candidate: SkillCandidate, providerName: string): void { + if (typeof candidate.name !== 'string') { + throw new TypeError(`skill provider "${providerName}" returned a non-string skill name`) + } if (!SKILL_NAME.test(candidate.name)) { throw new Error(`skill provider "${providerName}" returned invalid skill name "${candidate.name}"`) } + if (typeof candidate.description !== 'string') { + throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string description`) + } if (candidate.description.length === 0) { throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" without a description`) } - if (!Number.isFinite(candidate.rank)) { + if (candidate.disableModelInvocation !== undefined && typeof candidate.disableModelInvocation !== 'boolean') { + throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-boolean disableModelInvocation`) + } + if (candidate.whenToUse !== undefined && typeof candidate.whenToUse !== 'string') { + throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string whenToUse`) + } + if (typeof candidate.source !== 'string') { + throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string source`) + } + if (typeof candidate.rank !== 'number' || !Number.isFinite(candidate.rank)) { throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" with an invalid rank`) } + if (typeof candidate.provider !== 'string') { + throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string provider`) + } if (candidate.provider !== providerName) { throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" for provider "${candidate.provider}"`) } + if (candidate.path !== undefined && typeof candidate.path !== 'string') { + throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string path`) + } } function normalizeRuntimeSkill(skill: SkillRegistration): SkillDefinition { - if (!SKILL_NAME.test(skill.name)) throw new Error(`invalid skill name "${skill.name}"`) - if (skill.description.length === 0) throw new Error(`skill "${skill.name}" requires a description`) + // Read every caller-owned top-level field once so validation and storage use + // one coherent definition even when JavaScript accessors are involved. + const name = skill.name + const description = skill.description + const whenToUse = skill.whenToUse + const disableModelInvocation = skill.disableModelInvocation + const source = skill.source + const inputProvider = skill.provider + const provider = inputProvider === undefined ? RUNTIME_PROVIDER : inputProvider + const resourceBase = skill.resourceBase + const content = skill.content + const path = skill.path + const metadata = skill.metadata + if (typeof name !== 'string') throw new TypeError('runtime skill name must be a string') + if (!SKILL_NAME.test(name)) throw new Error(`invalid skill name "${name}"`) + if (typeof description !== 'string') throw new TypeError(`skill "${name}" description must be a string`) + if (description.length === 0) throw new Error(`skill "${name}" requires a description`) + if (disableModelInvocation !== undefined && typeof disableModelInvocation !== 'boolean') { + throw new TypeError(`skill "${name}" disableModelInvocation must be a boolean`) + } + if (whenToUse !== undefined && typeof whenToUse !== 'string') throw new TypeError(`skill "${name}" whenToUse must be a string`) + if (typeof source !== 'string') throw new TypeError(`skill "${name}" source must be a string`) + if (typeof provider !== 'string') throw new TypeError(`skill "${name}" provider must be a string`) + if (typeof content !== 'string') throw new TypeError(`skill "${name}" content must be a string`) + if (path !== undefined && typeof path !== 'string') throw new TypeError(`skill "${name}" path must be a string`) return { - ...skill, - provider: skill.provider ?? RUNTIME_PROVIDER, - source: skill.source, + name, + description, + ...whenToUse !== undefined ? { whenToUse } : {}, + ...disableModelInvocation !== undefined ? { disableModelInvocation } : {}, + source, + provider, + ...resourceBase !== undefined ? { resourceBase: structuredClone(resourceBase) } : {}, + content, + ...path !== undefined ? { path } : {}, + ...metadata !== undefined ? { metadata: structuredClone(metadata) } : {}, + } +} + +/** Detach a provider-loaded definition before it crosses back to the caller. */ +function snapshotDefinition(skill: SkillDefinition): SkillDefinition { + const name = skill.name + const description = skill.description + const whenToUse = skill.whenToUse + const disableModelInvocation = skill.disableModelInvocation + const source = skill.source + const provider = skill.provider + const resourceBase = skill.resourceBase + const content = skill.content + const path = skill.path + const metadata = skill.metadata + if (typeof name !== 'string') throw new TypeError('loaded skill name must be a string') + if (!SKILL_NAME.test(name)) throw new Error(`loaded skill has invalid name "${name}"`) + if (typeof description !== 'string') throw new TypeError(`loaded skill "${name}" description must be a string`) + if (description.length === 0) throw new Error(`loaded skill "${name}" requires a description`) + if (disableModelInvocation !== undefined && typeof disableModelInvocation !== 'boolean') { + throw new TypeError(`loaded skill "${name}" disableModelInvocation must be a boolean`) + } + if (whenToUse !== undefined && typeof whenToUse !== 'string') throw new TypeError(`loaded skill "${name}" whenToUse must be a string`) + if (typeof source !== 'string') throw new TypeError(`loaded skill "${name}" source must be a string`) + if (typeof provider !== 'string') throw new TypeError(`loaded skill "${name}" provider must be a string`) + if (typeof content !== 'string') throw new TypeError(`loaded skill "${name}" content must be a string`) + if (path !== undefined && typeof path !== 'string') throw new TypeError(`loaded skill "${name}" path must be a string`) + return { + name, + description, + ...whenToUse !== undefined ? { whenToUse } : {}, + ...disableModelInvocation !== undefined ? { disableModelInvocation } : {}, + source, + provider, + ...resourceBase !== undefined ? { resourceBase: structuredClone(resourceBase) } : {}, + content, + ...path !== undefined ? { path } : {}, + ...metadata !== undefined ? { metadata: structuredClone(metadata) } : {}, } } @@ -411,7 +574,7 @@ function toSummary(skill: SkillDefinition | SkillCandidate): SkillSummary { ...disableModelInvocation !== undefined ? { disableModelInvocation } : {}, source, provider, - ...resourceBase !== undefined ? { resourceBase } : {}, + ...resourceBase !== undefined ? { resourceBase: structuredClone(resourceBase) } : {}, } } @@ -441,9 +604,19 @@ function collectCacheKey(options: SkillLookupOptions, providerRevision: number, return JSON.stringify({ cwd: options.cwd, providerRevision, runtimeRevision }) } +/** Capture one lookup identity before any provider or cache async boundary. */ +function snapshotLookupOptions(options: SkillLookupOptions): Readonly { + const cwd = options.cwd + const signal = options.signal + return Object.freeze({ + ...cwd !== undefined ? { cwd } : {}, + ...signal !== undefined ? { signal } : {}, + }) +} + function waitWithAbort(promise: Promise, signal: AbortSignal | undefined): Promise { if (signal === undefined) return promise - signal.throwIfAborted() + throwIfAborted(signal) return new Promise((resolve, reject) => { const cleanup = (): void => { signal.removeEventListener('abort', onAbort) @@ -467,12 +640,28 @@ function waitWithAbort(promise: Promise, signal: AbortSignal | undefined): }) } -function toError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)) +/** Throw a total Error for an already-aborted lookup. */ +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted === true) throw toError(signal.reason) } +/** Normalize an arbitrary abort or provider failure without trusting coercion. */ +function toError(error: unknown): Error { + try { + if (error instanceof Error) return error + } catch { + // A hostile proxy may throw during instanceof; fall through to the total renderer. + } + return new Error(errorMessage(error)) +} + +/** Render an arbitrary provider failure without letting coercion escape containment. */ function errorMessage(error: unknown): string { - return String(error) + try { + return String(error) + } catch { + return '[unrenderable thrown value]' + } } export default SkillService diff --git a/packages/skill/skill/tests/skill.spec.ts b/packages/skill/skill/tests/skill.spec.ts index e3b491f690..b47bdb6431 100644 --- a/packages/skill/skill/tests/skill.spec.ts +++ b/packages/skill/skill/tests/skill.spec.ts @@ -165,6 +165,480 @@ describe('SkillService registry', () => { expect(() => ctx.skills.registerProvider(replacement)).not.toThrow() }) + it('rejects malformed provider and candidate scalar fields without freezing caller objects', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + const badProviderName = { value: 'object-provider' } + expect(() => ctx.skills.registerProvider({ + name: badProviderName as unknown as string, + list: () => Promise.resolve([]), + get: () => Promise.resolve(undefined), + })).toThrow('skill provider name must be a string') + expect(Object.isFrozen(badProviderName)).toBe(false) + expect(() => ctx.skills.registerProvider({ + name: 'bad-list', + list: { bind() {} } as unknown as SkillProvider['list'], + get: () => Promise.resolve(undefined), + })).toThrow('list must be a function') + expect(() => ctx.skills.registerProvider({ + name: 'bad-get', + list: () => Promise.resolve([]), + get: { bind() {} } as unknown as SkillProvider['get'], + })).toThrow('get must be a function') + + const badDescription = { value: 'object-description' } + ctx.skills.registerProvider({ + name: 'bad-candidate', + list: () => Promise.resolve([{ + ...memorySkill('bad-candidate', 'placeholder', 1), + provider: 'bad-candidate', + description: badDescription as unknown as string, + disableModelInvocation: 'false' as unknown as boolean, + }]), + get: () => Promise.resolve(undefined), + }) + await expect(ctx.skills.list()).rejects.toThrow('non-string description') + expect(Object.isFrozen(badDescription)).toBe(false) + + const badBoolean = new Context() + await badBoolean.plugin(SkillService) + badBoolean.skills.registerProvider({ + name: 'bad-boolean', + list: () => Promise.resolve([{ + ...memorySkill('bad-boolean', 'Bad boolean', 1), + provider: 'bad-boolean', + disableModelInvocation: 'false' as unknown as boolean, + }]), + get: () => Promise.resolve(undefined), + }) + await expect(badBoolean.skills.list()).rejects.toThrow('non-boolean disableModelInvocation') + }) + + it('rejects non-array provider results and every malformed candidate scalar', async () => { + const badList = new Context() + await badList.plugin(SkillService) + badList.skills.registerProvider({ + name: 'non-array-list', + list: () => Promise.resolve({} as unknown as SkillCandidate[]), + get: () => Promise.resolve(undefined), + }) + await expect(badList.skills.list()).rejects.toThrow('list() must return an array') + + const cases: { patch: Partial; expected: string }[] = [ + { patch: { name: { value: 'candidate' } as unknown as string }, expected: 'non-string skill name' }, + { patch: { whenToUse: 1 as unknown as string }, expected: 'non-string whenToUse' }, + { patch: { source: { value: 'source' } as unknown as string }, expected: 'non-string source' }, + { patch: { rank: '1' as unknown as number }, expected: 'invalid rank' }, + { patch: { provider: { value: 'provider' } as unknown as string }, expected: 'non-string provider' }, + { patch: { path: 1 as unknown as string }, expected: 'non-string path' }, + ] + for (const [index, { patch, expected }] of cases.entries()) { + const ctx = new Context() + await ctx.plugin(SkillService) + const providerName = `candidate-provider-${index}` + const candidate = { + name: `candidate-${index}`, + description: 'Candidate', + whenToUse: 'Use this candidate.', + disableModelInvocation: false, + provider: providerName, + source: 'test', + rank: 1, + locator: 'candidate', + path: '/skills/candidate/SKILL.md', + ...patch, + } as SkillCandidate + ctx.skills.registerProvider({ + name: providerName, + list: () => Promise.resolve([candidate]), + get: () => Promise.resolve(undefined), + }) + + await expect(ctx.skills.list()).rejects.toThrow(expected) + } + }) + + it('snapshots lookup options before asynchronous discovery and loading', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + let release: (() => void) | undefined + const gate = new Promise((resolve) => { release = resolve }) + const listCwds: (string | undefined)[] = [] + const getCwds: (string | undefined)[] = [] + ctx.skills.registerProvider({ + name: 'contextual', + async list(options) { + listCwds.push(options.cwd) + await gate + const name = options.cwd === '/workspace/a' ? 'skill-a' : 'skill-b' + return [ + { name, description: name, provider: 'contextual', source: 'test', rank: 1, locator: name }, + { name: 'vanished', description: 'Vanished', provider: 'contextual', source: 'test', rank: 2, locator: 'vanished' }, + ] + }, + async get(candidate, options) { + getCwds.push(options.cwd) + if (candidate.name === 'vanished') return undefined + return { ...candidate, content: `${options.cwd}:${candidate.name}` } + }, + }) + + const listOptions: { cwd: string | undefined } = { cwd: '/workspace/a' } + const pending = ctx.skills.list(listOptions) + listOptions.cwd = '/workspace/b' + release?.() + + expect((await pending).map(skill => skill.name)).toEqual(['skill-a', 'vanished']) + expect((await ctx.skills.list({ cwd: '/workspace/a' })).map(skill => skill.name)).toEqual(['skill-a', 'vanished']) + expect(listCwds).toEqual(['/workspace/a']) + + const getOptions: { cwd: string | undefined } = { cwd: '/workspace/a' } + const loading = ctx.skills.get('skill-a', getOptions) + getOptions.cwd = '/workspace/b' + expect((await loading)?.content).toBe('/workspace/a:skill-a') + expect(await ctx.skills.get('vanished', { cwd: '/workspace/a' })).toBeUndefined() + expect(getCwds).toEqual(['/workspace/a', '/workspace/a']) + }) + + it('rechecks cancellation after cached discovery before provider loading', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + let getCalls = 0 + ctx.skills.registerProvider({ + name: 'cached', + async list() { + return [{ + name: 'cached-skill', + description: 'Cached skill', + provider: 'cached', + source: 'test', + rank: 1, + locator: 'cached', + }] + }, + async get(candidate) { + getCalls += 1 + return { ...candidate, content: 'Cached body.' } + }, + }) + await ctx.skills.list({ cwd: '/workspace/cache' }) + const controller = new AbortController() + const reason = new Error('cancelled after cached discovery') + + const pending = ctx.skills.get('cached-skill', { + cwd: '/workspace/cache', + signal: controller.signal, + }) + controller.abort(reason) + + await expect(pending).rejects.toBe(reason) + expect(getCalls).toBe(0) + }) + + it('stops waiting for cached provider loading when a hostile abort reason fires', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + let markStarted: (() => void) | undefined + let release: (() => void) | undefined + let seenSignal: AbortSignal | undefined + const started = new Promise((resolve) => { markStarted = resolve }) + const held = new Promise((resolve) => { + release = () => { + resolve({ + name: 'held-skill', + description: 'Held skill', + provider: 'held', + source: 'test', + content: 'Held body.', + }) + } + }) + ctx.skills.registerProvider({ + name: 'held', + async list() { + return [{ + name: 'held-skill', + description: 'Held skill', + provider: 'held', + source: 'test', + rank: 1, + locator: 'held', + }] + }, + get(_candidate, options) { + seenSignal = options.signal + markStarted?.() + return held + }, + }) + await ctx.skills.list({ cwd: '/workspace/cache' }) + const controller = new AbortController() + const hostileReason = { + [Symbol.toPrimitive]() { + throw new Error('abort reason coercion failed') + }, + } + const pending = ctx.skills.get('held-skill', { + cwd: '/workspace/cache', + signal: controller.signal, + }) + const outcome = pending.then( + () => 'resolved', + (error: unknown) => error instanceof Error && error.message === '[unrenderable thrown value]' + ? 'aborted' + : 'other-error', + ) + await started + controller.abort(hostileReason) + + const settled = await Promise.race([ + outcome, + new Promise<'timeout'>(resolve => setTimeout(() => { resolve('timeout') }, 25)), + ]) + release?.() + await pending.catch(() => undefined) + + expect(seenSignal).toBe(controller.signal) + expect(settled).toBe('aborted') + }) + + it('detaches cached candidates and loaded definitions while preserving locator identity', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + const locator = { id: 'provider-owned' } + const candidate: SkillCandidate = { + name: 'stable-skill', + description: 'Stable description', + whenToUse: 'When stability matters.', + disableModelInvocation: false, + provider: 'detached', + source: 'test', + resourceBase: { kind: 'opaque', description: 'candidate resources' }, + rank: 1, + locator, + path: '/skills/stable/SKILL.md', + metadata: { owner: 'candidate' }, + } + const definition: SkillDefinition = { + name: 'stable-skill', + description: 'Stable description', + whenToUse: 'When stability matters.', + disableModelInvocation: false, + provider: 'detached', + source: 'test', + resourceBase: { kind: 'opaque', description: 'definition resources' }, + path: '/skills/stable/SKILL.md', + metadata: { owner: 'definition' }, + content: 'Stable body.', + } + let listCalls = 0 + let received: SkillCandidate | undefined + ctx.skills.registerProvider({ + name: 'detached', + async list() { + listCalls += 1 + return [candidate] + }, + async get(loaded) { + received = loaded + return definition + }, + }) + + const first = await ctx.skills.list() + candidate.name = 'Bad_Name' + candidate.description = '' + if (candidate.resourceBase?.kind === 'opaque') candidate.resourceBase.description = 'mutated candidate' + if (candidate.metadata) candidate.metadata.owner = 'mutated candidate' + if (first[0]?.resourceBase?.kind === 'opaque') first[0].resourceBase.description = 'mutated summary' + + const second = await ctx.skills.list() + expect(second).toEqual([expect.objectContaining({ + name: 'stable-skill', + description: 'Stable description', + resourceBase: { kind: 'opaque', description: 'candidate resources' }, + })]) + expect(listCalls).toBe(1) + + const loaded = await ctx.skills.get('stable-skill') + expect(received).not.toBe(candidate) + expect(received?.locator).toBe(locator) + expect(received).toMatchObject({ + name: 'stable-skill', + description: 'Stable description', + resourceBase: { kind: 'opaque', description: 'candidate resources' }, + metadata: { owner: 'candidate' }, + }) + expect(loaded).not.toBe(definition) + if (loaded?.resourceBase?.kind === 'opaque') loaded.resourceBase.description = 'mutated definition output' + if (loaded?.metadata) loaded.metadata.owner = 'mutated definition output' + + expect(await ctx.skills.get('stable-skill')).toMatchObject({ + resourceBase: { kind: 'opaque', description: 'definition resources' }, + metadata: { owner: 'definition' }, + }) + expect(definition).toMatchObject({ + resourceBase: { kind: 'opaque', description: 'definition resources' }, + metadata: { owner: 'definition' }, + }) + }) + + it('detaches runtime registrations and every public resource view', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + const resourceBase = { kind: 'opaque' as const, description: 'runtime resources' } + const metadata = { owner: 'runtime' } + ctx.skills.register({ + name: 'runtime-skill', + description: 'Runtime', + whenToUse: 'When runtime data is needed.', + disableModelInvocation: false, + source: 'runtime', + resourceBase, + metadata, + content: 'Runtime body.', + }) + ctx.skills.register({ + name: 'z-runtime', + description: 'Second runtime skill', + source: 'runtime', + content: 'Second runtime body.', + }) + resourceBase.description = 'mutated registration' + metadata.owner = 'mutated registration' + + const listed = await ctx.skills.list() + const loaded = await ctx.skills.get('runtime-skill') + expect(listed[0]?.resourceBase).toEqual({ kind: 'opaque', description: 'runtime resources' }) + expect(loaded?.metadata).toEqual({ owner: 'runtime' }) + if (listed[0]?.resourceBase?.kind === 'opaque') listed[0].resourceBase.description = 'mutated list output' + if (loaded?.resourceBase?.kind === 'opaque') loaded.resourceBase.description = 'mutated get output' + if (loaded?.metadata) loaded.metadata.owner = 'mutated get output' + + expect((await ctx.skills.list())[0]?.resourceBase).toEqual({ kind: 'opaque', description: 'runtime resources' }) + expect(await ctx.skills.get('runtime-skill')).toMatchObject({ + resourceBase: { kind: 'opaque', description: 'runtime resources' }, + metadata: { owner: 'runtime' }, + }) + }) + + it('rejects malformed runtime and loaded-definition scalar fields without freezing them', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + const runtimeDescription = { value: 'runtime-description' } + expect(() => ctx.skills.register({ + name: 'bad-runtime', + description: runtimeDescription as unknown as string, + source: 'runtime', + content: 'body', + })).toThrow('description must be a string') + expect(Object.isFrozen(runtimeDescription)).toBe(false) + expect(() => ctx.skills.register({ + name: 'bad-runtime-boolean', + description: 'Runtime', + disableModelInvocation: 'false' as unknown as boolean, + source: 'runtime', + content: 'body', + })).toThrow('disableModelInvocation must be a boolean') + expect(() => ctx.skills.register({ + name: 'bad-runtime-provider', + description: 'Runtime', + source: 'runtime', + provider: null as unknown as string, + content: 'body', + })).toThrow('provider must be a string') + + const loadedContent = { value: 'loaded-content' } + ctx.skills.registerProvider({ + name: 'bad-definition', + list: () => Promise.resolve([{ + name: 'bad-definition', + description: 'Candidate', + provider: 'bad-definition', + source: 'test', + rank: 1, + locator: 'bad-definition', + }]), + get: candidate => Promise.resolve({ + ...candidate, + content: loadedContent as unknown as string, + }), + }) + await expect(ctx.skills.get('bad-definition')).rejects.toThrow('content must be a string') + expect(Object.isFrozen(loadedContent)).toBe(false) + }) + + it('rejects every other malformed runtime scalar', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + type Registration = Parameters[0] + const valid: Registration = { + name: 'runtime-validation', + description: 'Runtime validation', + whenToUse: 'Use this runtime skill.', + disableModelInvocation: false, + source: 'runtime', + provider: 'runtime-validation', + content: 'Runtime body.', + path: '/skills/runtime-validation/SKILL.md', + } + const cases: { patch: Partial; expected: string }[] = [ + { patch: { name: { value: 'runtime' } as unknown as string }, expected: 'runtime skill name must be a string' }, + { patch: { whenToUse: 1 as unknown as string }, expected: 'whenToUse must be a string' }, + { patch: { source: { value: 'source' } as unknown as string }, expected: 'source must be a string' }, + { patch: { content: { value: 'content' } as unknown as string }, expected: 'content must be a string' }, + { patch: { path: 1 as unknown as string }, expected: 'path must be a string' }, + ] + for (const { patch, expected } of cases) { + expect(() => ctx.skills.register({ ...valid, ...patch })).toThrow(expected) + } + }) + + it('rejects every malformed scalar in provider-loaded definitions', async () => { + const cases: { patch: Partial; expected: string }[] = [ + { patch: { name: { value: 'loaded' } as unknown as string }, expected: 'loaded skill name must be a string' }, + { patch: { name: 'Bad_Name' }, expected: 'loaded skill has invalid name' }, + { patch: { description: { value: 'description' } as unknown as string }, expected: 'description must be a string' }, + { patch: { description: '' }, expected: 'requires a description' }, + { patch: { disableModelInvocation: 'false' as unknown as boolean }, expected: 'disableModelInvocation must be a boolean' }, + { patch: { whenToUse: 1 as unknown as string }, expected: 'whenToUse must be a string' }, + { patch: { source: { value: 'source' } as unknown as string }, expected: 'source must be a string' }, + { patch: { provider: { value: 'provider' } as unknown as string }, expected: 'provider must be a string' }, + { patch: { content: { value: 'content' } as unknown as string }, expected: 'content must be a string' }, + { patch: { path: 1 as unknown as string }, expected: 'path must be a string' }, + ] + for (const [index, { patch, expected }] of cases.entries()) { + const ctx = new Context() + await ctx.plugin(SkillService) + const providerName = `definition-provider-${index}` + const skillName = `definition-${index}` + ctx.skills.registerProvider({ + name: providerName, + list: () => Promise.resolve([{ + name: skillName, + description: 'Candidate', + provider: providerName, + source: 'test', + rank: 1, + locator: 'definition', + }]), + get: () => Promise.resolve({ + name: skillName, + description: 'Definition', + whenToUse: 'Use this definition.', + disableModelInvocation: false, + provider: providerName, + source: 'test', + content: 'Definition body.', + path: '/skills/definition/SKILL.md', + ...patch, + } as SkillDefinition), + }) + + await expect(ctx.skills.get(skillName)).rejects.toThrow(expected) + } + }) + it('validates provider candidates and invalid registry caps', async () => { const defaultedService = new SkillService(new Context()) expect(await defaultedService.list()).toEqual([]) @@ -282,6 +756,34 @@ describe('SkillService registry', () => { expect(flakyCalls).toBe(3) }) + it('contains a provider rejection whose string coercion throws', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + const hostileFailure = { + toString() { + throw new Error('provider failure coercion failed') + }, + } + ctx.skills.registerProvider({ + name: 'hostile-failure', + list() { + // Deliberately violate the provider contract to prove containment is total. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + return Promise.reject(hostileFailure) + }, + async get() { + return undefined + }, + }) + + await expect(ctx.skills.list()).resolves.toEqual([]) + expect(warnings).toEqual([ + 'skill provider "hostile-failure" skipped: [unrenderable thrown value]', + ]) + }) + it('abandons an in-flight catalog when provider registrations change', async () => { const ctx = new Context() await ctx.plugin(SkillService) diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index f9c7b04a51..66234a012c 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -23,10 +23,9 @@ const emptyStop: StreamChunk[] = [{ type: 'finish', reason: { kind: 'stop' } }] /** * Drives the REAL fork backend with a real loop + scripted mock MODEL + the - * real dsh-invariants plugin. The invariants plugin re-replays a seeded child - * log on `session/created` (its freeze-check), so a malformed (unbalanced) fork - * seed makes these tests THROW — that is the regression guard for the - * completed-turn-prefix boundary. + * real dsh-invariants plugin. The plugin replays a seeded child log on + * `session/created`, so a malformed (unbalanced) fork seed makes these tests + * THROW — that is the regression guard for the completed-turn-prefix boundary. */ async function setup(script: Script) { const ctx = new Context() diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index caa7b9b161..e9f7beb540 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -8,7 +8,7 @@ The shared **in-process subagent run driver**. A library with no provider or imp Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`): -1. snapshots the accepted request before asynchronous owner setup: the parent and signal remain identity capabilities but are never reread from the caller-owned record; tool filter, seed, agent options, output schema, and prompt are detached. It computes child depth = `depthOf(parent) + 1` and rejects `request.maxDepth` overflow with `SubagentDepthError`; `outputSchema` is asserted before cloning so a hostile value fails as `OutputSchemaError`, while the prompt passes the session log's lossless-JSON check before and after cloning; +1. reads every public request and seed field once before asynchronous owner setup: the parent and signal remain identity capabilities, while tool filter, seed, agent options, output schema, and prompt are each materialized by the shared one-pass lossless-JSON snapshot. It computes child depth = `depthOf(parent) + 1`, rejects `request.maxDepth` overflow with `SubagentDepthError`, reports an invalid schema as `OutputSchemaError`, and derives both the child prefix and `seedLength` from the same detached seed; 2. first installs provider ownership, then attaches the request abort listener and creates one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves no child or orphaned listener. Async child creation goes through that fiber's `ctx.agents` service with fresh IDs, lineage/seed, inherited model, and an unpublished setup transaction for persona, tool restriction, and structured output. Parent teardown, provider teardown, and manual `run.dispose()` all dispose this exact node, preventing publication after it becomes inactive and awaiting the same quiescence boundary. `startInProcessRun` still returns its `SubagentRun` immediately: `run.started` resolves only after `ctx.agents.create()` has published the child (and rejects if publication never happens), while cancellation during creation is recorded and applied when a child exists; 3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts); there is deliberately NO re-prompt for a structured child that finished cleanly without calling `structured_output` — the shortfall maps to an `error` result for the parent; 4. reads the result, scoped to the child's OWN events (everything at or after `seedLength`, so a seeded child that produced no message of its own never returns the seeded parent's last message): the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. A structured run surfaces the captured value as `result.structured`; a structured child that finished cleanly WITHOUT ever capturing settles `error` (a clean finish without the demanded result is a failure, not a success with a missing field). diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index de3b042fe3..518ea6a8b4 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -18,9 +18,9 @@ import { randomUUID } from 'node:crypto' import type { Context, Fiber } from 'cordis' import { AgentId, type Agent, type AgentHandle, type AgentOptions } from '@deepseek-ai/dsh-agent' -import { SessionId, isJsonValue, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' +import { SessionId, snapshotJsonValue, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools' +import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' import { attachStructuredRuntime, @@ -125,59 +125,74 @@ export function startInProcessRun( request: SubagentStartRequest, options: InProcessRunOptions, ): SubagentRun { - // Snapshot the accepted request synchronously. The parent and signal are - // identity capabilities (kept live but never reread from the mutable request - // record); every data field is detached before asynchronous owner setup. + // Capture every top-level field once. Parent/signal are identity capabilities; + // every data value is materialized below before asynchronous owner setup. const parent = request.parent const signal = request.signal const persona = request.persona - const toolFilter = request.toolFilter === undefined ? undefined : structuredClone(request.toolFilter) - const seed = options.seed === undefined ? undefined : structuredClone(options.seed) + const inputToolFilter = request.toolFilter + const inputMaxDepth = request.maxDepth + const inputSchema = request.outputSchema + const inputPrompt = request.prompt + const inputAgentOptions = request.agentOptions + const inputSeed = options.seed + const toolFilter = inputToolFilter === undefined ? undefined : snapshotJsonValue(inputToolFilter) + if (inputToolFilter !== undefined && toolFilter === undefined) { + throw new TypeError('subagent tool filter must be losslessly JSON-serializable') + } + const seed = inputSeed === undefined ? undefined : snapshotJsonValue(inputSeed) + if (inputSeed !== undefined && seed === undefined) { + throw new TypeError('subagent seed must be losslessly JSON-serializable') + } const childDepth = depthOf(parent) + 1 - if (request.maxDepth !== undefined && childDepth > request.maxDepth) { - throw new SubagentDepthError(childDepth, request.maxDepth) + if (inputMaxDepth !== undefined && childDepth > inputMaxDepth) { + throw new SubagentDepthError(childDepth, inputMaxDepth) } - // Assert, then snapshot, the schema subset BEFORE any child exists (the - // service has already capability-gated; this rejects a schema outside the - // enforced subset loud). Assertion comes FIRST so a hostile value fails as - // OutputSchemaError, never as structuredClone's raw DataCloneError — the - // asserted subset is plain JSON data, which always clones. The snapshot is - // load-bearing: the caller keeps its reference, so attaching the ORIGINAL - // would let a post-start() mutation drift the enforced schema away from the - // asserted one — the clone (taken synchronously with the assertion, no - // interleaving possible) pins assertion, the model-visible parameters, and - // validateStructuredValue to one isolation-immutable value. - if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema) - const schema = request.outputSchema === undefined ? undefined : structuredClone(request.outputSchema) + const requestedAgentOptions = inputAgentOptions === undefined + ? {} + : snapshotJsonValue(inputAgentOptions) + if (requestedAgentOptions === undefined) { + throw new TypeError('subagent agent options must be losslessly JSON-serializable') + } + // Materialize, then assert, the schema subset BEFORE any child exists. The + // single traversal rejects non-JSON data without rereading accessors; the + // detached value then pins assertion, model-visible parameters, and runtime + // validation to one provider-owned schema. Contract failures stay typed as + // OutputSchemaError rather than leaking a materialization detail. + const schema = inputSchema === undefined ? undefined : snapshotJsonValue(inputSchema) + if (inputSchema !== undefined && schema === undefined) { + throw new OutputSchemaError(['schema annotation must be JSON data; the complete schema must be losslessly JSON-serializable']) + } + if (schema !== undefined) assertSupportedOutputSchema(schema) // The accepted request owns a value snapshot, not the caller's mutable - // content array. Validate the same lossless-JSON contract Session.append - // enforces before any child exists, then detach it synchronously so mutation - // during async creation cannot change what is logged or sent to the model. - if (!isJsonValue(request.prompt)) { + // content array. Use the same one-pass boundary Session.append enforces before + // any child exists so later mutation cannot change what is logged or sent. + const prompt = snapshotJsonValue(inputPrompt) + if (prompt === undefined) { throw new TypeError('subagent prompt must be losslessly JSON-serializable') } - const prompt = structuredClone(request.prompt) - if (!isJsonValue(prompt)) { - throw new TypeError('subagent prompt must be stable losslessly JSON-serializable data') - } const childId = AgentId(randomUUID()) // The child's OWN events begin after the seed (fork seeds the parent's // completed-turn prefix; spawn seeds nothing). `readResult` scopes to this // boundary so a child that produces no message of its own never returns the // SEEDED parent's last assistant message as its result. - const seedLength = options.seed?.length ?? 0 + const seedLength = seed?.length ?? 0 const parentHeader = parent.session.header // Inherit the parent's model by default (a child with no model cannot run); // an explicit `request.agentOptions.model` overrides it. The deployment // persona needs no inheritance (a context-wide section both render); a // per-child `request.persona` becomes a SCOPED section of the same name in // the setup below, shadowing the deployment's for this child alone. - const agentOptions: AgentOptions = structuredClone({ - ...parent.options.model !== undefined ? { model: parent.options.model } : {}, - ...request.agentOptions, + const parentModel = parent.options.model + const agentOptions = snapshotJsonValue({ + ...parentModel !== undefined ? { model: parentModel } : {}, + ...requestedAgentOptions, subagentDepth: childDepth, }) + if (agentOptions === undefined) { + throw new TypeError('subagent agent options must be losslessly JSON-serializable') + } // The child's scoped world, composed in the factory's unpublished setup // window. The factory awaits it before inserting or announcing the child, so diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index dff63486ed..d4267ae009 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -79,8 +79,8 @@ export interface StructuredAttachment { * agent-creation `setup` window with the child's scope context — every * registration rides the child's fiber and unwinds with the child. * @param childCtx - the child agent's scope context (`setup`'s argument). - * @param schema - the isolation-cloned, already-asserted schema subset to - * enforce (see `assertSupportedOutputSchema` in dsh-tools). + * @param schema - the detached, already-asserted schema subset to enforce (see + * `assertSupportedOutputSchema` in dsh-tools). * @returns the attachment handle (read `captured()` after the child settles). */ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOutputSchema): StructuredAttachment { diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 648ee5935f..f8e18ed572 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -1,15 +1,15 @@ import { describe, expect, it, vi } from 'vitest' import { Context, type Fiber } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' -import SubagentService from '@deepseek-ai/dsh-subagent' +import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' -import { depthOf, SubagentDepthError, startInProcessRun } from '../src/index.ts' +import { depthOf, type InProcessRunOptions, SubagentDepthError, startInProcessRun } from '../src/index.ts' type Script = ConstructorParameters[0] @@ -57,7 +57,7 @@ describe('startInProcessRun', () => { }, {})).toThrow('subagent prompt must be losslessly JSON-serializable') }) - it('rejects a prompt whose getter becomes non-JSON while it is snapshotted', async () => { + it('reads each prompt value once before asynchronous child creation', async () => { const { ctx, parent } = await setup([]) let reads = 0 const prompt = [{ @@ -68,9 +68,91 @@ describe('startInProcessRun', () => { }, }] - expect(() => startInProcessRun(ctx, { prompt, parent }, {})) - .toThrow('subagent prompt must be stable losslessly JSON-serializable data') - expect(reads).toBe(2) + const run = startInProcessRun(ctx, { prompt, parent }, {}) + expect(reads).toBe(1) + await run.dispose() + }) + + it('reads each public request and seed option field once', async () => { + const { ctx, parent } = await setup([]) + const reads = { prompt: 0, toolFilter: 0, maxDepth: 0, outputSchema: 0, agentOptions: 0, persona: 0, seed: 0 } + const request = Object.defineProperties({ parent }, { + prompt: { enumerable: true, get: () => { reads.prompt += 1; return [{ type: 'text', text: 'accepted' }] } }, + toolFilter: { enumerable: true, get: () => { reads.toolFilter += 1; return reads.toolFilter === 1 ? undefined : { deny: ['ghost'] } } }, + maxDepth: { enumerable: true, get: () => { reads.maxDepth += 1; return reads.maxDepth === 1 ? undefined : 0 } }, + outputSchema: { enumerable: true, get: () => { reads.outputSchema += 1; return undefined } }, + agentOptions: { enumerable: true, get: () => { reads.agentOptions += 1; return {} } }, + persona: { enumerable: true, get: () => { reads.persona += 1; return undefined } }, + }) as unknown as SubagentStartRequest + const options = Object.defineProperty({}, 'seed', { + enumerable: true, + get: () => { reads.seed += 1; return reads.seed === 1 ? undefined : [] }, + }) as InProcessRunOptions + + const run = startInProcessRun(ctx, request, options) + + expect(reads).toEqual({ prompt: 1, toolFilter: 1, maxDepth: 1, outputSchema: 1, agentOptions: 1, persona: 1, seed: 1 }) + await run.dispose() + }) + + it('rejects an exotic seed before asynchronous owner setup can sanitize it', async () => { + const { ctx, parent } = await setup([]) + class ExoticSeedEvent { + readonly type = 'turn/start' + readonly seq = 0 + readonly time = 1 + readonly data = { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } + } + + expect(() => startInProcessRun(ctx, { + prompt: [{ type: 'text', text: 'accepted' }], + parent, + }, { seed: [new ExoticSeedEvent()] as unknown as SessionEvent[] })) + .toThrow(/subagent seed must be losslessly JSON-serializable/) + }) + + it.each([ + { + label: 'tool filter', + overrides: { toolFilter: { deny: [Number.NaN as unknown as string] } }, + message: 'subagent tool filter must be losslessly JSON-serializable', + }, + { + label: 'agent options', + overrides: { agentOptions: { model: Number.NaN as unknown as string } }, + message: 'subagent agent options must be losslessly JSON-serializable', + }, + { + label: 'output schema', + overrides: { + outputSchema: { + type: 'object', + properties: { answer: { type: Number.NaN } }, + } as unknown as NonNullable, + }, + message: 'schema annotation must be JSON data', + }, + ])('rejects non-JSON $label before asynchronous child creation', async ({ overrides, message }) => { + const { ctx, parent } = await setup([]) + + expect(() => startInProcessRun(ctx, { + prompt: [{ type: 'text', text: 'accepted' }], + parent, + ...overrides, + }, {})).toThrow(message) + }) + + it('rejects a non-JSON model inherited from the parent before child creation', async () => { + const { ctx, parent } = await setup([]) + const invalidParent = { + options: { ...parent.options, model: Number.NaN as unknown as string }, + session: parent.session, + } as unknown as Agent + + expect(() => startInProcessRun(ctx, { + prompt: [{ type: 'text', text: 'accepted' }], + parent: invalidParent, + }, {})).toThrow('subagent agent options must be losslessly JSON-serializable') }) it('rejects when the run-owner fiber settles without installing its context', async () => { diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index e214ba58c6..b068cfe79d 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -21,7 +21,7 @@ Unlike the bash seam (one executor per context, second load throws), **multiple | `registerProvider(provider)` | Register a frozen acceptance snapshot under `provider.name`; later caller mutation cannot change registry behavior or HMR cleanup, while `start` stays bound to the original provider receiver. Throws `SubagentError('DUPLICATE_PROVIDER')` on a name clash. Effect-scoped (HMR-safe); returns the disposer. | | `getProvider(name)` | Look up the frozen registry snapshot (`undefined` if absent). | | `list()` | Registered provider names (insertion order). | -| `start(name, request)` | Resolve the provider (`NO_PROVIDER` if absent), validate every requested START-TIME capability (`UNSUPPORTED_CAPABILITY` for the first unmet one — before any child is created), then delegate to `provider.start`. Emit `subagent/start` only after `run.started` fulfills and the paired `subagent/end` after that started run settles; a pre-publication readiness rejection emits neither. | +| `start(name, request)` | Resolve the provider (`NO_PROVIDER` if absent), read every caller field once into one acceptance snapshot, validate every requested START-TIME capability and scalar value before any child is created, and materialize prompt/schema/options/filter data through a single-pass lossless-JSON snapshot before delegating to `provider.start`. Return a frozen service-owned run wrapper whose provider fields are captured once, whose methods remain bound to the provider handle, and whose `result` is one detached, deeply frozen normalization shared by the caller and telemetry. Emit `subagent/start` only after `run.started` fulfills and the paired `subagent/end` after that started run settles; a pre-publication readiness rejection emits neither. | ## Capabilities: two kinds, discovered two ways @@ -32,12 +32,12 @@ Beside `capabilities` sits one DESCRIPTIVE fact, not validated by the service: ` ## Run lifecycle -`provider.start(request)` returns a `SubagentRun`: a handle with `started` (the publication/readiness promise), `result` (the terminal outcome), `cancel()`, `dispose()`, and the optional runtime methods. `started` resolves only after the provider has established a real child and rejects if the attempt fails or is cancelled first. `result` resolves with a `SubagentResult` (`output`, optional `structured`, `stopReason`) — it 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` tool result. The consumer MUST `dispose()` on every path (success, error, abort) to reach child quiescence and avoid leaking an idle child / session. +`provider.start(request)` returns a provider-owned `SubagentRun`; `SubagentService.start` captures that handle once and returns a frozen service-owned wrapper with `started` (the publication/readiness promise), a normalized `result` (the terminal outcome), bound `cancel()` and `dispose()`, and bound optional runtime methods. `started` resolves only after the provider has established a real child and rejects if the attempt fails or is cancelled first. `result` resolves with one detached, deeply frozen `SubagentResult` (`output`, optional `structured`, `stopReason`) that the service and caller share — it does **not** reject on a child-level failure (a model/transport failure resolves with `stopReason: 'error'`), but malformed provider data rejects as an infrastructure contract fault. The consumer maps a non-`completed` reason to an `isError` tool result and MUST `dispose()` on every path (success, error, abort) to reach child quiescence and avoid leaking an idle child / session. -The service also announces provider lifecycle: `subagent/provider-added` (the frozen registry snapshot) fires after a registration and `subagent/provider-removed` (the accepted name) after an unregistration, so a consumer deriving state from a named provider (the model-facing tool wording) mirrors registry membership instead of assuming load order — the cordis Loader starts sibling plugins concurrently, so "listed earlier" does not mean "registered earlier". Run lifecycle is gated by provider readiness: `subagent/start` (payload `SubagentRunInfo`) fires only after `run.started` fulfills, and `subagent/end` (payload `SubagentRunEndInfo`) fires only for that announced run; readiness rejection emits neither. For spawn/fork, the start listener can resolve the published child via `ctx.agents.get(info.id)`; a remote provider need not have a local registry entry. Both events are **observe-only** plain emits. The service observes `result` immediately even while readiness is pending, clones its output before the caller can mutate it, and buffers that end payload until start has fired; a rejecting result cannot become an unhandled detached promise, start always precedes end, and a listener cannot corrupt the caller's result. `subagent/end` carries the cloned output as `lastAssistantMessage` on the settle path and omits it on infrastructure rejection. Any run-affecting decision is out of scope for this observe-only surface. +The service also announces provider lifecycle: `subagent/provider-added` (the frozen registry snapshot) fires after a registration and `subagent/provider-removed` (the accepted name) after an unregistration, so a consumer deriving state from a named provider (the model-facing tool wording) mirrors registry membership instead of assuming load order — the cordis Loader starts sibling plugins concurrently, so "listed earlier" does not mean "registered earlier". Run lifecycle is gated by provider readiness: the service captures the provider handle's public fields once, `subagent/start` (payload `SubagentRunInfo`) fires only after the accepted `started` promise fulfills, and `subagent/end` (payload `SubagentRunEndInfo`) uses the same accepted id and normalized result; readiness rejection emits neither. For spawn/fork, the start listener can resolve the published child via `ctx.agents.get(info.id)`; a remote provider need not have a local registry entry. Both events are **observe-only** plain emits whose service-owned payloads are deeply frozen before per-listener dispatch. The service observes the normalized `result` immediately even while readiness is pending and buffers that end payload until start has fired; a malformed provider result rejects the returned result promise and becomes contained `error` telemetry, a rejection cannot become an unhandled detached promise, start always precedes end, and one listener cannot corrupt either the caller or later listeners. `subagent/end` carries the same frozen output as `lastAssistantMessage` on a valid settle path and omits it on infrastructure or result-contract failure. Any run-affecting decision is out of scope for this observe-only surface. ## Scope (first cut) -The consumer collects **synchronously**: it starts a run and awaits `result`. Steering (`sendMessage`) is part of the contract but intentionally unused. Background / poll / spill semantics are deferred to a future redesign unifying long-running-tool handling across subagents and bash. See the RFC: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md). +The consumer collects **synchronously**: it starts a run and awaits `result`. Steering (`sendMessage`) is part of the contract but intentionally unused. Background, poll, and spill semantics are outside this seam; long-running-tool handling is shared work across subagents and bash. See the RFC: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md). See `src/types.ts` for the full contracts. diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index eb0dbf8da0..cf1ebf9485 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -25,6 +25,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.6" }, @@ -32,6 +33,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.6" } diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index e1f4fac594..de376686fd 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -34,10 +34,11 @@ import { Context, Service } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' -import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools' +import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools' import type { Scoped } from '@deepseek-ai/dsh-scope' -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { Agent, AgentId } from '@deepseek-ai/dsh-agent' import type { SubagentCapabilities, @@ -115,7 +116,7 @@ declare module 'cordis' { } } -/** Identifying detail for a started subagent run (the `subagent/start` payload). */ +/** Deep-frozen, observe-only identifying detail for a started subagent run. */ export interface SubagentRunInfo { /** The provider that started the run. */ provider: string @@ -123,7 +124,7 @@ export interface SubagentRunInfo { id: AgentId } -/** Outcome detail for a settled subagent run (the `subagent/end` payload). */ +/** Deep-frozen, observe-only outcome detail for a settled subagent run. */ export interface SubagentRunEndInfo { /** The provider that ran it. */ provider: string @@ -186,11 +187,12 @@ export class SubagentService extends Service { // mutate or reuse the provider object before its old fiber unloads. Binding // preserves the provider method's receiver while making replacement of the // public callback field after registration inert. + const inputCapabilities = provider.capabilities const capabilities: SubagentCapabilities = Object.freeze({ - outputSchema: provider.capabilities.outputSchema, - depthLimit: provider.capabilities.depthLimit, - toolFilter: provider.capabilities.toolFilter, - persona: provider.capabilities.persona, + outputSchema: inputCapabilities.outputSchema, + depthLimit: inputCapabilities.depthLimit, + toolFilter: inputCapabilities.toolFilter, + persona: inputCapabilities.persona, }) const snapshot: SubagentProvider = Object.freeze({ name: provider.name, @@ -244,10 +246,16 @@ export class SubagentService extends Service { /** * Start a subagent run on the named provider. Resolves the provider (throws - * `NO_PROVIDER` if absent), validates every requested START-TIME capability + * `NO_PROVIDER` if absent), reads the caller request once into a coherent + * acceptance snapshot, validates every requested START-TIME capability * against {@link SubagentProvider.capabilities} (throws `UNSUPPORTED_CAPABILITY` * for the first unmet one — fail loud, before any child is created), then - * delegates to {@link SubagentProvider.start}, then emits `subagent/start` / + * validates the request's scalar values, materializes model-bound data in one + * lossless-JSON traversal, and delegates the detached request to + * {@link SubagentProvider.start}. The returned handle is a service-owned, + * frozen wrapper: provider fields are captured once, methods stay bound to the + * provider handle, and `result` resolves to one detached, deeply frozen value + * shared by the caller and lifecycle telemetry. Emits `subagent/start` / * `subagent/end` only after the run's readiness boundary fulfills. A provider * that fails before establishing a child emits neither event. * @param name - the provider to run on. @@ -255,32 +263,94 @@ export class SubagentService extends Service { * @returns the live run (its `result` resolves when the child settles). */ start(name: string, request: SubagentStartRequest): SubagentRun { - // Parent is the lifecycle scope identity accepted at start. Never reread it - // from the caller-owned request after the provider/result async boundary, - // or start/end could be dispatched into different agent scopes. - const parent = request.parent const provider = this.providers.get(name) if (!provider) { throw new SubagentError(`no subagent provider registered for "${name}"`, 'NO_PROVIDER') } - this.assertCapabilities(provider, request) - if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema) + // Read every top-level field exactly once before capability checks or + // detachment. A stateful accessor must not look absent to validation and then + // appear in the provider request (or vice versa). + const input = this.snapshotStartRequest(request) + const parent = input.parent + this.assertCapabilities(provider, input) + if (input.maxDepth !== undefined && ( + !Number.isSafeInteger(input.maxDepth) + || input.maxDepth < 0 + || Object.is(input.maxDepth, -0) + )) { + throw new TypeError('subagent maxDepth must be a non-negative safe integer') + } + if (input.persona !== undefined && typeof input.persona !== 'string') { + throw new TypeError('subagent persona must be a string') + } + // Model/session-bound values are validated and detached in a single + // recursive pass. A check followed by structuredClone would reread getters + // and could erase an exotic prototype returned only to the clone. + const prompt = snapshotJsonValue(input.prompt) + if (prompt === undefined) { + throw new TypeError('subagent prompt must be losslessly JSON-serializable') + } + const outputSchema = input.outputSchema === undefined + ? undefined + : snapshotJsonValue(input.outputSchema) + if (input.outputSchema !== undefined && outputSchema === undefined) { + throw new OutputSchemaError(['schema annotation must be JSON data; the complete schema must be losslessly JSON-serializable']) + } + if (outputSchema !== undefined) assertSupportedOutputSchema(outputSchema) + const agentOptions = input.agentOptions === undefined + ? undefined + : snapshotJsonValue(input.agentOptions) + if (input.agentOptions !== undefined && agentOptions === undefined) { + throw new TypeError('subagent agent options must be losslessly JSON-serializable') + } + const toolFilter = input.toolFilter === undefined + ? undefined + : snapshotJsonValue(input.toolFilter) + if (input.toolFilter !== undefined && toolFilter === undefined) { + throw new TypeError('subagent tool filter must be losslessly JSON-serializable') + } // Detach every data field before crossing into a provider. Parent/signal // are live identity capabilities and stay exact; the mutable request record // and its arrays/objects are never retained, so every backend (including an // async out-of-process one) observes the request accepted at start. const accepted: SubagentStartRequest = { - prompt: structuredClone(request.prompt), + prompt, parent, - ...request.signal !== undefined ? { signal: request.signal } : {}, - ...request.agentOptions !== undefined ? { agentOptions: structuredClone(request.agentOptions) } : {}, - ...request.outputSchema !== undefined ? { outputSchema: structuredClone(request.outputSchema) } : {}, - ...request.maxDepth !== undefined ? { maxDepth: request.maxDepth } : {}, - ...request.toolFilter !== undefined ? { toolFilter: structuredClone(request.toolFilter) } : {}, - ...request.persona !== undefined ? { persona: request.persona } : {}, + ...input.signal !== undefined ? { signal: input.signal } : {}, + ...agentOptions !== undefined ? { agentOptions } : {}, + ...outputSchema !== undefined ? { outputSchema } : {}, + ...input.maxDepth !== undefined ? { maxDepth: input.maxDepth } : {}, + ...toolFilter !== undefined ? { toolFilter } : {}, + ...input.persona !== undefined ? { persona: input.persona } : {}, } - const run = provider.start(accepted) + const providerRun = provider.start(accepted) + // Provider-owned run objects can be accessor-backed too. Capture every + // public field exactly once, bind methods to the provider's original handle, + // and expose only this service-owned wrapper. The normalized result promise + // is also the one lifecycle telemetry observes, so the caller and observers + // cannot receive different values from stateful accessors. + const id = providerRun.id + const started = providerRun.started + const providerResult = providerRun.result + const cancel = providerRun.cancel.bind(providerRun) + const sendMessage = providerRun.sendMessage?.bind(providerRun) + const dispose = providerRun.dispose.bind(providerRun) + const resume = providerRun.resume?.bind(providerRun) + const result = providerResult.then(value => this.snapshotRunResult(value)) + const run: SubagentRun = Object.freeze({ + id, + started, + result, + cancel, + dispose, + ...sendMessage === undefined + ? {} + : { sendMessage }, + ...resume === undefined + ? {} + : { resume }, + }) // Observe result settlement IMMEDIATELY, before waiting on readiness. A // provider may fail both promises in the same turn; deferring the rejection @@ -296,26 +366,16 @@ export class SubagentService extends Service { // remains observable by the run's consumer, but telemetry must not claim // that a child started. } - void run.result.then( - (result) => { - // Snapshot before the caller's own `await run.result` continuation. Even - // when readiness is still pending, buffering the clone rather than the - // caller-owned result keeps the eventual observe-only event immutable - // with respect to consumer mutation. - let lastAssistantMessage: SubagentResult['output'] | undefined - try { - lastAssistantMessage = structuredClone(result.output) - } catch (error: unknown) { - this.ctx.logger.warn(`subagent: could not clone ${name} output for subagent/end: ${String(error)}`) - } + void result.then( + (value) => { deliverEnd({ provider: name, - id: run.id, - stopReason: result.stopReason, - ...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {}, + id, + stopReason: value.stopReason, + lastAssistantMessage: value.output, }) }, - () => { deliverEnd({ provider: name, id: run.id, stopReason: 'error' }) }, + () => { deliverEnd({ provider: name, id, stopReason: 'error' }) }, ) // Readiness is the publication boundary owned by the provider. For @@ -324,10 +384,10 @@ export class SubagentService extends Service { // per-listener containment, then flush an outcome that settled unusually // early. A readiness rejection is handled here and deliberately emits no // false start/end pair; the result path above remains independently handled. - void run.started.then( + void started.then( () => { readiness = 'started' - this.emitLifecycle('subagent/start', { provider: name, id: run.id }, parent) + this.emitLifecycle('subagent/start', { provider: name, id }, parent) if (pendingEnd !== undefined) { const info = pendingEnd pendingEnd = undefined @@ -342,6 +402,54 @@ export class SubagentService extends Service { return run } + /** Normalize one provider result into the immutable seam value. */ + private snapshotRunResult(value: SubagentResult): SubagentResult { + // Capture every provider-owned field once before validation. In particular, + // lifecycle telemetry must not reread accessors after the caller receives + // the result and observe a different terminal outcome. + const output = value.output + const structured = value.structured + const stopReason = value.stopReason + if (!Array.isArray(output)) { + throw new TypeError('subagent result output must be an array') + } + if (typeof stopReason !== 'string') { + throw new TypeError('subagent result stopReason must be a string') + } + const accepted: SubagentResult = { + output, + ...structured === undefined ? {} : { structured }, + stopReason, + } + const snapshot = snapshotJsonValue(accepted) + if (snapshot === undefined) { + throw new TypeError('subagent result must be losslessly JSON-serializable') + } + return deepFreeze(snapshot) + } + + /** Read one coherent caller request into immutable data properties. */ + private snapshotStartRequest(request: SubagentStartRequest): Readonly { + const prompt = request.prompt + const parent = request.parent + const signal = request.signal + const agentOptions = request.agentOptions + const outputSchema = request.outputSchema + const maxDepth = request.maxDepth + const toolFilter = request.toolFilter + const persona = request.persona + return Object.freeze({ + prompt, + parent, + ...signal !== undefined ? { signal } : {}, + ...agentOptions !== undefined ? { agentOptions } : {}, + ...outputSchema !== undefined ? { outputSchema } : {}, + ...maxDepth !== undefined ? { maxDepth } : {}, + ...toolFilter !== undefined ? { toolFilter } : {}, + ...persona !== undefined ? { persona } : {}, + }) + } + /** * Emit a `subagent/*` lifecycle event with PER-LISTENER containment: dispatch * each subscriber individually and log (never propagate) a thrown one, so one @@ -374,14 +482,15 @@ export class SubagentService extends Service { // parent-scoped listener observes only its own delegations); the // provider-removed registry notification stays unfiltered. The carrier is // args[0] of the dispatch call, exactly as cordis' own emit spells it. + const acceptedInfo = typeof info === 'string' ? info : deepFreeze(info) const dispatchArgs: unknown[] = parent === undefined - ? [name, info] - : [scopeTarget(this, parent), name, info] + ? [name, acceptedInfo] + : [scopeTarget(this, parent), name, acceptedInfo] for (const callback of this.ctx.events.dispatch('emit', dispatchArgs)) { try { - callback(info) + callback(acceptedInfo) } catch (error: unknown) { - this.ctx.logger.warn(`subagent: ${name} listener threw: ${String(error)}`) + this.ctx.logger.warn(`subagent: ${name} listener threw: ${renderThrown(error)}`) } } } @@ -409,4 +518,13 @@ export class SubagentService extends Service { } } +/** Render an arbitrary thrown value without allowing coercion to throw again. */ +function renderThrown(value: unknown): string { + try { + return value instanceof Error ? `${value.name}: ${value.message}` : String(value) + } catch { + return '' + } +} + export default SubagentService diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 7b9e828c37..ba4b58c2fc 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -173,6 +173,16 @@ describe('SubagentService', () => { persona: true, } const provider = new StubProvider('stable', capabilities) + let capabilityReads = 0 + let capabilityValue = capabilities + Object.defineProperty(provider, 'capabilities', { + configurable: true, + get: () => { + capabilityReads += 1 + return capabilityValue + }, + set: (value: SubagentCapabilities) => { capabilityValue = value }, + }) const added: SubagentProvider[] = [] const removed: string[] = [] ctx.on('subagent/provider-added', registered => void added.push(registered)) @@ -184,6 +194,7 @@ describe('SubagentService', () => { pluginCtx.subagents.registerProvider(provider) }, }) + expect(capabilityReads).toBe(1) const accepted = ctx.subagents.getProvider('stable') const mutable = provider as unknown as { @@ -216,7 +227,10 @@ describe('SubagentService', () => { expect(ctx.subagents.list()).toEqual(['stable']) expect(ctx.subagents.getProvider('mutated')).toBeUndefined() + const controller = new AbortController() const run = ctx.subagents.start('stable', baseRequest({ + signal: controller.signal, + agentOptions: { model: 'mock' }, outputSchema: { type: 'object', properties: { answer: { type: 'string' } } }, maxDepth: 2, toolFilter: { deny: ['bash'] }, @@ -277,6 +291,142 @@ describe('SubagentService', () => { ctx.subagents.start('strong', baseRequest({ outputSchema: { type: 'object', properties: { x: { type: 'string' } } }, maxDepth: 1 })) expect(provider.startCount).toBe(1) }) + + it.each([ + { label: 'NaN', value: Number.NaN }, + { label: 'a fraction', value: 1.5 }, + { label: 'a negative integer', value: -1 }, + { label: 'negative zero', value: -0 }, + ])('rejects maxDepth=$label before the provider starts', async ({ value }) => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const provider = new StubProvider('invalid-depth', ALL_CAPS) + ctx.subagents.registerProvider(provider) + + expect(() => ctx.subagents.start('invalid-depth', baseRequest({ maxDepth: value }))) + .toThrow('subagent maxDepth must be a non-negative safe integer') + expect(provider.startCount).toBe(0) + }) + + it('rejects a non-string persona before the provider starts', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const provider = new StubProvider('invalid-persona', { ...ALL_CAPS, persona: true }) + ctx.subagents.registerProvider(provider) + + expect(() => ctx.subagents.start('invalid-persona', baseRequest({ + persona: 42 as unknown as string, + }))).toThrow('subagent persona must be a string') + expect(provider.startCount).toBe(0) + }) + + it('reads an optional capability accessor once so it cannot appear after validation', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + let accepted: SubagentStartRequest | undefined + const provider: SubagentProvider = { + name: 'weak-getter', + capabilities: NO_CAPS, + inheritsParentContext: false, + start: (request) => { + accepted = request + return { + id: AgentId('weak-getter-child'), + started: Promise.resolve(), + result: Promise.resolve({ output: [], stopReason: 'completed' }), + cancel() {}, + async dispose() {}, + } + }, + } + ctx.subagents.registerProvider(provider) + let reads = 0 + const request = baseRequest() + Object.defineProperty(request, 'toolFilter', { + enumerable: true, + get: () => { + reads += 1 + return reads === 1 ? undefined : { deny: ['bash'] } + }, + }) + + ctx.subagents.start('weak-getter', request) + + expect(reads).toBe(1) + expect(accepted?.toolFilter).toBeUndefined() + }) + }) + + it('rejects an exotic public prompt before the provider starts', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const provider = new StubProvider('prompt-boundary') + ctx.subagents.registerProvider(provider) + class ExoticTextBlock { + readonly type = 'text' + readonly text = 'hello' + } + + expect(() => ctx.subagents.start('prompt-boundary', baseRequest({ + prompt: [new ExoticTextBlock()] as unknown as SubagentStartRequest['prompt'], + }))).toThrow('subagent prompt must be losslessly JSON-serializable') + expect(provider.startCount).toBe(0) + }) + + it.each([ + { + label: 'agent options', + overrides: { agentOptions: { model: Number.NaN as unknown as string } }, + message: 'subagent agent options must be losslessly JSON-serializable', + }, + { + label: 'tool filter', + overrides: { toolFilter: { deny: [Number.NaN as unknown as string] } }, + message: 'subagent tool filter must be losslessly JSON-serializable', + }, + { + label: 'output schema', + overrides: { + outputSchema: { + type: 'object', + properties: { answer: { type: Number.NaN } }, + } as unknown as NonNullable, + }, + message: 'schema annotation must be JSON data', + }, + ])('rejects non-JSON $label before the provider starts', async ({ overrides, message }) => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const provider = new StubProvider('invalid-request-data', ALL_CAPS) + ctx.subagents.registerProvider(provider) + + expect(() => ctx.subagents.start('invalid-request-data', baseRequest(overrides))) + .toThrow(message) + expect(provider.startCount).toBe(0) + }) + + it('reads each nested prompt value once into the provider snapshot', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const provider = new StubProvider('unstable-prompt') + ctx.subagents.registerProvider(provider) + let reads = 0 + const block = Object.defineProperties({}, { + type: { enumerable: true, value: 'text' }, + text: { + enumerable: true, + get: () => { + reads += 1 + return reads === 1 ? 'hello' : new Map([['not', 'json']]) + }, + }, + }) + + expect(() => ctx.subagents.start('unstable-prompt', baseRequest({ + prompt: [block] as unknown as SubagentStartRequest['prompt'], + }))).not.toThrow() + expect(reads).toBe(1) + expect(provider.startCount).toBe(1) }) it('emits subagent/start then subagent/end around a run', async () => { @@ -299,6 +449,165 @@ describe('SubagentService', () => { expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id, stopReason: 'completed' })) }) + it('captures a provider run once and gives callers and telemetry one normalized result', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const reads = { + id: 0, + started: 0, + result: 0, + cancel: 0, + sendMessage: 0, + dispose: 0, + resume: 0, + output: 0, + structured: 0, + stopReason: 0, + } + const methodReceivers: string[] = [] + const providerResult = Object.defineProperties({}, { + output: { + enumerable: true, + get: () => { + reads.output += 1 + return reads.output === 1 + ? [{ type: 'text', text: 'accepted output' }] + : [{ type: 'text', text: 'drifted output' }] + }, + }, + structured: { + enumerable: true, + get: () => { + reads.structured += 1 + return { verdict: reads.structured === 1 ? 'accepted' : 'drifted' } + }, + }, + stopReason: { + enumerable: true, + get: () => { + reads.stopReason += 1 + return reads.stopReason === 1 ? 'completed' : 'error' + }, + }, + }) as SubagentResult + const providerRun = Object.defineProperties({}, { + id: { + enumerable: true, + get: () => { + reads.id += 1 + return AgentId(reads.id === 1 ? 'accepted-child' : 'drifted-child') + }, + }, + started: { + enumerable: true, + get: () => { + reads.started += 1 + if (reads.started !== 1) throw new Error('started reread') + return Promise.resolve() + }, + }, + result: { + enumerable: true, + get: () => { + reads.result += 1 + if (reads.result !== 1) throw new Error('result reread') + return Promise.resolve(providerResult) + }, + }, + cancel: { + enumerable: true, + get: () => { + reads.cancel += 1 + if (reads.cancel !== 1) throw new Error('cancel reread') + return function (this: SubagentRun): void { + expect(this).toBe(providerRun) + methodReceivers.push('cancel') + } + }, + }, + sendMessage: { + enumerable: true, + get: () => { + reads.sendMessage += 1 + if (reads.sendMessage !== 1) throw new Error('sendMessage reread') + return function (this: SubagentRun): void { + expect(this).toBe(providerRun) + methodReceivers.push('sendMessage') + } + }, + }, + dispose: { + enumerable: true, + get: () => { + reads.dispose += 1 + if (reads.dispose !== 1) throw new Error('dispose reread') + return async function (this: SubagentRun): Promise { + expect(this).toBe(providerRun) + methodReceivers.push('dispose') + } + }, + }, + resume: { + enumerable: true, + get: () => { + reads.resume += 1 + if (reads.resume !== 1) throw new Error('resume reread') + return function (this: SubagentRun): SubagentRun { + expect(this).toBe(providerRun) + methodReceivers.push('resume') + return providerRun + } + }, + }, + }) as SubagentRun + ctx.subagents.registerProvider({ + name: 'stateful-run', + capabilities: NO_CAPS, + inheritsParentContext: false, + start: () => providerRun, + }) + const ended = vi.fn() + ctx.on('subagent/end', ended) + + const run = ctx.subagents.start('stateful-run', baseRequest()) + expect(Object.is(run, providerRun)).toBe(false) + expect(Object.isFrozen(run)).toBe(true) + run.cancel() + run.sendMessage?.([]) + expect(Object.is(run.resume?.([]), providerRun)).toBe(true) + await run.dispose() + const result = await run.result + await run.started + await Promise.resolve() + + expect(reads).toEqual({ + id: 1, + started: 1, + result: 1, + cancel: 1, + sendMessage: 1, + dispose: 1, + resume: 1, + output: 1, + structured: 1, + stopReason: 1, + }) + expect(methodReceivers).toEqual(['cancel', 'sendMessage', 'resume', 'dispose']) + expect(result).toEqual({ + output: [{ type: 'text', text: 'accepted output' }], + structured: { verdict: 'accepted' }, + stopReason: 'completed', + }) + expect(Object.isFrozen(result)).toBe(true) + expect(Object.isFrozen(result.output)).toBe(true) + expect(ended).toHaveBeenCalledWith({ + provider: 'stateful-run', + id: 'accepted-child', + stopReason: 'completed', + lastAssistantMessage: [{ type: 'text', text: 'accepted output' }], + }) + }) + it('waits for provider readiness and observes an early result rejection without reordering lifecycle', async () => { const ctx = new Context() await ctx.plugin(SubagentService) @@ -428,12 +737,13 @@ describe('SubagentService', () => { })) }) - it('observe-only: a subagent/end listener mutating lastAssistantMessage cannot corrupt the caller\'s result', async () => { + it('observe-only: a mutating subagent/end listener cannot corrupt the caller or later listeners', async () => { // The subagent/end emit fires from a detached `.then` registered before // start() returns — i.e. BEFORE the caller's own `await run.result` // continuation. If the event shared the result.output reference, a mutating - // listener would change the SubagentResult the caller consumes. The service - // deep-clones output onto the event, so the listener mutates only its copy. + // listener would change the SubagentResult the caller consumes or the value + // a later observer sees. The service freezes one normalized result and the + // lifecycle payload before dispatching either public surface. const ctx = new Context() await ctx.plugin(SubagentService) ctx.subagents.registerProvider(new StubProvider( @@ -448,12 +758,22 @@ describe('SubagentService', () => { if (blocks?.[0]?.type === 'text') blocks[0].text = 'HIJACKED' blocks?.push({ type: 'text', text: 'injected' }) }) + const later = vi.fn() + ctx.on('subagent/end', later) const run = ctx.subagents.start('clone', baseRequest()) const result = await run.result await Promise.resolve() // let the detached settle hook (and its listener) run - // The caller's result.output is untouched by the listener's mutation. + // The caller and the listener after the mutator both retain the accepted value. expect(result.output).toEqual([{ type: 'text', text: 'original' }]) + expect(Object.isFrozen(result.output)).toBe(true) + expect(later).toHaveBeenCalledWith(expect.objectContaining({ + stopReason: 'completed', + lastAssistantMessage: [{ type: 'text', text: 'original' }], + })) + const laterInfo = later.mock.calls[0]![0] as Record + expect(Object.isFrozen(laterInfo)).toBe(true) + expect(Object.isFrozen(laterInfo.lastAssistantMessage)).toBe(true) }) it('omits lastAssistantMessage on the reject path (no SubagentResult was produced)', async () => { @@ -483,17 +803,14 @@ describe('SubagentService', () => { expect('lastAssistantMessage' in endInfo).toBe(false) // no output exists on reject }) - it('contains a structuredClone failure: emits subagent/end without lastAssistantMessage (no unhandled rejection)', async () => { - // The clone runs inside onFulfilled, OUTSIDE emitLifecycle's per-listener - // containment. An uncloneable output (here a content block carrying a - // function) would otherwise throw and become an unhandled rejection on the - // detached `.then`. The handler must instead log and emit the event WITHOUT - // lastAssistantMessage, still carrying the real stopReason. + it('rejects an invalid provider output and maps the contract fault to error telemetry', async () => { + // A function is outside the lossless JSON vocabulary. The service-owned + // result promise rejects instead of exposing the malformed provider value; + // its already-attached lifecycle observer maps that infrastructure fault to + // error telemetry without producing an unhandled rejection. const ctx = new Context() await ctx.plugin(SubagentService) - const warn = vi.fn(); ctx.logger.warn = warn as never - // An output value structuredClone cannot handle (a function is uncloneable). - const uncloneable = [{ type: 'text', text: 'x', evil: () => 0 }] as unknown as SubagentResult['output'] + const nonJsonOutput = [{ type: 'text', text: 'x', evil: () => 0 }] as unknown as SubagentResult['output'] ctx.subagents.registerProvider({ name: 'unclone', capabilities: NO_CAPS, @@ -501,7 +818,7 @@ describe('SubagentService', () => { start: () => ({ id: AgentId('unclone-child'), started: Promise.resolve(), - result: Promise.resolve({ output: uncloneable, stopReason: 'completed' } as SubagentResult), + result: Promise.resolve({ output: nonJsonOutput, stopReason: 'completed' } as SubagentResult), cancel() {}, dispose: async () => {}, }), @@ -510,13 +827,52 @@ describe('SubagentService', () => { const ended = vi.fn() ctx.on('subagent/end', ended) const run = ctx.subagents.start('unclone', baseRequest()) - await run.result + await expect(run.result).rejects.toThrow('subagent result must be losslessly JSON-serializable') await Promise.resolve() const endInfo = ended.mock.calls[0]![0] as Record - expect(endInfo.stopReason).toBe('completed') // the real outcome is preserved - expect('lastAssistantMessage' in endInfo).toBe(false) // clone failed → omitted, not crashed - expect(warn).toHaveBeenCalledWith(expect.stringContaining('could not clone')) + expect(endInfo.stopReason).toBe('error') + expect('lastAssistantMessage' in endInfo).toBe(false) + }) + + it.each([ + { + label: 'a non-array output', + value: { output: { type: 'text', text: 'not an array' }, stopReason: 'completed' }, + message: 'subagent result output must be an array', + }, + { + label: 'a non-string stopReason', + value: { output: [], stopReason: 42 }, + message: 'subagent result stopReason must be a string', + }, + ])('rejects a provider result with $label', async ({ value, message }) => { + const ctx = new Context() + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'invalid-shape', + capabilities: NO_CAPS, + inheritsParentContext: false, + start: () => ({ + id: AgentId('invalid-shape-child'), + started: Promise.resolve(), + result: Promise.resolve(value as unknown as SubagentResult), + cancel() {}, + async dispose() {}, + }), + }) + const ended = vi.fn() + ctx.on('subagent/end', ended) + + const run = ctx.subagents.start('invalid-shape', baseRequest()) + await expect(run.result).rejects.toThrow(message) + await Promise.resolve() + + expect(ended).toHaveBeenCalledWith(expect.objectContaining({ + provider: 'invalid-shape', + id: 'invalid-shape-child', + stopReason: 'error', + })) }) it('emits subagent/end with stopReason "error" when the run result promise rejects', async () => { @@ -566,6 +922,60 @@ describe('SubagentService', () => { await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) }) + it('contains a listener whose thrown value cannot be stringified', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider(new StubProvider('hostile-listener')) + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + const hostile = { + [Symbol.toPrimitive]() { throw new Error('render failed') }, + } + const second = vi.fn() + ctx.on('subagent/start', () => { throw hostile }) + ctx.on('subagent/start', second) + + const run = ctx.subagents.start('hostile-listener', baseRequest()) + await run.started + + expect(second).toHaveBeenCalledOnce() + expect(warnings.some(message => message.includes(''))).toBe(true) + await run.result + }) + + it('rejects a throwing provider result accessor and maps it to error telemetry', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'hostile-result', + capabilities: NO_CAPS, + inheritsParentContext: false, + start: () => ({ + id: AgentId('hostile-result-child'), + started: Promise.resolve(), + result: Promise.resolve({ + output: [], + get stopReason(): 'completed' { throw new Error('stop reason exploded') }, + }), + cancel() {}, + async dispose() {}, + }), + }) + const ended = vi.fn() + ctx.on('subagent/end', ended) + + const run = ctx.subagents.start('hostile-result', baseRequest()) + await run.started + await expect(run.result).rejects.toThrow('stop reason exploded') + await Promise.resolve() + + expect(ended).toHaveBeenCalledWith(expect.objectContaining({ + provider: 'hostile-result', + id: 'hostile-result-child', + stopReason: 'error', + })) + }) + it('contains a throwing subagent/end listener per-listener: a later listener still observes the settle, no unhandled rejection', async () => { const ctx = new Context() await ctx.plugin(SubagentService) diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index f93f929241..3d3c9be6a9 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../core/agent" }, + { + "path": "../../core/session" + }, { "path": "../../llm/llm" }, diff --git a/packages/support/README.md b/packages/support/README.md index 2a08063bad..c8883a89ff 100644 --- a/packages/support/README.md +++ b/packages/support/README.md @@ -5,7 +5,7 @@ Packages that exist to serve development, testing, and the examples rather than | Package | Role | ctx key | |---|---|---| | `acp-snapshot/` | ACP snapshot suite kit: subprocess scenario harness + golden normalizers + the `defineAcpSnapshotSuite` factory | (library — imported by example `*.snapshot.ts` suites) | -| `invariants/` | Dev-mode event-contract invariants + session-log freeze | (listens on `session/*`, `agent/*`) | +| `invariants/` | Dev-mode event-contract assertions | (listens on `session/*`, `agent/*`) | | `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | | `subagent-mock/` | Scripted `SubagentProvider` for deterministic seam/tool tests | (registers on `ctx.subagents`) | diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 94c2682f9d..4f50ec42f3 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -1,9 +1,11 @@ # dsh-invariants -Dev-mode event-contract invariants and session-log freeze. A pure-listener plugin (everything is a plugin) that asserts the harness event contract at runtime and, optionally, freezes logged session-event data so any code that mutates history throws instead of corrupting silently. +Dev-mode event-contract assertions. This pure-listener plugin checks relationships among session events, agent states, scoped dispatches, and model requests at runtime; it does not own or change product behavior. **Off in production.** Enable it in tests and the demos, where a contract violation should fail loudly. It costs nothing when not registered, and doubles as executable documentation of the event taxonomy — the assertions *are* the contract. +Session itself owns immutable log storage in every composition: it takes one lossless JSON snapshot of each accepted event, deep-freezes that record, and exposes the log through immutable array snapshots. The invariants plugin checks the cross-record and cross-seam rules that storage immutability cannot express. + ## Plugin A functional plugin — register the module namespace (this is what loading by name in `cordis.yml` does): @@ -14,17 +16,10 @@ import * as Invariants from '@deepseek-ai/dsh-invariants' declare const ctx: Context -await ctx.plugin(Invariants) // freeze on (default) -await ctx.plugin(Invariants, { freeze: false }) // assert contract, don't freeze +await ctx.plugin(Invariants) ``` -`inject`: `['sessions']` — it reads `ctx.sessions.list()` at apply time to rebuild trace state for sessions that already exist (so a hot reload mid-turn doesn't falsely reject the next event). It listens on `session/created`, `session/event`, and `agent/status`. - -### Config - -| Key | Default | Meaning | -|---|---|---| -| `freeze` | `true` | Deep-freeze each logged event's data so mutating a logged event throws. Set `false` to assert the contract without freezing. | +`inject`: `['sessions']` — it reads `ctx.sessions.list()` at apply time to rebuild trace state for sessions that already exist, so a hot reload mid-turn does not falsely reject the next event. It registers only listeners and has no configuration. ## Invariants asserted @@ -46,10 +41,10 @@ Model requests (on `llm/stream`): On any violation it throws `InvariantError` (`code: 'INVARIANT'`). -## Why runtime, not deep-readonly types +## Why runtime assertions remain useful -A `DeepReadonly` is high type-noise across every log consumer, and a plugin can cast straight through it. A dev-mode freeze plus these assertions catch real corruption at zero production cost and zero type noise. The always-on half of that defense — cloning derived messages so request/adapter mutation can't reach back into the log — lives in `dsh-session`'s `deriveMessages`. This package is the dev-mode tripwire. See [dev-mode invariants](../../../docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md). +Session enforces the per-record storage boundary at runtime, where a cast cannot bypass it. Pervasive `DeepReadonly` types would add noise across consumers without expressing relationships such as turn/step nesting, subject-correct scoped dispatch, or equality between a request and its log reconstruction. This plugin checks those relationships in development while `dsh-session` keeps history immutable in every composition. See [source-owned session immutability and dev-mode invariants](../../../docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md). ## Seeded sessions -A seeded/forked session arrives with events already in its log (the `Session` constructor copies the seed without emitting `session/event`). On `session/created` the plugin replays the existing log through the checker and freezes those entries, so seeded history is held to the same contract. +A seeded or forked session arrives with events already in its log because construction does not emit `session/event` for each seed record. `Session` validates, snapshots, and freezes every seed record before accepting it; on `session/created`, this plugin replays the accepted log only to rebuild and check its relational trace state. diff --git a/packages/support/invariants/package.json b/packages/support/invariants/package.json index 4ea36f66de..ba95eea315 100644 --- a/packages/support/invariants/package.json +++ b/packages/support/invariants/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-invariants", - "description": "Dev-mode event-contract invariants + session-log freeze for the DeepSeek Harness", + "description": "Dev-mode event-contract assertions for the DeepSeek Harness", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index ee431a7f39..153d97a45c 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -1,20 +1,18 @@ /** - * Dev-mode invariants: a pure-listener plugin that asserts the harness event - * contract at runtime, and (optionally) freezes logged session-event data so - * any code that mutates history throws instead of corrupting silently. + * Dev-mode invariants: a pure-listener plugin that asserts relationships in + * the harness event contract at runtime. * * Everything is a plugin — this is just listeners on `session/created`, - * `session/event`, and `agent/status`. It is **off in production**: enable it - * in tests and the demos, where a contract violation should be a loud failure, - * not a subtle one. It doubles as executable documentation of the event - * taxonomy: the assertions below ARE the contract. + * `session/event`, `agent/status`, and the scoped dispatch and request seams. + * It is **off in production**: enable it in tests and demos, where a contract + * violation should be a loud failure rather than a subtle one. It doubles as + * executable documentation of the event taxonomy: the assertions below are + * the contract. * - * Why runtime assertions instead of compile-time deep-readonly types? See - * the dev-invariants RFC. Briefly: a `DeepReadonly` is high type-noise across - * every log consumer and a plugin casts straight through it; a dev-mode freeze - * + assertions catch real corruption at zero production cost and zero type - * noise. The always-on half of that defense (cloning derived messages) lives - * in dsh-session; this package is the dev-mode tripwire. + * Session owns immutable log storage: it snapshots and deep-freezes every + * accepted event at the source. This plugin checks relationships that one + * event's types and immutability cannot express, including turn/step nesting, + * scoped dispatch, status transitions, and request reconstructability. * * @module @deepseek-ai/dsh-invariants */ @@ -44,16 +42,6 @@ export class InvariantError extends HarnessError { } } -/** 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 -} - /** Per-session bookkeeping for the session-log invariants. */ interface SessionTrace { /** Highest `seq` seen so far (must strictly increase). */ @@ -87,29 +75,6 @@ interface AgentSubject { agent: Agent } -/** - * Deep-freeze a value and everything reachable from it. - * - * Walks every object's own properties even when the object itself is already - * frozen: `Session.append()` accepts event data from arbitrary plugins/tools, - * so a caller can hand us a SHALLOW-frozen object whose descendants are still - * mutable. Skipping an already-frozen node (the obvious idempotence shortcut) - * would leave exactly the kind of mutable history the dev-invariants RFC means to catch. A - * `WeakSet` of visited objects keeps it terminating on cycles and avoids - * re-walking shared subtrees / already-processed seed events. - */ -function deepFreeze(value: unknown, seen: WeakSet = new WeakSet()): void { - if (value === null || typeof value !== 'object') return - if (seen.has(value)) return - seen.add(value) - // Freeze the node (no-op if a caller pre-froze it), then ALWAYS descend — - // a frozen container can still hold mutable children. - Object.freeze(value) - for (const key of Object.keys(value)) { - deepFreeze((value as Record)[key], seen) - } -} - /** Assert that a step-scoped event names the currently open turn and step. */ function requireOpenStep(trace: SessionTrace, kind: string, turn: number, step: number): void { if (trace.openTurn !== turn || trace.openStep !== step) { @@ -311,13 +276,13 @@ function checkTransition(from: AgentStatus | undefined, to: AgentStatus): void { /** * Register the dev-mode invariants. Contributions are effect-scoped, so - * disposing the plugin fiber removes all listeners and stops freezing - * (HMR-safe). On (re-)apply the trace state is rebuilt by replaying each - * existing session's log, so a hot reload mid-turn does not falsely reject the - * next event. + * disposing the plugin fiber removes all listeners (HMR-safe). On (re-)apply + * the trace state is rebuilt by replaying each existing session's log, so a + * hot reload mid-turn does not falsely reject the next event. + * + * @param ctx - Cordis context that receives the invariant listeners. */ -export function apply(ctx: Context, config: Config = {}): void { - const freeze = config.freeze ?? true +export function apply(ctx: Context): void { const traces = new WeakMap() // Agent status has no stored history to replay; the first observation after // (re-)apply seeds the baseline, so a reload never produces a false positive. @@ -334,13 +299,12 @@ export function apply(ctx: Context, config: Config = {}): void { surface: [], }) - /** Build (or rebuild) a session's trace by replaying its whole log; freeze it. */ + /** Build (or rebuild) a session's trace by replaying its whole log. */ const seedSession = (session: Session): SessionTrace => { const trace = freshTrace() traces.set(session, trace) for (const event of session.events) { checkEvent(trace, event) - if (freeze) deepFreeze(event) } return trace } @@ -362,7 +326,6 @@ export function apply(ctx: Context, config: Config = {}): void { ctx.on('session/event', (session, event) => { checkEvent(traceFor(session), event) - if (freeze) deepFreeze(event) }) ctx.on('agent/status', (agent, status) => { diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index ad3792b302..44a87abf8c 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -8,10 +8,10 @@ import * as Invariants from '@deepseek-ai/dsh-invariants' import { InvariantError } from '@deepseek-ai/dsh-invariants' /** A Context with the session store and the invariants plugin registered. */ -async function setup(config?: { freeze?: boolean }) { +async function setup() { const ctx = new Context() await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(Invariants, config ?? {}) + const fiber = await ctx.plugin(Invariants) return { ctx, fiber } } @@ -22,7 +22,7 @@ function mockAgent(id: string): Agent { describe('session-log invariants', () => { it('accepts a well-formed turn/step/tool sequence', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() expect(() => { session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -38,7 +38,7 @@ describe('session-log invariants', () => { }) it('rejects a non-monotonic seq (replay spine)', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() // Session.append enforces seq-contiguity at the source, so drive the // invariants seq check directly via session/event with a regressing seq. @@ -48,7 +48,7 @@ describe('session-log invariants', () => { }) it('rejects a turn/start while another turn is open', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })) @@ -56,7 +56,7 @@ describe('session-log invariants', () => { }) it('rejects a turn/end that does not match the open turn', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) expect(() => session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })) @@ -64,14 +64,14 @@ describe('session-log invariants', () => { }) it('rejects a step/start outside its declared turn', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) expect(() => session.append('step/start', { turn: 2, step: 1 })).toThrow(/open turn is 1/) }) it('rejects a step/end that does not match the open step', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn: 1, step: 1 }) @@ -79,7 +79,7 @@ describe('session-log invariants', () => { }) it('rejects an assistant/chunk outside an open step', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) expect(() => session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } })) @@ -87,7 +87,7 @@ describe('session-log invariants', () => { }) it('rejects a message event appended outside any open turn (turn-enclosure)', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() // No turn open: every message-bearing event must be turn-enclosed (the turn-enclosure RFC). expect(() => session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })) @@ -97,7 +97,7 @@ describe('session-log invariants', () => { }) it('rejects steering and plugin-added events appended outside any open turn', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() // steering/message is turn-scoped: outside a turn it would land past the // commit boundary and be dropped on resume (the turn-enclosure RFC). @@ -113,7 +113,7 @@ describe('session-log invariants', () => { }) it('accepts message events once a turn is open', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) expect(() => session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })) @@ -121,7 +121,7 @@ describe('session-log invariants', () => { }) it('rejects a tool/result with no prior tool/call', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn: 1, step: 1 }) @@ -130,7 +130,7 @@ describe('session-log invariants', () => { }) it('allows a synthetic interrupted tool/result from crash repair without a prior tool/call event', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() expect(() => { session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -152,7 +152,7 @@ describe('session-log invariants', () => { }) it('allows a tool/call with no matching tool/result (thrown waterfall ends the step)', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() expect(() => { session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -164,7 +164,7 @@ describe('session-log invariants', () => { }) it('holds seeded sessions to the contract on session/created', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() // A seq-contiguous, serializable seed (so it passes Session's constructor // validation) that nonetheless violates turn nesting — a second turn/start // while the first turn is still open — must be rejected by the invariants @@ -177,7 +177,7 @@ describe('session-log invariants', () => { }) it('tracks turns per session independently', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const a = ctx.sessions.create(SessionId('a')) const b = ctx.sessions.create(SessionId('b')) a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -186,7 +186,7 @@ describe('session-log invariants', () => { }) it('accepts multiple steps in a turn and consecutive turns', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() expect(() => { session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -203,7 +203,7 @@ describe('session-log invariants', () => { }) it('rejects a skipped turn number', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -212,7 +212,7 @@ describe('session-log invariants', () => { }) it('rejects a skipped step number within a turn', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn: 1, step: 1 }) @@ -222,7 +222,7 @@ describe('session-log invariants', () => { }) it('rejects a turn/end while a step is still open', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn: 1, step: 1 }) @@ -231,7 +231,7 @@ describe('session-log invariants', () => { }) it('rejects a step/start while a step is still open', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn: 1, step: 1 }) @@ -239,7 +239,7 @@ describe('session-log invariants', () => { }) it('rejects a tool/result satisfying a call from a previous step', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn: 1, step: 1 }) @@ -252,7 +252,7 @@ describe('session-log invariants', () => { }) it('rejects an assistant/message naming the wrong step', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn: 1, step: 1 }) @@ -266,7 +266,7 @@ describe('HMR state rebuild', () => { const ctx = new Context() await ctx.plugin(SessionStore) // First registration, mid-turn: a turn is open when the plugin reloads. - const first = await ctx.plugin(Invariants, { freeze: false }) + const first = await ctx.plugin(Invariants) const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn: 1, step: 1 }) @@ -274,7 +274,7 @@ describe('HMR state rebuild', () => { // Re-apply (HMR): the fresh fiber must replay the existing log so the open // step is known — the next chunk must NOT be a false positive. - await ctx.plugin(Invariants, { freeze: false }) + await ctx.plugin(Invariants) expect(() => session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })) .not.toThrow() // And a genuine violation is still caught after the rebuild. @@ -283,76 +283,49 @@ describe('HMR state rebuild', () => { }) }) -describe('dev-freeze', () => { - it('freezes appended event data so mutating a logged event throws', async () => { - const { ctx } = await setup() // freeze defaults true - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) +describe('session immutability', () => { + it('always freezes appended event data without the invariants plugin', () => { + const session = new Session(SessionId('appended')) const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) expect(Object.isFrozen(event)).toBe(true) expect(Object.isFrozen(event.data)).toBe(true) expect(Object.isFrozen(event.data.content)).toBe(true) + expect(Object.isFrozen(event.data.content[0])).toBe(true) + expect(Object.isFrozen(session.events)).toBe(true) expect(() => { (event.data.content[0] as { text: string }).text = 'HACKED' }).toThrow() }) - it('does not freeze when freeze:false', async () => { - const { ctx } = await setup({ freeze: false }) - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - expect(Object.isFrozen(event)).toBe(false) - }) - - it('freezes seeded events on session/created', async () => { - const { ctx } = await setup() + it('always freezes seeded events without the invariants plugin', () => { const seed = [ { type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, { type: 'user/message' as const, seq: 1, time: 0, data: { content: [{ type: 'text' as const, text: 'seeded' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const }, ] - const session = ctx.sessions.create(undefined, { seed }) + const session = new Session(SessionId('seeded'), seed) + expect(Object.isFrozen(seed[0])).toBe(false) + expect(Object.isFrozen(session.events)).toBe(true) expect(Object.isFrozen(session.events[0])).toBe(true) + expect(Object.isFrozen(session.events[0]?.data)).toBe(true) + expect(Object.isFrozen(session.events[1]?.data)).toBe(true) }) - it('freezes mutable descendants of a shallow-frozen event datum', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - // A caller hands in a SHALLOW-frozen block whose nested array is still - // mutable. deepFreeze must descend into the already-frozen object and - // freeze the descendant, not short-circuit on the frozen container — - // otherwise dev-mode misses exactly the history mutation the dev-invariants RFC catches. - // `append` snapshots `data`, so the freeze applies to the LOGGED clone, not - // the caller's input — read the event back and assert on its data. + it('snapshots and freezes descendants of a shallow-frozen caller value', () => { + const session = new Session(SessionId('shallow-frozen')) const innerContent: { type: 'text'; text: string }[] = [{ type: 'text', text: 'inner' }] const block = Object.freeze({ type: 'tool-result' as const, toolCallId: CallId('c1'), content: innerContent, isError: false }) const event = session.append('user/message', { content: [block], source: { kind: 'user' } }, { surfaceOp: 'append' }) const logged = event.data.content[0] as { content: { type: 'text'; text: string }[] } + expect(Object.isFrozen(innerContent)).toBe(false) expect(Object.isFrozen(logged.content)).toBe(true) expect(Object.isFrozen(logged.content[0])).toBe(true) + innerContent[0]!.text = 'caller mutation' + expect(logged.content[0]!.text).toBe('inner') expect(() => { logged.content.push({ type: 'text', text: 'mutation' }) }).toThrow() }) - - it('terminates on a cyclic event datum (WeakSet guard)', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - // The deep-freeze WeakSet guard must terminate on a self-referential - // structure rather than recursing forever. Session.append now rejects - // non-serializable (incl. cyclic) data at the source, so drive the freeze - // handler directly via hand-built session/events — exactly the shape the - // invariants listener receives. Open a turn first (seq 0) so the cyclic - // user/message (seq 1) satisfies the turn-enclosure invariant. - ctx.emit(scopeTarget(session, undefined), 'session/event', session, { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } as never) - const cyclic: Record = { type: 'text', text: 'x' } - cyclic['self'] = cyclic - const event = { type: 'user/message', seq: 1, time: 1, data: { content: [cyclic], source: { kind: 'user' } } } - expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, event as never) }).not.toThrow() - expect(Object.isFrozen(cyclic)).toBe(true) - }) }) describe('agent status invariants', () => { it('accepts legal transitions: idle→running→idle and →disposed', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const agent = mockAgent('a1') expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') @@ -363,28 +336,28 @@ describe('agent status invariants', () => { }) it('accepts running→disposed', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const agent = mockAgent('a2') ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed') }).not.toThrow() }) it('rejects a no-op transition', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const agent = mockAgent('a3') ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') }).toThrow(/no-op transition/) }) it('rejects leaving the terminal disposed state', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const agent = mockAgent('a4') ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed') expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') }).toThrow(/left terminal state disposed/) }) it('tracks status per agent independently', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const a = mockAgent('a5') const b = mockAgent('b5') ctx.emit(scopeTarget(a, a), 'agent/status', a, 'running') @@ -401,10 +374,10 @@ describe('HMR safety', () => { await fiber.dispose() - // After disposal: no freezing, no assertions. An event that WOULD have - // violated the open-turn rule now passes silently, and is not frozen. + // After disposal the plugin's assertions are gone, so an event that would + // violate the open-turn rule passes. Session still owns immutability. const event = session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) - expect(Object.isFrozen(event)).toBe(false) + expect(Object.isFrozen(event)).toBe(true) // A no-op status transition no longer throws either. const agent = mockAgent('hmr') ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') @@ -419,16 +392,17 @@ describe('HMR safety', () => { expect(err.message).toBe('invariant violated: seq must strictly increase') }) - it('does not leak listeners across dispose (no stale freezing)', async () => { + it('does not leak listeners across dispose', async () => { const { ctx, fiber } = await setup() await fiber.dispose() const spy = vi.fn() ctx.on('session/event', spy) const session = ctx.sessions.create() session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - // our own spy fires, proving events still flow — but the plugin's frozen. + // The spy proves events still flow after plugin disposal. Session, not the + // disposed listener, freezes the accepted record. expect(spy).toHaveBeenCalledOnce() - expect(Object.isFrozen(session.events[0])).toBe(false) + expect(Object.isFrozen(session.events[0])).toBe(true) }) }) @@ -640,7 +614,7 @@ describe('surface invariants', () => { }) it('catches an incomplete-provenance replace on the load/seed path', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const badSeed = [ { type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } }, { type: 'step/start' as const, seq: 1, time: 0, data: { turn: 1, step: 1 } }, @@ -655,10 +629,10 @@ describe('surface invariants', () => { const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - // Type system prevents surface metadata on non-surface events; this test - // exercises the runtime guard against casts or persisted-data bypass. - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return - expect(() => (session.append as any)('turn/end', { turn: 1, reason: { kind: 'completed' } }, { sourceEventSeqs: [0] })) + // Session rejects this at its own acceptance boundary. Emit a hand-built + // record to cover the listener's defensive check for alternate producers. + const event = { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } }, sourceEventSeqs: [0] } + expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, event as never) }) .toThrow(/cannot carry sourceEventSeqs/) }) @@ -666,8 +640,8 @@ describe('surface invariants', () => { const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return - expect(() => (session.append as any)('turn/end', { turn: 1, reason: { kind: 'completed' } }, { surfaceOp: 'append' })) + const event = { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } }, surfaceOp: 'append' } + expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, event as never) }) .toThrow(/cannot carry surfaceOp/) }) }) @@ -675,7 +649,7 @@ describe('surface invariants', () => { describe('request-reconstruction cross-check (llm/stream)', () => { /** Session with a boundary: one derivable user message, an open step, and the header event the loop would have logged. */ async function requestSetup() { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create(SessionId('req-check')) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) @@ -737,7 +711,7 @@ describe('request-reconstruction cross-check (llm/stream)', () => { }) it('rejects a loop-built request with no header event or no step/start in its log', async () => { - const { ctx } = await setup({ freeze: false }) + const { ctx } = await setup() const session = ctx.sessions.create(SessionId('req-bare')) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) const bare = Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: session.id }) @@ -778,7 +752,7 @@ describe('request cross-check ordering (prepend)', () => { const ctx = new Context() await ctx.plugin(SessionStore) ctx.on('llm/stream', () => (async function* () {})() as never) // short-circuits, no next() - await ctx.plugin(Invariants, { freeze: false }) + await ctx.plugin(Invariants) const session = ctx.sessions.create(SessionId('prepend-check')) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) diff --git a/packages/support/subagent-mock/tests/subagent-mock.spec.ts b/packages/support/subagent-mock/tests/subagent-mock.spec.ts index ddd725da4b..8a96cc4141 100644 --- a/packages/support/subagent-mock/tests/subagent-mock.spec.ts +++ b/packages/support/subagent-mock/tests/subagent-mock.spec.ts @@ -57,7 +57,8 @@ describe('dsh-subagent-mock', () => { // the structured path is only reachable when the cap is on; with it off and // no schema requested, the result has no structured field. const run = ctx.subagents.start('mock', baseRequest()) - await expect(run.result).resolves.toMatchObject({ structured: undefined }) + const result = await run.result + expect(result).not.toHaveProperty('structured') }) it('honors a configured stop reason', async () => { diff --git a/packages/workflow/workflow-workerthread/README.md b/packages/workflow/workflow-workerthread/README.md index 86d386350a..d7cd9ed64c 100644 --- a/packages/workflow/workflow-workerthread/README.md +++ b/packages/workflow/workflow-workerthread/README.md @@ -35,9 +35,9 @@ Values LEAVING the script (hook options/schemas, the script's return) are materi ## Cancellation, death, disposal -Per-run limits: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` posts the cancel to the worker (its hooks start throwing `CANCELLED`; the script dies at its next await) and cancels every host-side child NOW on **both seam channels** — the shared request signal aborts AND each registered child's explicit `cancel()` is called host-side, because the seam leaves a provider free to honor either channel and a worker wedged in a synchronous spin could not relay its own per-child cancel RPCs (those later land as idempotent no-ops). The grace then arms: a run still unsettled `disposeGraceMs` later force-settles `cancelled` and the worker is **terminated**. A cancellation that lands before the body runs (the ready→go handshake) reports `cancelled` without executing anything; a worker `result` racing an in-flight host cancellation reports `cancelled` too (first-wins settlement — the seam-visible result had not settled when cancellation was requested); post-cancel `phase`/`log` narration is suppressed host-side, while cancelled children still deliver their paired `agent-end`. +Per-run limits: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` posts the cancel to the worker (its hooks start throwing `CANCELLED`; the script dies at its next await) and cancels every host-side child NOW on **both seam channels** — the shared request signal aborts AND each registered child's explicit `cancel()` is called host-side, because the seam leaves a provider free to honor either channel and a worker wedged in a synchronous spin could not relay its own per-child cancel RPCs (those later land as idempotent no-ops). Each provider-owned cancel callback is exception-contained independently, so a broken child cannot prevent peer cancellation or workflow settlement. The grace then arms: a run still unsettled `disposeGraceMs` later force-settles `cancelled` and the worker is **terminated**. A cancellation that lands before the body runs (the ready→go handshake) reports `cancelled` without executing anything; a worker `result` racing an in-flight host cancellation reports `cancelled` too (first-wins settlement — the seam-visible result had not settled when cancellation was requested); post-cancel `phase`/`log` narration is suppressed host-side, while cancelled children still deliver their paired `agent-end`. -A worker that dies unexpectedly (an OOM, a script reaching `process.exit` through the documented vm escape) settles the run `stopReason: 'error'` with the exit diagnostics — or `'cancelled'` when a cancel was in flight — and the host-side child registry is what winds every surviving child down. `dispose()` = cancel + immediate host-driven disposal of every registered child (a wedged worker can relay no dispose RPC, so child teardown overlaps the grace instead of starting after it; the worker's own dispose RPCs join the same per-child disposal) + bounded wait (result, then child-registry quiescence, capped by the grace) + unconditional `worker.terminate()`: the thread never outlives its run. Once a run settles, stray children a script fired without awaiting are cancelled too, and `dispose()` waits for their disposal (bounded by the grace) before returning. `agent-start`/`agent-end` pairing is host-guaranteed the same way: forwarded starts live in a ledger, worker-reported ends pair them on the graceful paths, and the termination paths (grace force-settle, worker death) synthesize the missing ends (outcome `cancelled`) before the run settles — a start still in flight across the force-settle can surface after `workflow/end`, immediately paired the same way. +A worker that dies unexpectedly (an OOM, a script reaching `process.exit` through the documented vm escape) settles the run `stopReason: 'error'` with the exit diagnostics — or `'cancelled'` when a cancel was in flight — and the host-side child registry is what winds every surviving child down. `dispose()` = cancel + immediate host-driven disposal of every registered child (a wedged worker can relay no dispose RPC, so child teardown overlaps the grace instead of starting after it; the worker's own dispose RPCs join the same per-child disposal) + bounded wait (result, then child-registry quiescence, capped by the grace) + unconditional `worker.terminate()`: the thread never outlives its run. Before an ordinary run settlement becomes observable, the host cancels every stray child on both channels too—even a fire-and-forget run still waiting on `started`, for which the worker has no handle yet—and `dispose()` then waits for their disposal (bounded by the grace) before returning. `agent-start`/`agent-end` pairing is host-guaranteed the same way: forwarded starts live in a ledger, worker-reported ends pair them on the graceful paths, and the termination paths (grace force-settle, worker death) synthesize the missing ends (outcome `cancelled`) before the run settles — a start still in flight across the force-settle can surface after `workflow/end`, immediately paired the same way. **Engine-specific limitations**: worker startup is paid per run; on a termination path `agentsStarted` reports the HOST-observed count (accepted `child-start`s — calls still queued worker-side for a concurrency slot are unknowable then); and a returned promise or thenable resolves per JavaScript semantics BEFORE materialization — that is what makes an un-awaited `return agent('x')` work — with the value-boundary guard applying to the resolution. diff --git a/packages/workflow/workflow-workerthread/package.json b/packages/workflow/workflow-workerthread/package.json index f86c74c2f7..ed934cd0cc 100644 --- a/packages/workflow/workflow-workerthread/package.json +++ b/packages/workflow/workflow-workerthread/package.json @@ -30,6 +30,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-workflow": "^0.0.1", diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index fff9d8496f..b1d92ac1d0 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -15,10 +15,13 @@ * terminated — the real kill an in-process engine could not perform). * * Children live in a host-side registry (callId → run) as soon as the provider - * accepts them, so cancellation reaches even a pre-publication attempt. The - * host observes `result` immediately but acknowledges the child to the worker - * only after `started` fulfills; readiness failure is a start error and the - * host disposes the attempt because the worker never received a handle. The + * accepts them, so cancellation reaches even a pre-publication attempt. Both + * explicit run cancellation and the shared request signal are driven when the + * workflow is cancelled OR normally settles, so a fire-and-forget child cannot + * survive merely by honoring only one channel. The host observes `result` + * immediately but acknowledges the child to the worker only after `started` + * fulfills; readiness failure is a start error and the host disposes the + * attempt because the worker never received a handle. The * worker drives disposal by RPC on the graceful path, `dispose()` host-drives * every registered child's disposal immediately (a wedged worker can relay no * dispose RPC, and child teardown must overlap the grace, not start after it), @@ -44,6 +47,7 @@ import type { WorkerOptions } from 'node:worker_threads' import type { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { assertNever } from '@deepseek-ai/dsh-llm' +import { snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { SubagentRun } from '@deepseek-ai/dsh-subagent' import type { WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowMeta, WorkflowResult, WorkflowRun, WorkflowRunId } from '@deepseek-ai/dsh-workflow' import { renderThrown } from './realm.ts' @@ -180,11 +184,10 @@ export class WorkerRun implements WorkflowRun { if (this.settled || this.cancelReason !== undefined) return this.cancelReason = reason ?? 'workflow cancelled' this.post(HostToWorkerType.Cancel, { reason: this.cancelReason }) - this.controller.abort(this.cancelReason) // The explicit channel is driven host-side, not left to the worker: a // provider honoring only run.cancel() must not wait on a wedged worker's // ChildCancel relay (those later RPCs land as idempotent no-ops). - for (const run of this.children.values()) run.cancel(this.cancelReason) + this.cancelChildren(this.cancelReason) this.graceTimer = setTimeout(() => { // The worker may no longer speak (it is about to be terminated): pair // every stranded start before the run settles, so ends precede @@ -274,7 +277,10 @@ export class WorkerRun implements WorkflowRun { this.onChildStart(message.callId, message.request) break case WorkerToHostType.ChildCancel: - this.children.get(message.callId)?.cancel(message.reason) + { + const run = this.children.get(message.callId) + if (run !== undefined) this.cancelChild(run, message.reason) + } break case WorkerToHostType.ChildDispose: this.onChildDispose(message.callId) @@ -322,11 +328,19 @@ export class WorkerRun implements WorkflowRun { const forwardResult = run.result.then<() => void, () => void>( (result) => { try { - const snapshot: ChildResult = structuredClone({ - output: result.output, - ...result.structured !== undefined ? { structured: result.structured } : {}, - stopReason: result.stopReason, + // Capture every provider-owned field once, then materialize the + // worker-bound value in one lossless traversal. A stateful accessor + // cannot validate one result and send another, and an exotic value is + // rejected before any prototype-erasing clone. + const output = result.output + const structured = result.structured + const stopReason = result.stopReason + const snapshot = snapshotJsonValue({ + output, + ...structured !== undefined ? { structured } : {}, + stopReason, }) + if (snapshot === undefined) throw new TypeError('child result is not losslessly JSON-serializable') return () => { this.post(HostToWorkerType.ChildSettled, { callId, result: snapshot }) } } catch (error: unknown) { const rendered = `workflow child result could not cross the worker boundary: ${renderThrown(error)}` @@ -416,18 +430,34 @@ export class WorkerRun implements WorkflowRun { /** Abort + dispose every registered child (worker death / final teardown); disposal is contained, not awaited. */ private reapChildren(reason: string): void { - this.controller.abort(this.cancelReason ?? reason) + const cancellation = this.cancelReason ?? reason + this.cancelChildren(cancellation) for (const [callId, run] of [...this.children]) { - run.cancel(this.cancelReason ?? reason) void this.disposeChild(callId, run) } } + /** Drive both cancellation channels for every child already accepted by the host. */ + private cancelChildren(reason: string): void { + this.controller.abort(reason) + for (const run of this.children.values()) this.cancelChild(run, reason) + } + + /** Contain one provider-owned cancel callback so every peer still receives cancellation. */ + private cancelChild(run: SubagentRun, reason?: string): void { + try { + run.cancel(reason) + } catch (error: unknown) { + this.ctx.logger.warn(`workflow-workerthread: child cancel failed: ${renderThrown(error)}`) + } + } + private onResult(result: WorkflowResult): void { - // The worker's settle-reap already child-cancel()s every stray; this - // abort fires the seam signal too, for providers that only honor the - // request signal (both channels, on every path). - if (this.cancelReason === undefined) this.controller.abort('workflow settled') + // The worker cancels handles it already received, but a fire-and-forget + // child may still be waiting on readiness and therefore have no worker + // handle. Drive BOTH provider-permitted channels from the host before the + // workflow becomes externally settled. + if (this.cancelReason === undefined) this.cancelChildren('workflow settled') if (this.cancelReason !== undefined && result.stopReason !== 'cancelled') { // The script settled while our cancel was crossing the thread boundary // — the seam-visible result had NOT settled when cancellation was diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 23d4de0f07..46a42e4036 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -366,15 +366,68 @@ describe('dsh-workflow-workerthread', () => { expect((result.value as { message: string }).message).toContain('backend exploded') }) - it('maps an uncloneable ready-child result to fatal AGENT_RESULT instead of wedging the bridge', async () => { + it('maps a non-JSON ready-child result to fatal AGENT_RESULT instead of wedging the bridge', async () => { const { ctx, parent } = await setup({ - reply: () => ({ output: [], structured: () => { /* deliberately not cloneable */ }, stopReason: 'completed' }), + reply: () => ({ output: [], structured: () => { /* deliberately outside lossless JSON */ }, stopReason: 'completed' }), }) const result = await run(ctx, parent, scripted(` try { await agent('p'); return 'unreachable' } catch (e) { return { code: e.code, message: e.message } } `)) expect(result.value).toMatchObject({ code: 'AGENT_RESULT' }) - expect((result.value as { message: string }).message).toContain('could not cross the worker boundary') + expect((result.value as { message: string }).message).toContain('subagent result must be losslessly JSON-serializable') + }) + + it('contains a non-JSON result even if the injected subagent service violates its normalization contract', async () => { + // SubagentService normally rejects this before the workflow sees it. Stub + // the injected seam itself so the host's defensive worker-boundary guard + // remains independently covered rather than becoming dead, untested code. + const { ctx, parent } = await setup() + const invalid = { + output: [], + structured: () => { /* deliberately outside lossless JSON */ }, + stopReason: 'completed', + } as unknown as SubagentResult + const start = vi.spyOn(ctx.subagents, 'start').mockReturnValue({ + id: AgentId('raw-invalid-child'), + started: Promise.resolve(), + result: Promise.resolve(invalid), + cancel: () => { /* already settled */ }, + dispose: () => Promise.resolve(), + }) + + const result = await run(ctx, parent, scripted(` + try { await agent('p'); return 'unreachable' } catch (e) { return { code: e.code, message: e.message } } + `)) + + expect(start).toHaveBeenCalledOnce() + expect(result.value).toMatchObject({ code: 'AGENT_RESULT' }) + expect((result.value as { message: string }).message) + .toContain('workflow child result could not cross the worker boundary') + }) + + it('reads each resolved child-result field once before crossing the worker boundary', async () => { + let structuredReads = 0 + class DriftedStructured { readonly value = 'drifted' } + const { ctx, parent } = await setup({ + reply: () => ({ + output: [], + get structured() { + structuredReads += 1 + return structuredReads === 1 ? { value: 'accepted' } : new DriftedStructured() + }, + stopReason: 'completed', + }), + }) + + const result = await run(ctx, parent, scripted(` + const found = await agent('p', { + schema: { type: 'object', properties: { value: { type: 'string' } }, required: ['value'] } + }) + return found.value + `)) + + expect(result.value).toBe('accepted') + expect(structuredReads).toBe(1) }) it('a child whose dispose() throws synchronously cannot wedge the script (the host acks anyway)', async () => { @@ -703,6 +756,81 @@ describe('dsh-workflow-workerthread', () => { await handle.dispose() }) + it('the settle-reap explicitly cancels a readiness-pending stray before workflow/end', async () => { + const { ctx, parent, provider } = await setup({ manual: true, deferStart: true }) + const childLifecycle: string[] = [] + let cancellationAtWorkflowEnd: string | undefined + ctx.on('workflow/agent-start', () => { childLifecycle.push('start') }) + ctx.on('workflow/agent-end', () => { childLifecycle.push('end') }) + ctx.on('workflow/end', () => { + cancellationAtWorkflowEnd = provider.runs[0]?.cancelled + }) + const handle = ctx.workflows.start({ + ...scripted(` + agent('readiness-pending stray') + return 'done' + `), + parent, + }) + + const result = await handle.result + + expect(result.stopReason).toBe('completed') + expect(provider.runs).toHaveLength(1) + expect(provider.runs[0]!.request.signal?.aborted).toBe(true) + expect(provider.runs[0]!.request.signal?.reason).toBe('workflow settled') + expect(provider.runs[0]!.cancelled).toBe('workflow settled') + expect(cancellationAtWorkflowEnd).toBe('workflow settled') + expect(childLifecycle).toEqual([]) + await handle.dispose() + expect(provider.runs[0]!.disposeCalls).toBe(1) + }) + + it('contains a throwing child cancel and still settles after cancelling peer strays', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + let starts = 0 + const cancelled: string[] = [] + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + const provider: SubagentProvider = { + name: 'throwing-cancel', + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, + inheritsParentContext: false, + start: () => { + const index = starts++ + return { + id: AgentId(`throwing-cancel-${index}`), + started: new Promise(() => { /* readiness stays pending */ }), + result: new Promise(() => { /* cancellation callback owns settlement */ }), + cancel: (reason?: string) => { + if (index === 0) throw new Error('cancel callback broke') + cancelled.push(`${index}:${reason ?? 'cancelled'}`) + }, + dispose: () => Promise.resolve(), + } + }, + } + ctx.subagents.registerProvider(provider) + await ctx.plugin(WorkerWorkflowEngine, { provider: 'throwing-cancel', maxConcurrentAgents: 2 }) + const handle = ctx.workflows.start({ + ...scripted(` + agent('first stray') + agent('second stray') + return 'done' + `), + parent: fakeParent(), + }) + + const result = await handle.result + + expect(result.stopReason).toBe('completed') + expect(starts).toBe(2) + expect(cancelled).toContain('1:workflow settled') + expect(warnings.some(message => message.includes('cancel callback broke'))).toBe(true) + await handle.dispose() + }) + it("cancel() drives each child's explicit cancel() host-side: a wedged worker cannot delay it", async () => { const ctx = new Context() await ctx.plugin(SubagentService) diff --git a/packages/workflow/workflow-workerthread/tsconfig.json b/packages/workflow/workflow-workerthread/tsconfig.json index 385651c192..730a3e61d9 100644 --- a/packages/workflow/workflow-workerthread/tsconfig.json +++ b/packages/workflow/workflow-workerthread/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../core/session" + }, { "path": "../../subagent/subagent" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68ec4e3183..575f2bbf9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,34 +75,6 @@ importers: specifier: ^4.1.8 version: 4.1.8(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - packages/ui/user-approval: - dependencies: - schemastery: - specifier: ^3.18.0 - version: 3.18.0 - devDependencies: - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-scope': - specifier: workspace:^ - version: link:../../core/scope - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - packages/bash/bash: devDependencies: '@deepseek-ai/dsh-brand': @@ -167,9 +139,6 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop - '@deepseek-ai/dsh-user-approval': - specifier: workspace:^ - version: link:../../ui/user-approval '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../bash @@ -197,6 +166,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../ui/user-approval cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) @@ -436,6 +408,9 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../session cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) @@ -449,9 +424,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../agent - '@deepseek-ai/dsh-user-approval': - specifier: workspace:^ - version: link:../../ui/user-approval '@deepseek-ai/dsh-code-runtime': specifier: workspace:^ version: link:../../code-runtime/code-runtime @@ -467,6 +439,9 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../system-prompt + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../ui/user-approval cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) @@ -849,6 +824,9 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -1177,9 +1155,6 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop - '@deepseek-ai/dsh-user-approval': - specifier: workspace:^ - version: link:../user-approval '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../bash/bash @@ -1228,6 +1203,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../user-approval '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../user-interaction @@ -1355,6 +1333,34 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/ui/user-approval: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/ui/user-interaction: devDependencies: '@deepseek-ai/dsh-agent': From a9cb70d89685a6e5fa6dc0fb093ec2ae7987747b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 12 Jul 2026 05:13:17 +0800 Subject: [PATCH 40/64] fix(scope): harden final ownership boundaries --- docs/config-catalog.md | 6 +- docs/cordis-catalog/events.md | 46 ++- docs/cordis-catalog/services.md | 16 +- docs/core-data-structures/core.md | 2 + docs/core-data-structures/scope.md | 31 ++ docs/core-data-structures/system-prompt.md | 37 +++ docs/event-producer-consumer.md | 33 +- ...-18-agent-lifecycle-and-ownership-seams.md | 4 +- .../2026-07-08-agent-scope-contexts.md | 123 ++++--- .../cordis/tool-cordis/src/api-catalog.ts | 20 +- packages/core/agent-loop/README.md | 4 +- packages/core/agent-loop/src/agent.ts | 84 +++-- packages/core/agent-loop/src/index.ts | 117 ++++--- packages/core/agent-loop/tests/agent.spec.ts | 51 ++- .../agent-loop/tests/scope-lifecycle.spec.ts | 56 ++++ packages/core/agent/README.md | 6 +- packages/core/agent/src/dispatch.ts | 33 +- packages/core/agent/src/index.ts | 153 ++++++++- packages/core/agent/src/types.ts | 5 +- packages/core/agent/tests/agent.spec.ts | 171 +++++++++- packages/core/scope/README.md | 2 +- packages/core/scope/src/index.ts | 156 +++++++-- packages/core/scope/tests/scope.spec.ts | 161 ++++++++- packages/core/session/README.md | 15 +- packages/core/session/src/index.ts | 246 ++++++++++++-- packages/core/session/tests/scoped.spec.ts | 17 + packages/core/session/tests/session.spec.ts | 183 +++++++++- packages/core/system-prompt/README.md | 8 +- packages/core/system-prompt/src/index.ts | 143 ++++++-- .../system-prompt/tests/system-prompt.spec.ts | 130 ++++++++ packages/core/tools/README.md | 4 +- packages/core/tools/src/index.ts | 33 +- packages/core/tools/tests/tools.spec.ts | 91 ++++- .../subagent/subagent-inprocess/README.md | 4 +- .../subagent/subagent-inprocess/src/index.ts | 57 ++-- .../tests/subagent-inprocess.spec.ts | 32 ++ packages/subagent/subagent-spawn/README.md | 2 +- .../tests/subagent-spawn.spec.ts | 31 +- packages/subagent/subagent/README.md | 8 +- packages/subagent/subagent/src/index.ts | 313 +++++++++++++----- .../subagent/subagent/tests/service.spec.ts | 306 ++++++++++++++++- packages/support/invariants/src/index.ts | 1 + packages/ui/acp/src/index.ts | 2 +- packages/ui/acp/tests/dispose.spec.ts | 8 +- packages/ui/user-approval/README.md | 4 +- packages/ui/user-approval/src/index.ts | 158 ++++++--- .../ui/user-approval/tests/approval.spec.ts | 161 +++++++++ .../workflow/workflow-workerthread/README.md | 2 +- .../workflow-workerthread/src/host.ts | 27 +- .../tests/workflow-workerthread.spec.ts | 35 ++ scripts/gen-doc-graphs.ts | 7 + scripts/type-equiv.manifest.json | 8 + 52 files changed, 2839 insertions(+), 514 deletions(-) create mode 100644 docs/core-data-structures/scope.md create mode 100644 docs/core-data-structures/system-prompt.md diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d7b7be2d88..0a1c0289ae 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -144,7 +144,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:44`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -790,7 +790,7 @@ export interface Config { } ``` -Source: [`packages/core/system-prompt/src/index.ts:265`](../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:315`](../packages/core/system-prompt/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -1001,7 +1001,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/ui/user-approval/src/index.ts:268`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:281`](../packages/ui/user-approval/src/index.ts) ## `@deepseek-ai/dsh-web` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index f5a0533842..cc450a782e 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -15,7 +15,7 @@ Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `n ### `agent/created` — emit -An agent's fully composed scoped world was published in the AgentRegistry. Its session is already live in the session store, but concrete factories may keep driving verbs locked until the subsequent `agent/session-start` boundary; that event is the first supported place to inject or queue work during startup. +An agent's fully composed scoped world was published in the AgentRegistry. Its session is already live in the session store, but concrete factories may keep driving verbs locked until the subsequent `agent/session-start` boundary; that event is the first supported place to inject or queue work during startup. A synchronous listener throw vetoes publication and rollback emits the matching disposal edges; returned-promise rejection is observed and logged but cannot retroactively veto this synchronous boundary. ```ts cordis-catalog 'agent/created'(this: Scoped, agent: Agent): void @@ -23,7 +23,7 @@ An agent's fully composed scoped world was published in the AgentRegistry. Its s Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:300`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:303`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -35,7 +35,7 @@ An agent was removed from the registry after its driver and any in-flight turn r Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:314`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:317`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -47,7 +47,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:587`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:590`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -61,7 +61,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:419`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:422`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -73,7 +73,7 @@ Waterfall: decide what happens to ONE drained queued message before it becomes a Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:437`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:440`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -85,7 +85,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:342`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:345`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -97,7 +97,7 @@ Waterfall: shape the step's call configuration — model switching, sampling ove Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:466`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:469`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -113,7 +113,7 @@ The seed is a frozen empty list; a contributing listener returns a NEW array — Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:518`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:521`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -125,7 +125,7 @@ The agent's session lifecycle began, fired once before its first turn. `source` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:362`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:365`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -137,7 +137,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:328`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:331`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -149,7 +149,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:533`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:536`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -161,7 +161,7 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:551`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:554`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -173,7 +173,7 @@ Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` wat Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:570`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:573`](../../packages/core/agent/src/types.ts) ## `approval/*` @@ -245,13 +245,23 @@ Source: [`packages/llm/llm/src/index.ts:39`](../../packages/llm/llm/src/index.ts ### `session/created` — emit -A session was created in the store. 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. +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. 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): void ``` -Source: [`packages/core/session/src/index.ts:47`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:50`](../../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): void +``` + +Source: [`packages/core/session/src/index.ts:62`](../../packages/core/session/src/index.ts) ### `session/event` — emit @@ -263,7 +273,7 @@ An event was appended to a session log (sync, fire-and-forget). This is the per- Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:61`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:76`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -273,7 +283,7 @@ Awaited durability checkpoint. The agent loop awaits `ctx.sessions.flush(session 'session/flush'(this: Scoped, session: Session): Promise | void ``` -Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:94`](../../packages/core/session/src/index.ts) ## `skill/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 903c368b7c..b7caa6f842 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -21,18 +21,19 @@ async createAgent(options: CreateAgentOptions): Promise async resume(options: ResumeAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:71`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:78`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package. Agent *creation* is provided by whichever plugin implements the AgentFactory (`@deepseek-ai/dsh-agent-loop`), registered via setFactory. ```ts cordis-catalog +reserve(id: AgentId): AgentRegistrationReservation setFactory(factory: AgentFactory): () => Promise | void async create(options: CreateAgentOptions): Promise async resume(options: ResumeAgentOptions): Promise register(agent: Agent): () => Promise | void -enter(agent: Agent): () => void +enter(agent: Agent, reservation?: AgentRegistrationReservation): () => void announce(agent: Agent): void get(id: AgentId): Agent | undefined list(): Agent[] @@ -40,7 +41,7 @@ list(): Agent[] Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:174`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:202`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` @@ -54,7 +55,7 @@ async request(req: ApprovalRequest): Promise Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) -Source: [`packages/ui/user-approval/src/index.ts:292`](../../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:305`](../../packages/ui/user-approval/src/index.ts) ## `ctx.bash` — `BashExecutor` (abstract seam) @@ -210,9 +211,10 @@ In-memory session store (`ctx.sessions`). Persistence is intentionally not implemented here — persistence plugins subscribe to `session/event` and flush on `session/flush` / dispose. ```ts cordis-catalog +reserve(id: SessionId): SessionRegistrationReservation create(id?: SessionId, options?: CreateSessionOptions): Session prepare(id?: SessionId, options?: CreateSessionOptions): Session -enter(session: Session): () => void +enter(session: Session, reservation?: SessionRegistrationReservation): () => void announce(session: Session): void async flush(session: Session): Promise get(id: SessionId): Session | undefined @@ -220,7 +222,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:608`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:663`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` @@ -260,7 +262,7 @@ protect(protection: PromptProtection): () => Promise | void async assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:380`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:430`](../../packages/core/system-prompt/src/index.ts) ## `ctx.tools` — `ToolRegistry` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index b601c2ae29..76ec27e744 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -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 | diff --git a/docs/core-data-structures/scope.md b/docs/core-data-structures/scope.md new file mode 100644 index 0000000000..d2a2fc47d4 --- /dev/null +++ b/docs/core-data-structures/scope.md @@ -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 RFC](../rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md) owns the design 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` is the compile-time brand on the proxy returned by `scopeTarget(base, key)`. Scope-filtered event declarations require this carrier as their `this` type, preventing an ordinary subject object from type-checking as the dispatch carrier. + +```ts type-equiv +type Scoped = T & { readonly [ScopedBrand]: 'dsh.scope.carrier' } +``` + +## 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 + dispose(): Promise +} +``` diff --git a/docs/core-data-structures/system-prompt.md b/docs/core-data-structures/system-prompt.md new file mode 100644 index 0000000000..0406ddb1a3 --- /dev/null +++ b/docs/core-data-structures/system-prompt.md @@ -0,0 +1,37 @@ +# 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. + +```ts type-equiv +interface ToolProviderResult { + schemas: ToolSchema[] + knownNames?: readonly string[] +} +``` + +## Canonical contribution protection + +`PromptProtection` names section and tool contributions whose canonical registry output remains authoritative after the assembly waterfall. Either field may be omitted, but a registration with no names is rejected. + +```ts type-equiv +interface PromptProtection { + sections?: readonly string[] + tools?: readonly string[] +} +``` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 5a0c547e70..7b26f43b12 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,27 +7,28 @@ 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:300`](../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:314`](../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:587`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:419`](../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:437`](../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:342`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:466`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:518`](../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:362`](../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:328`](../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:533`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:551`](../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:570`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`strictSerial (serial)`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:303`](../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:317`](../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:590`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:422`](../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:440`](../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:345`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:469`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:521`](../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:365`](../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:331`](../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:536`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:554`](../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:573`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`strictSerial (serial)`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:72`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `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:47`](../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:61`](../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:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../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:62`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../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:94`](../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:132`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | | `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:138`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:115`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | diff --git a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md index 2b1c198812..8e1acd1638 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md +++ b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md @@ -18,7 +18,7 @@ A new `cancel()` verb on the `Agent` interface — the single public stop primit `ctx.agents.create`/`resume` (and the `AgentFactory` interface) return `AgentHandle = { agent: Agent; dispose(): Promise }`. 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). -**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 detaching the session store's private append observer 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 the store-owned append observer's 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. - **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 diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md index 981603cb19..436a208be8 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -11,7 +11,7 @@ This is a composition problem, not an application-isolation problem. Starting a | Surface | What varies by agent | Failure when it is only global | |---|---|---| | Tools | Available capabilities, a child-only tool, or a scoped replacement for one implementation | The model receives excess authority, or a child-specific tool leaks into every prompt | -| Prompt state | Persona, instructions, variables, and Code Mode SDK declarations | Every agent receives the same instructions or runtime facts | +| Prompt state | Persona, instructions, variables, and [Code Mode](../feature/2026-06-15-code-mode.md) SDK declarations | Every agent receives the same instructions or runtime facts | | Live policy | Hooks, execution guards, result observers, and continuation rules | A listener intended for one agent can alter another agent's work | | Lifetime | Cleanup when the agent fails, is cancelled, is disposed, or loses its owner | Registrations outlive the agent or disappear before its final work settles | @@ -19,23 +19,30 @@ Two consistency requirements make the problem deeper than filtering a list. Firs Second, some rules are invariants rather than cooperative extensions. An ordinary middleware listener may replace a prompt assembly, turn an allow into a deny, rewrite a result, force another model step, or short-circuit listeners registered after it. Structured output therefore cannot rely on being “first” or “last” in an extensible listener chain; the owning service needs a final boundary for rules that later listeners must not undo. -The subagent API makes both needs concrete. Two concurrent children can request different personas, tool filters, and output schemas. Those requests are honest only when each child receives an independently owned view and when its terminal-output protocol survives unrelated plugins. +Third, accepting a value must transfer ownership of the exact value that was checked. TypeScript `readonly` annotations disappear at runtime, callers and providers may expose stateful accessors, and a validation pass followed by a clone reads mutable input twice. Identity fields, schemas, session data, requests, and results therefore need runtime boundaries that capture each caller-owned field once, materialize data once, and expose only owner-controlled snapshots. Otherwise the checked, executed, logged, and observed views can diverge even when scope resolution itself is correct. + +The subagent API makes these requirements concrete. Two concurrent children can request different personas, tool filters, and output schemas. Those requests are honest only when each child receives an independently owned view and when its terminal-output protocol survives unrelated plugins. ## Decision Each live agent owns a registration context named `agent.ctx`, and services expose narrow owner-final policy boundaries where ordinary middleware ordering is not strong enough. Together these choices make one agent's world composable with normal plugin APIs while keeping authority, observation, and cleanup aligned. -The design has three parts: +The design has four parts: | Part | Rule | Purpose | |---|---|---| | Registration scope | A registration through a plain plugin context is global; the same registration through `agent.ctx` belongs to that agent | Reuse existing APIs for per-agent tools, prompt state, and listeners | | Lifecycle transaction | Create and resume await scoped setup while the agent and session are unpublished, then publish them in an ordered rollback-covered sequence | No observer sees a partially composed agent, and every failure path owns cleanup | | Owner-final policy | Prompt protection, tool guards, final tool-result observation, and terminal turn stopping run at service-owned boundaries | Invariants do not depend on listener registration order | +| Boundary ownership | Services capture fixed fields once, materialize lossless-JSON data once, and publish owner-controlled views | Validation, execution, persistence, and telemetry cannot observe different values from one call | + +Three domain terms recur below. A **Session** is one agent run's append-only event log, from which model history and durable replay are derived. **Lossless JSON** means JSON primitives plus dense arrays and plain objects that can be copied without changing meaning; the boundary rejects sparse arrays, cycles, exotic prototypes, non-finite numbers, negative zero, `undefined`, `bigint`, functions, and symbols instead of coercing or erasing them. **Code Mode** presents the model with a generated software-development-kit interface and a reserved `run_code` transport, rather than advertising every end-capability as a native tool. + +Ownership stays with the component that can enforce each fact. The scope package owns scope tags and carrier construction; each registry owns acceptance snapshots and resolution; the agent factory owns identity reservation, setup, and publication; the session owns accepted history; the tool and subagent services own their pipeline records; and the workflow host owns cancellation of the runs it started. A caller never validates a value that another component later rereads from the caller's mutable object. The scope is flat. An agent resolves the deployment-global layer plus its own layer; a child does not inherit registrations from its parent's scope. Parent/child lineage remains explicit session data, and parent-owned disposal links lifetimes without silently inheriting authority. -The implementation lives primarily 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 Cordis event catalog](../../../cordis-catalog/events.md) is the exhaustive event-signature reference; this RFC explains why the contracts have their current shape. +The core implementation lives in [`dsh-scope`](../../../../packages/core/scope/README.md), [`dsh-agent`](../../../../packages/core/agent/README.md), [`dsh-agent-loop`](../../../../packages/core/agent-loop/README.md), [`dsh-session`](../../../../packages/core/session/README.md), [`dsh-system-prompt`](../../../../packages/core/system-prompt/README.md), and [`dsh-tools`](../../../../packages/core/tools/README.md). The composition example spans [`dsh-subagent`](../../../../packages/subagent/subagent/README.md), [`dsh-subagent-inprocess`](../../../../packages/subagent/subagent-inprocess/README.md), and [`dsh-workflow-workerthread`](../../../../packages/workflow/workflow-workerthread/README.md). The [generated Cordis event catalog](../../../cordis-catalog/events.md) is the exhaustive event-signature reference; this RFC explains why the contracts have their current shape. ## Background: the small Cordis vocabulary used here @@ -149,7 +156,11 @@ Calling a service through `agent.ctx` does not implicitly make every later read The tool view must not change because a caller kept the object it passed to `register()` or received a definition from `get()` or `visible()`. Registration therefore creates the stored identity once; future changes happen through explicit unregister/register effects. -Tool parameters cross the model and log boundary, so the registry materializes them with `snapshotJsonValue`: one recursive traversal reads each property once, rejects anything outside lossless JSON, and constructs the detached value that is actually stored. A check followed by `structuredClone` is not equivalent—a getter could return plain JSON to the check and a class instance to the clone, which would erase its prototype and silently accept different data. The first-party `defineTool()` helper closes the earlier authoring boundary with the same primitive: it reads every top-level option once, materializes the `SchemaSpec`, and derives an independent wire schema plus all later execute/presentation validation from that accepted snapshot. Without that split, mutating an author-owned spec after definition could make the model call a schema that the tool no longer accepts. Registration then reads every top-level definition field exactly once, validates, binds, and stores only those captured values; a stateful `parameters` or callback accessor therefore cannot make the checked definition differ from the executable one. It snapshots the scalar fields, binds each callback once to the original definition as its method receiver, and deep-freezes the stored record. Replacing `definition.execute` after registration therefore has no effect, while a callback can still deliberately read mutable state from its closure or original receiver. `get()` and `visible()` return the frozen stored definitions; `schemas()` returns detached schema projections. +Tool parameters cross the model and log boundary, so the registry materializes them with `snapshotJsonValue`: one recursive traversal reads each property once, rejects anything outside lossless JSON, and constructs the detached value that is actually stored. A check followed by `structuredClone` is not equivalent—a getter could return plain JSON to the check and a class instance to the clone, which would erase its prototype and silently accept different data. + +The first-party `defineTool()` helper closes the authoring boundary with the same primitive. It reads every top-level option once, materializes the `SchemaSpec`, and derives an independent wire schema plus all later execute and presentation validation from that accepted snapshot. Without that split, mutating an author-owned spec after definition could make the model call a schema that the tool no longer accepts. + +Registration then reads every top-level definition field exactly once, validates, binds, and stores only those captured values; a stateful `parameters` or callback accessor therefore cannot make the checked definition differ from the executable one. It snapshots the scalar fields, binds each callback once to the original definition as its method receiver, and deep-freezes the stored record. Replacing `definition.execute` after registration therefore has no effect, while a callback can still deliberately read mutable state from its closure or original receiver. `get()` and `visible()` return the frozen stored definitions; `schemas()` returns detached schema projections. ```text defineTool(options): @@ -208,7 +219,7 @@ The operation being described determines the key; callers cannot attach an unrel | `approval/request` | `ApprovalRequest.agent` | | `tools/pre-execute`, `tools/execute`, `tools/post-execute`, `tools/result` | `ToolExecution.agent`, or no key for an agent-less call | | `system-prompt/assemble` | `AssembleContext.scope` | -| `session/created`, `session/event`, `session/flush` | The owner scope captured when the session enters the store | +| `session/created`, `session/disposed`, `session/event`, `session/flush` | The owner scope captured when the session enters the store | | `subagent/start`, `subagent/end` | The delegating parent agent | Approval requests cross an asynchronous answer boundary, so the service snapshots the accepted record synchronously. It preserves the exact agent and abort-signal identities but copies the scalar fields, captures the agent's session once, and uses that one snapshot for `approval/asked`, scoped dispatch, cancellation, policy, and `approval/decided`. Mutating the caller-owned record after `request()` returns therefore cannot split the audit pair or redirect the question to another agent's listeners. @@ -234,7 +245,7 @@ The real helpers fuse values that must agree. `agentEvents(context, agent)` uses Function-style listeners receive the carrier as `this`, and agent event APIs allow them to call subject methods. The carrier is therefore a JavaScript proxy that reads and writes through to the real subject and binds methods to it. -Binding matters for classes with JavaScript private fields: a method called with the proxy itself as receiver would fail the runtime private-field identity check. The proxy preserves the subject's existing event filter and JavaScript object invariants, but it is intentionally not identity-equal to the subject; event arguments carry the real object whenever identity matters. +Binding matters for classes with JavaScript private fields: a method called with the proxy itself as receiver would fail the runtime private-field identity check. The carrier therefore uses a dedicated surrogate proxy target with its own immutable composed-filter slot, while ordinary property access, writes, own-key visibility, methods, invocation, and construction delegate to the real subject; callable carriers also preserve whether the subject is constructable. For non-overlay properties owned by the subject, descriptor queries preserve values and flags except that `configurable` is reported as `true`, which is the Proxy-safe way for an extensible surrogate to expose a property it does not itself own. A filter property pinned on the subject before, during, or after construction cannot trigger the proxy invariant that would otherwise force delivery to use the subject's raw filter and silently drop scope isolation. The carrier is intentionally not identity-equal to the subject; event arguments carry the real object whenever identity matters. `Scoped` is a TypeScript-only marker that requires this carrier at declared scoped dispatch sites. It improves authoring but adds no runtime security, so runtime marks and development invariants check the same contract for JavaScript, casts, and hand-written dispatches. @@ -246,11 +257,13 @@ An agent's scope, session, registry entry, and driver form one owned transaction Programmatic create and resume reserve both the agent ID and session ID before work that can await. Create prepares a fresh or seeded session; resume first loads and reconstructs the persisted session. Both paths then construct the agent, mint `agent.ctx`, and install the complete teardown skeleton before awaiting setup. -The factory captures IDs and the setup callback and clones caller-owned agent options before the first asynchronous boundary. Seed events and session metadata take a stricter route: pre-cloning either could erase a class or exotic prototype before the session validator saw it, so the factory reads each reference once and hands it synchronously to `SessionStore.prepare`. That boundary rejects exotic shells, reads each accepted metadata field once, and recursively validates and copies every seed value in one pass. One-pass materialization matters because `validate(value); structuredClone(value); validate(clone)` still reads a getter twice, and the clone can erase the prototype of a class instance returned only on the second read. The accepted metadata becomes a detached, deep-frozen `SessionHeader` whose id must equal the session id. Resume applies the same rule after persistence loading by capturing `createdAt`, `cwd`, `parentSession`, and `seedLength` once before reconstruction. A caller or stateful backend therefore cannot move the transaction away from the identities it reserved, change persistence routing or lineage after publication, or sanitize invalid data into acceptance. +The factory first captures the requested IDs, setup callback, and caller-owned agent options. Seed events and session metadata take a stricter route than a preliminary clone: cloning can erase an exotic prototype before validation sees it, so the factory reads each reference once and hands it synchronously to the session store's reservation-bound prepare operation. That boundary rejects exotic shells, reads accepted metadata fields once, and recursively materializes each seed record in one pass. Resume applies the same rule to persistence output by capturing the loaded header fields once before reconstruction. The transaction therefore cannot move to different identities, storage routing, or lineage after an asynchronous boundary. -The session log also closes the ownership boundary after acceptance. Seed and append paths share exact runtime surface-metadata checks: surface events require either `'append'` or an exact replace record with non-negative safe-integer bounds, provenance is an array of non-negative safe integers, and non-surface events reject both fields. Accepted events are deep-frozen, and `session.events` returns a cached frozen array snapshot rather than the mutable internal array. A later append invalidates the cache and publishes a new snapshot; any earlier snapshot remains unchanged. This preserves append-only behavior even for JavaScript callers that cast away TypeScript's readonly view or retain an event reference received from `append` or `session/event`. +Before setup can observe the new objects, their ownership-bearing public properties become stable runtime data slots rather than TypeScript-only `readonly` promises. The concrete agent pins its ID, accepted options, and session; the factory binds its scope context exactly once. The session pins its ID and detached, deep-frozen header. Registry detach closures likewise close over their accepted map keys instead of rereading public properties during teardown. A JavaScript assignment or stateful accessor therefore cannot split registry lookup, dispatch, persistence, and the driver into different identities. -Reservations prevent two concurrent transactions from composing different unpublished agents under the same public identity. They remain held across persistence loading and setup and are released on every success or failure path. +The session owns the accepted log as described in [the session-immutability RFC](2026-06-11-dev-invariants-over-deep-readonly.md). Seed and append paths materialize lossless JSON once, validate both the event envelope and the metadata that places message-producing events into derived model history, and deep-freeze the exact accepted event. `session.events` returns a frozen snapshot that never grows later. The store keeps append notification and scope-carrier state in store-owned private tables instead of caller-writable `Session` fields, so outside JavaScript cannot suppress or redirect `session/event` dispatch. + +Reservations prevent two concurrent factory transactions from composing different unpublished objects under the same public identities. Each reservation belongs both to the factory transaction and to the Cordis fiber that requested it: explicit release covers every success or failure path, while owner-fiber disposal is the backstop for an abandoned handle during plugin unload or HMR. The agent registry and session store recognize their own reserved keys: setup code that calls public reserve, prepare, create, register, or bare enter APIs with the same IDs fails. The session capability can prepare exactly one object, and publication succeeds only when both stores receive the factory-held exact capabilities; the session store additionally checks that the capability owns that exact prepared session. This closes the otherwise possible path in which setup publishes a substitute object under an ID that the factory merely tracked in a separate pending set, without letting a vanished owner wedge the ID forever. Resume installs an owner-liveness sentinel before reserving IDs or starting persistence I/O, then races loading against owner disposal. If disposal wins, resume rejects and releases both reservations immediately; a backend promise that settles later cannot publish. After a successful load, the factory synchronously installs the full agent lifecycle before removing the sentinel, so ownership passes from load to setup without an unobserved disposal gap. @@ -260,18 +273,18 @@ The sentinel exists only for the interval in which no agent lifecycle can exist resume(request): snapshot request ids, options, and setup callback sentinel = owner.effect(onDispose => signal ownerDisposed) - reserve(agentId, sessionId) + reservations = reserve agentId in AgentRegistry and sessionId in SessionStore try: persisted = await firstOf(persistence.load(sessionId), ownerDisposed) - session = reconstruct(persisted) + session = reservations.session.prepare(reconstruct persisted data) # This call installs the full lifecycle before its first await. - starting = startOwned(agentId, session, options, setup) + starting = startOwned(agentId, session, options, reservations, setup) disarm and dispose sentinel return await starting finally: - release both ids + release both reservation capabilities settle the sentinel transaction ``` @@ -326,8 +339,8 @@ The implementation keeps publication synchronous and leaves rollback to the surr ```text publish(world): - world.detachSession = world.agent.ctx.sessions.enter(world.session) - world.detachAgent = app.agents.enter(world.agent) + world.detachSession = world.agent.ctx.sessions.enter(world.session, world.sessionReservation) + world.detachAgent = app.agents.enter(world.agent, world.agentReservation) app.sessions.announce(world.session) app.agents.announce(world.agent) world.driver.enableDrivingVerbs() @@ -337,7 +350,11 @@ publish(world): Both registry entries exist before the first creation listener runs, and setup-installed listeners receive both announcements. Driving opens immediately before `agent/session-start`, so that event remains the first supported place for a listener to inject or queue startup work. -The sequence is not described as atomic because observers run between its steps. If a `session/created` or `agent/created` listener throws, the transaction rolls the registry entries and scope back, but effects already performed by an earlier listener cannot be retracted. An announced agent is paired with its disposal notification during rollback. `agent/session-start` is a non-vetoing notification: listener failures are logged and contained so the loop still starts. +The sequence is not described as atomic because observers run between its steps. If a `session/created` or `agent/created` listener throws synchronously, the transaction rolls the registry entries and scope back, but effects already performed by an earlier listener cannot be retracted. Each store therefore marks its announcement as begun before invoking creation listeners and rejects a repeat or reentrant announcement before dispatch. Rollback emits `session/disposed` or `agent/disposed` exactly once for every corresponding creation announcement that began, including a partial emit in which an early listener observed creation before a later listener threw. An object entered but never announced has no disposal notification because no observer was told it existed. + +Creation notification preserves that synchronous veto while also defending against JavaScript's asynchronous callback shape. A listener may return a promise even though the event type returns `void`; the dispatcher does not await it because publication has no asynchronous gap, but it observes and logs a later rejection. Such a rejection is too late to roll back, does not become unhandled, and does not starve the listeners invoked after that callback. + +The disposal notifications and `agent/session-start` are deliberately non-vetoing. Their dispatchers invoke every listener synchronously and independently; they log and contain both a synchronous throw and a rejection from a returned promise. Returned promises are observed for failure but not awaited, so an asynchronous notification listener cannot delay rollback or teardown, veto driver startup, or starve a later listener. ### Teardown stops work before revoking its world @@ -346,14 +363,16 @@ Every owner path uses the same reverse order: stop the loop and await its actual ```text disposeOwnedAgent(world): await world.stopDriver() # waits for loop exit and all agent-started flushes - world.detachAgent() # emits agent/disposed when announced - world.detachSession() + world.detachAgent() # leaves registry; emits agent/disposed if announced + world.detachSession() # stops event feed, leaves store; emits session/disposed if announced await world.scope.dispose() ``` The actual Cordis generator yields these disposers in reverse so its last-in-first-out teardown executes in the order shown. -`agent/disposed` means the driver is quiescent and the agent has left the registry; session detachment and scope unwind may still be completing after that notification. `AgentHandle.dispose()` is memoized so concurrent owners await the same full transaction, and `Scope.dispose()` provides the corresponding shared boundary for direct scope disposal and raw-disposer races. +`agent/disposed` means the driver is quiescent and the agent has left the registry; the session is still live during that notification. `session/disposed` follows after append notification has been detached and the session has left its store. The scope is still live when each disposal listener is selected and invoked, although returned asynchronous work is observed rather than awaited. Both notifications use the same scope key and delivery rule as their creation partners and occur exactly once only when those creation announcements began. + +`AgentHandle.dispose()` is memoized so concurrent owners await the same full transaction, and `Scope.dispose()` provides the corresponding shared boundary for direct scope disposal and raw-disposer races. Parent-owned subagents use explicit ownership rather than capability inheritance. The driver creates one run-owner fiber under `parent.ctx` and invokes the child factory through that fiber, so lifecycle ownership exists before setup or publication begins; disposing a parent reaches its descendants even if a delegating tool never reaches its own `finally`. The child still receives a newly minted scope and resolves only global plus child-scoped capabilities. @@ -371,6 +390,8 @@ A global section protection also reserves the registry name against scoped shado Restoration is intentionally not a whole-assembly reset. The service first removes protected names from the waterfall result, then reinserts protected canonical entries in their canonical order immediately before the first surviving later unprotected canonical neighbor, or at the end when no such neighbor survives. Unprotected entries keep the ordering and definitions chosen by the waterfall. This anchor rule preserves the protected contribution's meaningful local placement without claiming that protection restores every global relative position after arbitrary listener reordering. +Only the restoration inputs are detached before dispatch: the canonical section array when section protection is active and the canonical tool array when tool protection is active. The waterfall receives the original mutable assembly, not a clone, and variables and other merge-extensible fields remain entirely under ordinary waterfall semantics. + ```text registerSection(input, scope): stored = copy(input.name, input.order, input.text) @@ -379,12 +400,14 @@ registerSection(input, scope): sectionLayer(scope).add(stored) assemble(context): - canonical = assemble registries for context.scope - transformed = await systemPromptAssembleWaterfall(clone(canonical)) + assembly = assemble registries for context.scope + canonicalSections = active section protection ? clone(assembly.sections) : absent + canonicalTools = active tool protection ? clone(assembly.tools) : absent + transformed = await systemPromptAssembleWaterfall(assembly) - for each protected name: + for each protected name in the corresponding canonical array: remove every transformed entry with that name - if canonical contains the name: + if the canonical array contains the name: if a later unprotected canonical neighbor survived: insert the canonical entry before that neighbor else: @@ -399,9 +422,11 @@ Code Mode uses global protection for the `tools:sdk` section and reserved `run_c ### Tool executions have stable identity -`ctx.tools.execute(input)` accepts a caller-owned `ToolExecutionInput` and snapshots it into a distinct pipeline-owned `ToolExecution`. It captures the required `callId`/`name` correlation identity, then reads every other top-level caller field once before using it, so parent-token validation, scope routing, policy, dispatch, and final observation all see one coherent identity; those captured optional fields construct the normalized error shell if a later accessor or argument validation fails. The registry materializes `arguments` in one lossless-JSON traversal and deep-freezes the result, so policy and dispatch receive exactly the value that passed validation. A cloneable but mutable exotic such as `Map` or a class instance is rejected before policy rather than smuggled through an apparently frozen wrapper. Invalid input still produces one normalized final error notification; a throwing `callId` or `name` accessor is outside that guarantee because no trustworthy result correlation exists. +`ctx.tools.execute(input)` accepts a caller-owned `ToolExecutionInput` and snapshots it into a distinct pipeline-owned `ToolExecution`. It reads `callId` and `name` once and requires each value to be a string before treating the pair as trustworthy correlation identity. A throwing accessor or non-string value rejects before `tools/result`, because even an error result could not carry a valid identity. After that boundary, the registry reads every other top-level caller field once, and any later accessor or validation failure becomes one normalized final error notification built from the already accepted strings and captured optional fields. -The registry assigns each pipeline trip a frozen, property-free `ToolExecutionToken`; callers cannot choose that token. The execution's `token`, `callId`, `name`, `agent`, optional opaque `parent` token, and detached `arguments` are non-writable and non-configurable from the first policy listener onward. `signal` is the only operational field: an around-dispatch wrapper may add, replace, or remove it, and the registry freezes the complete execution before outcome observation. +The registry materializes `arguments` in one lossless-JSON traversal and deep-freezes the result, so parent-token validation, scope routing, policy, dispatch, and final observation receive exactly the value that passed validation. A cloneable but mutable exotic such as `Map` or a class instance is rejected before policy rather than smuggled through an apparently frozen wrapper. + +The registry assigns each pipeline trip a frozen, property-free `ToolExecutionToken`; callers cannot choose that token. The execution is identity-stable, not fully immutable, while the pipeline runs: its `token`, `callId`, `name`, `agent`, optional opaque `parent` token, and detached `arguments` are non-writable and non-configurable from the first policy listener onward. `signal` is the only operational field; an around-dispatch wrapper may add, replace, or remove it. The registry freezes the complete execution before outcome observation. Stable identity prevents a listener from changing which capability or scope was authorized after policy ran. It also gives commit-style observers a safe `WeakMap` key even when an adapter reuses a model call ID. @@ -411,14 +436,19 @@ The input-to-execution conversion is intentionally one-way: ```text prepareExecution(input): - accepted = read callId, name, arguments, agent, parent, signal exactly once + callId = read input.callId exactly once + name = read input.name exactly once + require callId and name are strings + # A failure above rejects: no trustworthy correlation identity exists. + + accepted = read arguments, agent, parent, and signal exactly once require accepted.parent is absent or a registry-minted token detachedArguments = snapshotLosslessJson(accepted.arguments) execution = { token: new frozen property-free object, - callId: accepted.callId, - name: accepted.name, + callId, + name, arguments: deepFreeze(detachedArguments), agent: accepted.agent, parent: accepted.parent, @@ -447,8 +477,12 @@ The entire registry method reads like one authority ladder: ```text execute(input): + callId = read input.callId exactly once + name = read input.name exactly once + require callId and name are strings + try: - execution = prepareExecution(input) + execution = prepareExecutionFromTrustedIdentity(input, callId, name) catch invalidInput: execution = frozen identity shell with arguments = undefined result = errorResult(invalidInput) @@ -488,6 +522,8 @@ Waterfalls can transform only at their named stages. Guards can only deny, and t ### `agent/turn-stop` makes a composed continuation terminal +Steering is input injected into an already running turn for the next model step; ordinary queued prompts wait for a future turn. The loop normally preserves that distinction by moving leftover steering into another step while leaving the queued-prompt FIFO alone. + Ordinary continuation remains extensible. The loop computes a default, runs the `agent/turn-continuation` waterfall, records any force-continue reason as steering, and folds pending steering into the decision because steering normally demands another model step. The scoped serial `agent/turn-stop` checkpoint runs after that folding. Its strict serial helper consults listeners in order until one returns a non-`undefined` value; a listener returns `{ action: 'stop' }` or abstains with `undefined`. The dedicated helper exists because ordinary Cordis serial dispatch treats `null` and `false` as framework abstentions, while this public contract has exactly one abstention value. A stop is terminal, so later listeners and pending steering cannot restore continuation. A malformed result, including `null` or `false`, or a throwing policy closes the current turn with an error while leaving the driver available for later work. @@ -524,13 +560,17 @@ In-process subagents demonstrate how the scope, lifecycle, and final-policy piec ### Inputs and ownership are fixed before asynchronous creation -Provider registration first freezes an acceptance snapshot of the provider name, capability flags, parent-context descriptor, and `start` callback; the callback is bound to the original provider receiver so its intentional internal state stays live. Lookup, validation, model-facing wording, dispatch, lifecycle notifications, and HMR cleanup all use that snapshot. Mutating or reusing the caller's provider object later therefore cannot rename a live entry, change its advertised powers, replace its callback, or make its disposer delete the wrong key. +Provider registration first freezes an acceptance snapshot of the provider name, capability flags, parent-context descriptor, and `start` callback; the callback is bound to the original provider object so its intentional internal state stays live. Lookup, validation, model-facing wording, dispatch, lifecycle notifications, and hot-reload cleanup all use that snapshot. Mutating or reusing the caller's provider object later therefore cannot rename a live entry, change its advertised powers, replace its callback, or make its disposer delete the wrong key. Starting a run reads every top-level request field once before capability validation, then snapshots every accepted field before asynchronous owner setup. This order makes checked and delegated capabilities identical even for a JavaScript caller with stateful accessors. Fixed scalars are checked at the same boundary: `maxDepth` must be a non-negative safe integer and `persona` must be a string. The parent and abort signal are retained as identity capabilities but never reread from the mutable request record; tool filters, seed events, agent options, output schema, and prompt are detached through the one-pass lossless-JSON materializer. The exported in-process driver repeats this boundary for direct callers before it awaits run-owner activation, including taking one seed snapshot from which it derives both the child prefix and `seedLength`. Later caller mutation therefore cannot change lifecycle scope, configuration, the schema enforced by the capture tool, or the prompt eventually logged and sent. The driver first installs provider ownership. Only after that succeeds does it attach the request's abort listener and create one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves neither a child nor an orphaned listener. The child factory runs through the owner fiber. Parent teardown, provider teardown, and manual run disposal all dispose this same node; moving it out of the active state synchronously prevents an unpublished setup from publishing afterward, while all three paths follow one quiescence promise. This structured ownership does not change the child's flat capability view. -The provider's run separates acceptance from publication with `started: Promise`, but the service does not return that caller-owned handle directly. It reads `id`, `started`, `result`, and every method once, binds methods to the original provider receiver, and returns a frozen service-owned wrapper. Its `result` promise captures `output`, optional `structured`, and `stopReason` once and resolves to one detached, deeply frozen lossless-JSON value shared by the caller and lifecycle telemetry; malformed provider data rejects as an infrastructure fault and produces contained `error` telemetry. For spawn and fork, the accepted `started` promise fulfills only after the child factory returns a published handle, so the service can emit `subagent/start` with `ctx.agents.get(id)` already live; it rejects when rollback prevents publication. The service observes the normalized result immediately but buffers its end payload until readiness, preserving start-before-end order without leaving an early rejection unhandled. Both lifecycle payloads are deeply frozen before contained per-listener dispatch, so one observer cannot corrupt the caller or a peer. A readiness rejection emits neither lifecycle event. The result driver awaits the same boundary before sending the child prompt. +The provider's run separates acceptance from publication with `started: Promise`, but the service does not expose that caller-owned handle directly. It captures `id`, `started`, `result`, and each method once, binds methods to the provider-owned run handle, and returns a frozen service-owned wrapper. Capturing `dispose` first also preserves a rollback capability if a later accessor or method check reveals a malformed handle. + +The wrapper's `result` promise captures `output`, optional `structured`, and `stopReason` once and resolves to one detached, deeply frozen lossless-JSON value shared by the caller and lifecycle telemetry. Malformed terminal data is an infrastructure fault; it rejects only after the service has started rollback of the provider attempt. The service observes the normalized result immediately, before waiting for readiness, so an early rejection is never temporarily unhandled. + +For spawn and fork, the accepted `started` promise fulfills only after the child factory returns a published handle. The service can then emit `subagent/start` with `ctx.agents.get(id)` already live and release any buffered terminal event; if readiness rejects, it emits neither start nor end. Lifecycle notification is fire-and-forget and non-vetoing: each listener receives the same deeply frozen payload, and synchronous throws or returned-promise rejections are logged and contained per listener without awaiting them. The child result driver awaits the same readiness boundary before sending the prompt. ```text startInProcessRun(providerContext, acceptedRequest): @@ -563,9 +603,10 @@ SubagentService.start(...): result: normalize once into detached, deeply frozen lossless JSON }) attach settlement handlers to serviceRun.result immediately - await serviceRun.started - emit subagent/start; later emit the buffered or eventual subagent/end - return serviceRun + attach handlers to serviceRun.started: + on fulfillment, emit subagent/start and then buffered or eventual subagent/end + on rejection, discard buffered lifecycle telemetry + return serviceRun immediately Workflow worker bridge after receiving returnedRun: register the run so cancellation can reach pre-publication work @@ -581,7 +622,11 @@ Before publishing the workflow's own result: only then settle the workflow result ``` -Every downstream protocol that announces a subagent must honor the same boundary. The workflow worker bridge therefore registers the returned run before waiting, observes and snapshots `result` immediately, sends `ChildStarted` only after `started` fulfills, and sends `ChildStartError` plus host-driven disposal when readiness rejects. Before the workflow result becomes observable, the host also drives both permitted cancellation channels—the shared abort signal and each registered run's explicit `cancel()`—because a fire-and-forget child still waiting on readiness has no worker-side handle that could relay cancellation. Provider cancel callbacks are contained independently so one broken implementation cannot prevent peers from receiving cancellation or wedge the workflow result. This keeps cancellation able to reach pending creation, prevents an early result rejection from going unhandled, ensures `workflow/agent-start` never names an unpublished child, and prevents a child from publishing after its workflow has ended. +Every downstream protocol that announces a subagent must honor the same boundary. The workflow worker bridge therefore registers the returned run before waiting, observes and snapshots `result` immediately, sends `ChildStarted` only after `started` fulfills, and sends `ChildStartError` plus host-driven disposal when readiness rejects. + +Cancellation before readiness is a publication decision, not merely a flag for later result mapping. The in-process run synchronously deactivates its owner fiber, so the agent factory's liveness check fails, `started` rejects, and neither the child session nor agent can publish. The run's result still settles as `aborted`. Before the workflow's own result becomes observable, its host likewise drives both permitted cancellation channels: it aborts the shared request signal and calls each registered run's `cancel()`, including runs still waiting on readiness. Provider cancel callbacks are contained independently so one broken implementation cannot prevent peers from receiving cancellation or wedge the workflow result. + +Together these rules prevent an early result rejection from going unhandled, ensure `workflow/agent-start` never names an unpublished child, and prevent a child from publishing after its workflow has ended. Parent teardown reaches `runOwner` by nesting; the provider and returned run handle reach the same node through their explicit disposers. @@ -607,7 +652,7 @@ The table describes the registry's named canonical contribution. An unrelated as ### Capture uses stage, final commit, monotonic denial, and terminal stop -The capture tool validates its arguments and stages the cloned value in a JavaScript `WeakMap` keyed by the immutable `ToolExecution`. This is an object-identity table whose key does not keep an abandoned execution alive. Validation failure becomes the ordinary `INVALID_ARGS` error that the model can correct within the turn. +The capture tool validates its arguments and stages the cloned value in a JavaScript `WeakMap` keyed by the identity-stable `ToolExecution`. This is an object-identity table whose key does not keep an abandoned execution alive. Validation failure becomes the ordinary `INVALID_ARGS` error that the model can correct within the turn. The scoped `tools/result` observer commits a direct native capture only when that exact execution's authoritative final result succeeds. A later call with a reused string call ID cannot reach the weak-keyed stage, and a post-execution block cannot promote it. @@ -744,9 +789,9 @@ The costs are concentrated in dispatch discipline, per-scope registry state, and - `run_code` is protected transport infrastructure rather than a filterable end capability, so a policy that must forbid programs denies execution at the tool-policy layer instead of removing the transport from a Code Mode prompt. - Prompt protection restores named canonical contributions and their anchor placement, not the entire assembly; unprotected output remains extensible, while a globally protected section name is deliberately unavailable for scoped shadowing. - Terminal turn stopping has authority to discard pending steering. That power is appropriate for owner-enforced terminal protocols and too strong for ordinary cooperative continuation policy. -- Programmatic `ctx.agents.create()` and `ctx.agents.resume()` are asynchronous because they await setup. The config-only `ctx.agentLoop.create()` path has no setup callback and remains synchronous. +- Programmatic `ctx.agents.create()` and `ctx.agents.resume()` are asynchronous because they await setup. The direct no-setup `ctx.agentLoop.create()` path, used by configuration and programmatic callers that already have complete options, remains synchronous. - Ordered composition requires both an exact raw scope disposer and a shared public quiescence promise; the dual surface reflects two distinct Cordis lifecycle requirements. ### Deliberate boundaries -The scope primitive is generic, but this decision applies it only where one agent needs a coherent registration view: tools, prompt state, scoped events, sessions, and in-process subagent composition. `agent.ctx` does not automatically scope every service call; filesystem policy, LLM interception, background subagent state, and future registries retain their existing seams until their own designs explicitly adopt the context rule. +The scope primitive is generic, but this decision applies it only where one agent needs a coherent registration view: tools, prompt state, scoped events, sessions, and in-process subagent composition. `agent.ctx` does not automatically scope every service call; filesystem policy, LLM interception, background subagent state, and other registries retain their existing seams until their own designs explicitly adopt the context rule. diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index aea7927571..af8e9905e4 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -65,11 +65,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'agents', summary: 'Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package.', methods: [ + 'reserve(id: AgentId): AgentRegistrationReservation', 'setFactory(factory: AgentFactory): () => Promise | void', 'async create(options: CreateAgentOptions): Promise', 'async resume(options: ResumeAgentOptions): Promise', 'register(agent: Agent): () => Promise | void', - 'enter(agent: Agent): () => void', + 'enter(agent: Agent, reservation?: AgentRegistrationReservation): () => void', 'announce(agent: Agent): void', 'get(id: AgentId): Agent | undefined', 'list(): Agent[]', @@ -155,9 +156,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'sessions', summary: 'In-memory session store (`ctx.sessions`).', methods: [ + 'reserve(id: SessionId): SessionRegistrationReservation', 'create(id?: SessionId, options?: CreateSessionOptions): Session', 'prepare(id?: SessionId, options?: CreateSessionOptions): Session', - 'enter(session: Session): () => void', + 'enter(session: Session, reservation?: SessionRegistrationReservation): () => void', 'announce(session: Session): void', 'async flush(session: Session): Promise', 'get(id: SessionId): Session | undefined', @@ -353,6 +355,12 @@ export const EVENT_API: readonly EventApiEntry[] = [ signature: '\'session/created\'(this: Scoped, session: Session): void', summary: 'A session was created in the store.', }, + { + name: 'session/disposed', + mode: 'emit', + signature: '\'session/disposed\'(this: Scoped, session: Session): void', + summary: 'A previously announced session left the store.', + }, { name: 'session/event', mode: 'emit', @@ -503,6 +511,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentOptions', declaration: 'export interface AgentOptions {\n model?: string;\n}', }, + { + name: 'AgentRegistrationReservation', + declaration: 'export interface AgentRegistrationReservation {\n readonly id: AgentId;\n release(): void;\n}', + }, { name: 'AgentStatus', declaration: 'export type AgentStatus = \'idle\' | \'running\' | \'disposed\';', @@ -803,6 +815,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionId', declaration: 'export type SessionId = Branded<\'SessionId\'>;', }, + { + name: 'SessionRegistrationReservation', + declaration: 'export interface SessionRegistrationReservation {\n readonly id: SessionId;\n prepare(options?: CreateSessionOptions): Session;\n release(): void;\n}', + }, { name: 'SkillCandidate', declaration: 'export interface SkillCandidate extends SkillSummary {\n rank: number;\n locator: unknown;\n path?: string;\n metadata?: Record;\n}', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index ac1a0e079f..a8da612305 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -8,9 +8,9 @@ This is the only package in the harness that contains concrete loop logic. Every ### Public API -Lifecycle (scoped): programmatic creation and resume snapshot caller-owned identity/configuration data, reserve both IDs, mint `agent.ctx`, and install the ordered teardown skeleton before awaiting optional `setup`. A create hands one-read raw seed and metadata references synchronously to the session boundary, which rejects exotic shells and materializes accepted values in a single recursive pass; pre-cloning either value could incorrectly sanitize prototypes. Resume installs an owner-liveness sentinel before persistence load, captures each loaded metadata field once, then hands ownership directly to the full lifecycle. After setup resolves, the factory checks its lifecycle flag, owner-fiber state, and owning agent status around one microtask checkpoint so a same-turn Cordis unload wins before publication. Successful setup inserts both session and agent before announcing either, enables driving immediately before `agent/session-start`, then starts the loop. Setup calls to `send`/`steer`/`inject`/`cancel` reject structurally; load/setup rejection or owner unload publishes nothing. Teardown runs stop/drain (including outstanding idle-injection flushes) → unregister → detach session → unwind scope. All `agent/*` dispatches go through `agentEvents(ctx, agent)`; per-step assembly through `assembleContextFor(agent)`; the turn-end durability checkpoint through `ctx.sessions.flush(session)`. +Lifecycle (scoped): programmatic creation and resume snapshot caller-owned identity/configuration data, obtain registry/store-owned capabilities for both unpublished IDs, mint `agent.ctx`, and install the ordered teardown skeleton before awaiting optional `setup`. The capabilities reject competing `register`/`enter`/`prepare`/`create` calls, so setup cannot publish the factory objects or same-id replacements. A create hands one-read raw seed and metadata references synchronously to the session boundary, which rejects exotic shells and materializes accepted values in a single recursive pass; pre-cloning either value could incorrectly sanitize prototypes. Resume installs an owner-liveness sentinel before persistence load, captures each loaded metadata field once, then hands ownership directly to the full lifecycle. After setup resolves, the factory checks its lifecycle flag, owner-fiber state, and owning agent status around one microtask checkpoint so a same-turn Cordis unload wins before publication. Successful setup inserts both session and agent before announcing either, enables driving immediately before `agent/session-start`, then starts the loop. The concrete agent owns runtime-pinned `id`, frozen detached `options`, `session`, and `ctx` bindings. Load/setup rejection or owner unload publishes nothing; partial creation announcements are paired during rollback. Teardown runs stop/drain (including outstanding idle-injection flushes) → unregister → detach session → unwind scope. All non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, which contains sync/async listener failures per observer; per-step assembly goes through `assembleContextFor(agent)`; the turn-end durability checkpoint goes through `ctx.sessions.flush(session)`. -- `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — config-driven create: an agent on a fresh per-run session id `${id}-session-` with optional session metadata. Used for `cordis.yml`-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber. +- `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create, used directly by programs and by `cordis.yml`-configured agents. It creates a fresh per-run session id `${id}-session-` with optional metadata; the uuid avoids colliding with a prior durable log. Each call is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber. `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index c4c35f34db..3234a47939 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -7,10 +7,10 @@ */ import type { Context } from 'cordis' -import { scopeTarget } from '@deepseek-ai/dsh-scope' -import type { Scoped } from '@deepseek-ai/dsh-scope' +import { agentEvents } from '@deepseek-ai/dsh-agent' import type { AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' +import { deepFreeze } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { Session } from '@deepseek-ai/dsh-session' import { Inbox } from './inbox.ts' @@ -65,6 +65,26 @@ export function prepareReactLoopAgent( } } +/** + * Install the concrete agent's scope context exactly once. Construction and + * scope minting are mutually referential (the scope key is the agent), so the + * factory performs this one post-construction binding before setup receives + * the unpublished agent. The runtime slot is non-writable/non-configurable; + * TypeScript `readonly` alone would still let JavaScript redirect later + * registrations to another context. + * @param agent - the unpublished concrete agent to bind. + * @param ctx - its fully extended agent scope context. + */ +export function bindReactLoopAgentContext(agent: ReactLoopAgent, ctx: Context): void { + if (Object.hasOwn(agent, 'ctx')) throw new Error(`agent "${agent.id}" context is already bound`) + Object.defineProperty(agent, 'ctx', { + value: ctx, + enumerable: true, + writable: false, + configurable: false, + }) +} + /** * The concrete {@link Agent} implementation owned by the agent-loop plugin. * @@ -84,18 +104,7 @@ export class ReactLoopAgent implements Agent { * context are mutually referential (the scope is keyed BY this agent), so * neither can exist strictly before the other. */ - ctx!: Context - - /** - * The dispatch carrier for this agent's own emits (`agent/status`, - * `agent/queued`, `agent/error`): keyed by the agent, base = the agent - * (listener `this` is the agent). Built lazily because it is self-referential. - */ - private get carrier(): Scoped { - return (this.#carrier ??= scopeTarget(this, this)) - } - - #carrier: Scoped | undefined + declare readonly ctx: Context private _status: AgentStatus = 'idle' private currentAbort: AbortController | undefined @@ -143,6 +152,16 @@ export class ReactLoopAgent implements Agent { public readonly options: AgentOptions, public readonly session: Session, ) { + const acceptedOptions = deepFreeze(structuredClone(options)) + // Pin the public ownership/identity bindings in the runtime object. A + // JavaScript caller can otherwise replace TS-readonly parameter properties + // after publication and split the registry, driver, session, and model + // configuration into different worlds. + Object.defineProperties(this, { + id: { value: id, enumerable: true, writable: false, configurable: false }, + options: { value: acceptedOptions, enumerable: true, writable: false, configurable: false }, + session: { value: session, enumerable: true, writable: false, configurable: false }, + }) const { promise, resolve } = Promise.withResolvers() this.disposed = promise this.resolveDisposed = resolve @@ -161,11 +180,7 @@ export class ReactLoopAgent implements Agent { // waiter (docs/defensive-patterns.md "contain callback exceptions" — a lifecycle await must // not hang on one bad listener). if (status !== 'running') this.settleIdleWaiters() - try { - this.loopCtx.emit(this.carrier, 'agent/status', this, status) - } catch (error: unknown) { - this.loopCtx.logger.warn(`agent "${this.id}": agent/status listener threw on ${status}: ${String(error)}`) - } + agentEvents(this.loopCtx, this).emit('agent/status', status) } /** @@ -194,7 +209,7 @@ export class ReactLoopAgent implements Agent { if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`) const source = this.resolveSource(options) this.#inbox.enqueue({ content, source }) - this.loopCtx.emit(this.carrier, 'agent/queued', this, content, { source, steering: false }) + agentEvents(this.loopCtx, this).emit('agent/queued', content, { source, steering: false }) } steer(content: ContentBlock[], options?: SendOptions): void { @@ -203,7 +218,7 @@ export class ReactLoopAgent implements Agent { if (this._status !== 'running') { this.send(content, options); return } const source = this.resolveSource(options) this.#inbox.steer({ content, source }) - this.loopCtx.emit(this.carrier, 'agent/queued', this, content, { source, steering: true }) + agentEvents(this.loopCtx, this).emit('agent/queued', content, { source, steering: true }) } inject(content: ContentBlock[], options?: SendOptions): void { @@ -269,14 +284,10 @@ export class ReactLoopAgent implements Agent { if (turnRecorded) { // Through the store's flush (the carrier owner), never a raw parallel. const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => { - const err = error instanceof Error ? error : new Error(String(error)) - this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${err.message}`) - try { - this.loopCtx.emit(this.carrier, 'agent/error', this, turn, 0, err) - } catch { - // contained: the failure is already logged; a throwing agent/error - // listener must not escape this fire-and-forget catch. - } + const rendered = renderThrown(error) + const err = error instanceof Error ? error : new Error(rendered) + this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${rendered}`) + agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err) }) this.pendingIdleFlushes.add(flush) // Attach the same retirement callback to both settlement arms so even a @@ -393,11 +404,7 @@ export class ReactLoopAgent implements Agent { // setStatus refuses transitions out of 'disposed', so emit directly — // 'disposed' is part of the agent/status contract. Guarded: a throwing // listener must not break the disposal chain. - try { - this.loopCtx.emit(this.carrier, 'agent/status', this, 'disposed') - } catch { - // listener error during disposal — nothing safe left to do with it - } + agentEvents(this.loopCtx, this).emit('agent/status', 'disposed') } // An unexpected driver rejection must not skip registry/session/scope // cleanup. The normal loop contains turn failures itself; allSettled is the @@ -414,3 +421,12 @@ export class ReactLoopAgent implements Agent { } } } + +/** Render an arbitrary thrown value without allowing coercion to throw again. */ +function renderThrown(value: unknown): string { + try { + return value instanceof Error ? value.message : String(value) + } catch { + return '' + } +} diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 1e5c122fc5..902e1e2185 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -13,17 +13,24 @@ import z from 'schemastery' import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import { agentEvents } from '@deepseek-ai/dsh-agent' -import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions, SessionStartSource } from '@deepseek-ai/dsh-agent' +import type { AgentFactory, AgentHandle, AgentId, AgentOptions, AgentRegistrationReservation, CreateAgentOptions, ResumeAgentOptions, SessionStartSource } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-llm' import { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session' -import type { Session } from '@deepseek-ai/dsh-session' +import type { Session, SessionRegistrationReservation } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' -import { prepareReactLoopAgent, ReactLoopAgent } from './agent.ts' +import { bindReactLoopAgentContext, prepareReactLoopAgent, ReactLoopAgent } from './agent.ts' export { ReactLoopAgent } from './agent.ts' +/** Both unpublished identity capabilities held by one factory transaction. */ +interface RegistrationReservations { + agent: AgentRegistrationReservation + session: SessionRegistrationReservation + release(): void +} + declare module 'cordis' { interface Context { agentLoop: AgentLoop @@ -71,10 +78,6 @@ export interface Config { export class AgentLoop extends Service implements AgentFactory { static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt'] - /** IDs held by unpublished async creation transactions. */ - private pendingAgentIds = new Set() - private pendingSessionIds = new Set() - // The schema validates plain strings (cordis.yml config values are untyped at // runtime); the {@link Config} TYPE declares the branded `id`/`resumeSessionId` // because the config format is the boundary where an id enters. The brand is a @@ -153,14 +156,19 @@ export class AgentLoop extends Service implements AgentFactory { * @returns the running agent, owned by the calling fiber (no handle). */ create(id: AgentId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent { - this.assertAgentIdFree(id) + const sessionId = SessionId(`${id}-session-${randomUUID()}`) + const reservations = this.reserve(id, sessionId) // Config/programmatic path: prepare the session and let start() fold its // lifecycle into the agent's composite effect (so a fiber unload tears the // session + agent down as one ordered chain, capturing the loop's closing // flush). The whole effect is owned by THIS fiber; no AgentHandle is needed. - const session = this.ctx.sessions.prepare(SessionId(`${id}-session-${randomUUID()}`), { meta }) - const { agent } = this.start(id, options, session, 'startup') - return agent + try { + const session = reservations.session.prepare({ meta }) + const { agent } = this.start(id, options, session, 'startup', reservations) + return agent + } finally { + reservations.release() + } } /** @@ -188,16 +196,16 @@ export class AgentLoop extends Service implements AgentFactory { const agentOptions = structuredClone(options.agentOptions ?? {}) const seed = options.seed const meta = options.meta - const release = this.reserve(agentId, sessionId) + const reservations = this.reserve(agentId, sessionId) try { - const session = this.ctx.sessions.prepare(sessionId, { + const session = reservations.session.prepare({ ...seed !== undefined ? { seed } : {}, ...meta !== undefined ? { meta } : {}, }) // A seeded (forked) create is still a fresh start, NOT a resume. - return await this.startOwned(agentId, agentOptions, session, 'startup', setup) + return await this.startOwned(agentId, agentOptions, session, 'startup', reservations, setup) } finally { - release() + reservations.release() } } @@ -273,7 +281,7 @@ export class AgentLoop extends Service implements AgentFactory { return transactionSettled }, `agentLoop.resumeLoad(${agentId})`) try { - const release = this.reserve(agentId, sessionId) + const reservations = this.reserve(agentId, sessionId) try { const loadTask = persistence.load(sessionId) const { meta, events } = await Promise.race([ @@ -292,7 +300,7 @@ export class AgentLoop extends Service implements AgentFactory { // An out-of-band direct registry/session insertion can still race this // service's reservation, so the public enter primitives re-check exact // liveness at publication. - const session = this.ctx.sessions.prepare(sessionId, { + const session = reservations.session.prepare({ seed: events, meta: { createdAt, @@ -305,12 +313,12 @@ export class AgentLoop extends Service implements AgentFactory { // effect before it reaches its first setup await. Only then disarm the // load sentinel: ownership passes directly from one effect to the other // with no disposal gap. - const starting = this.startOwned(agentId, agentOptions, session, 'resume', setup) + const starting = this.startOwned(agentId, agentOptions, session, 'resume', reservations, setup) observingOwner = false await disposeLoadSentinel() return await starting } finally { - release() + reservations.release() } } finally { try { @@ -327,29 +335,24 @@ export class AgentLoop extends Service implements AgentFactory { } } - /** - * Reject a duplicate agent id BEFORE the session is entered into the store, so - * a failed factory call never leaves an orphaned live session (and lazy - * persistence state) behind. `register()` enforces the same uniqueness, but - * only after the session has already entered the store. - */ - private assertAgentIdFree(id: AgentId): void { - if (this.ctx.agents.get(id) !== undefined || this.pendingAgentIds.has(id)) { - throw new Error(`agent "${id}" is already registered`) - } - } - - /** Reserve both public identities for one unpublished async transaction. */ - private reserve(agentId: AgentId, sessionId: SessionId): () => void { - this.assertAgentIdFree(agentId) - if (this.ctx.sessions.get(sessionId) !== undefined || this.pendingSessionIds.has(sessionId)) { - throw new Error(`session "${sessionId}" already exists`) - } - this.pendingAgentIds.add(agentId) - this.pendingSessionIds.add(sessionId) - return () => { - this.pendingAgentIds.delete(agentId) - this.pendingSessionIds.delete(sessionId) + /** Reserve both public identities in their owning registries. */ + private reserve(agentId: AgentId, sessionId: SessionId): RegistrationReservations { + const agent = this.ctx.agents.reserve(agentId) + try { + const session = this.ctx.sessions.reserve(sessionId) + return { + agent, + session, + release() { + // Both owner capabilities are independently idempotent, so the + // composite needs no second state machine of its own. + session.release() + agent.release() + }, + } + } catch (error: unknown) { + agent.release() + throw error } } @@ -361,7 +364,12 @@ export class AgentLoop extends Service implements AgentFactory { * `active`, unwinds the scope, and wins the race without any late Cordis * effect collection. */ - private prepareLifecycle(id: AgentId, options: AgentOptions, session: Session): { + private prepareLifecycle( + id: AgentId, + options: AgentOptions, + session: Session, + reservations: RegistrationReservations, + ): { agent: ReactLoopAgent active: () => boolean deactivated: Promise @@ -378,7 +386,7 @@ export class AgentLoop extends Service implements AgentFactory { const driver = prepareReactLoopAgent(this.ctx, id, options, session) const { agent } = driver const scope: Scope = createScope(this.ctx, agent) - agent.ctx = scope.ctx.extend({ agent }) + bindReactLoopAgentContext(agent, scope.ctx.extend({ agent })) let active = true let detachSession: (() => void) | undefined @@ -422,19 +430,15 @@ export class AgentLoop extends Service implements AgentFactory { const publish = (source: SessionStartSource): void => { // Publication is one synchronous, rollback-covered sequence. Setup has // already completed, so its scoped listeners observe both announcements. - detachSession = agent.ctx.sessions.enter(session) - detachAgent = this.ctx.agents.enter(agent) + detachSession = agent.ctx.sessions.enter(session, reservations.session) + detachAgent = this.ctx.agents.enter(agent, reservations.agent) this.ctx.sessions.announce(session) this.ctx.agents.announce(agent) // Setup is over and both entries are live. Open the driving surface just // before session-start so its listeners retain their supported ability to // inject/queue, while setup itself can never drive an unpublished agent. driver.enableDrive() - try { - agentEvents(this.ctx, agent).emit('agent/session-start', source) - } catch (error: unknown) { - this.ctx.logger.warn(`agent "${id}": agent/session-start listener threw: ${String(error)}`) - } + agentEvents(this.ctx, agent).emit('agent/session-start', source) stop = driver.startDriver() } @@ -453,9 +457,13 @@ export class AgentLoop extends Service implements AgentFactory { /** Publish a no-setup config agent synchronously. */ private start( - id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource, + id: AgentId, + options: AgentOptions, + session: Session, + source: SessionStartSource, + reservations: RegistrationReservations, ): { agent: ReactLoopAgent; disposeAgent: () => Promise } { - const lifecycle = this.prepareLifecycle(id, options, session) + const lifecycle = this.prepareLifecycle(id, options, session, reservations) try { lifecycle.publish(source) return { agent: lifecycle.agent, disposeAgent: lifecycle.disposeAgent } @@ -485,9 +493,10 @@ export class AgentLoop extends Service implements AgentFactory { */ private async startOwned( id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource, + reservations: RegistrationReservations, setup?: (agentCtx: Context) => Promise | void, ): Promise { - const lifecycle = this.prepareLifecycle(id, options, session) + const lifecycle = this.prepareLifecycle(id, options, session, reservations) try { // The owner-disposal branch makes a never-settling setup unable to hold // the transaction or its ID reservations forever. Promise.race installs diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index ab3494d991..b2a4fe30d5 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -7,7 +7,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { prepareReactLoopAgent } from '../src/agent.ts' +import { bindReactLoopAgentContext, prepareReactLoopAgent } from '../src/agent.ts' import { MockAdapter, textResponse } from './mock-adapter.ts' async function harness(adapter: MockAdapter) { @@ -49,6 +49,34 @@ function send(agent: ReactLoopAgent, text: string) { } describe('ReactLoopAgent', () => { + it('owns immutable runtime bindings for id, options, session, and scoped context', async () => { + const ctx = await harness(new MockAdapter([textResponse('unused')])) + const options = { model: 'mock' } + const agent = ctx.agentLoop.create(AgentId('owned-bindings'), options) + const acceptedSession = agent.session + const acceptedContext = agent.ctx + + options.model = 'caller-mutated' + expect(agent.options).toEqual({ model: 'mock' }) + expect(Object.isFrozen(agent.options)).toBe(true) + expect(Reflect.set(agent, 'id', AgentId('redirected'))).toBe(false) + expect(Reflect.set(agent, 'options', { model: 'other' })).toBe(false) + expect(Reflect.set(agent, 'session', ctx.sessions.create(SessionId('other')))).toBe(false) + expect(Reflect.set(agent, 'ctx', new Context())).toBe(false) + expect(agent.id).toBe('owned-bindings') + expect(agent.session).toBe(acceptedSession) + expect(agent.ctx).toBe(acceptedContext) + expect(() => { bindReactLoopAgentContext(agent, new Context()) }).toThrow(/context is already bound/) + for (const name of ['id', 'options', 'session', 'ctx']) { + expect(Object.getOwnPropertyDescriptor(agent, name)).toMatchObject({ + configurable: false, + writable: false, + }) + } + + await ctx.fiber.dispose() + }) + it('send() throws after disposal', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) @@ -197,6 +225,23 @@ describe('ReactLoopAgent', () => { warn.mockRestore() }) + it('idle inject() safely renders a hostile non-Error flush failure', async () => { + const ctx = await harness(new MockAdapter([textResponse('ok')])) + const hostile = { [Symbol.toPrimitive]() { throw new Error('no coercion') } } + ctx.on('session/flush', () => { throw hostile }) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const agent = ctx.agentLoop.create(AgentId('hostile-flush'), { model: 'mock' }) + const errors: string[] = [] + ctx.on('agent/error', (_a, _turn, _step, error) => void errors.push(error.message)) + + agent.inject([{ type: 'text', text: 'notice' }]) + await new Promise(r => setTimeout(r, 20)) + + expect(errors).toEqual(['']) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('')) + warn.mockRestore() + }) + it('idle inject() with a non-serializable source opens no turn (nothing to close)', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) @@ -416,7 +461,7 @@ describe('ReactLoopAgent', () => { expect(adapter.requests).toHaveLength(1) expect(agent.status).toBe('idle') - expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent/status listener threw on running')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent event "agent/status" listener threw')) warn.mockRestore() }) @@ -434,7 +479,7 @@ describe('ReactLoopAgent', () => { expect(adapter.requests).toHaveLength(1) expect(agent.status).toBe('idle') - expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent/status listener threw on idle')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent event "agent/status" listener threw')) warn.mockRestore() }) }) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 6d0bb3107b..5ed11d396a 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -197,6 +197,35 @@ describe('agent scope lifecycle', () => { await handle.dispose() }) + it('makes setup-time publication structurally impossible through public stores', async () => { + const ctx = await harness() + const lifecycle: string[] = [] + ctx.on('session/created', () => void lifecycle.push('session')) + ctx.on('agent/created', () => void lifecycle.push('agent')) + + const handle = await ctx.agents.create({ + agentId: AgentId('guarded-publication'), + sessionId: SessionId('guarded-publication-s'), + agentOptions: { model: 'mock' }, + setup: (agentCtx) => { + const agent = agentCtx.agent! + expect(() => agentCtx.agents.enter(agent)).toThrow(/reserved for unpublished creation/) + expect(() => agentCtx.agents.register(agent)).toThrow(/reserved for unpublished creation/) + expect(() => agentCtx.sessions.enter(agent.session)).toThrow(/reserved for unpublished creation/) + expect(() => agentCtx.sessions.prepare(agent.session.id)).toThrow(/reserved for unpublished creation/) + expect(() => agentCtx.sessions.create(agent.session.id)).toThrow(/reserved for unpublished creation/) + expect(lifecycle).toEqual([]) + expect(ctx.agents.get(agent.id)).toBeUndefined() + expect(ctx.sessions.get(agent.session.id)).toBeUndefined() + }, + }) + + expect(lifecycle).toEqual(['session', 'agent']) + expect(ctx.agents.get(handle.agent.id)).toBe(handle.agent) + expect(ctx.sessions.get(handle.agent.session.id)).toBe(handle.agent.session) + await handle.dispose() + }) + it('structurally rejects every driving verb during setup', async () => { const ctx = await harness() const handle = await ctx.agents.create({ @@ -357,6 +386,33 @@ describe('agent scope lifecycle', () => { await retry.dispose() }) + it('pairs session and agent announcements when agent creation aborts publication', async () => { + const ctx = await harness() + const lifecycle: string[] = [] + ctx.on('session/created', (session) => { lifecycle.push(`session-created:${session.id}`) }) + ctx.on('session/disposed', (session) => { lifecycle.push(`session-disposed:${session.id}`) }) + ctx.on('agent/created', (agent) => { + lifecycle.push(`agent-created:${agent.id}`) + throw new Error('agent observer failed') + }) + ctx.on('agent/disposed', (agent) => { lifecycle.push(`agent-disposed:${agent.id}`) }) + + await expect(ctx.agents.create({ + agentId: AgentId('partial-agent'), + sessionId: SessionId('partial-session'), + agentOptions: { model: 'mock' }, + })).rejects.toThrow('agent observer failed') + + expect(lifecycle).toEqual([ + 'session-created:partial-session', + 'agent-created:partial-agent', + 'agent-disposed:partial-agent', + 'session-disposed:partial-session', + ]) + expect(ctx.agents.get(AgentId('partial-agent'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('partial-session'))).toBeUndefined() + }) + it('the synchronous config helper rolls back when publication throws', async () => { const ctx = await harness() const sessionsBefore = ctx.sessions.list().length diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 23fa073ea1..1b1e65a289 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -8,10 +8,10 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i ### Public API -The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher every agent-subject event goes through (carrier + injected subject in one move); `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while the factory keeps the agent and session unpublished; creation awaits setup and a same-turn owner-unload checkpoint before either creation notification or the first assembly. Setup composes, it never drives: the concrete loop rejects driving verbs until the `agent/session-start` boundary. +The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher every agent-subject event goes through (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while registry/store-owned reservation capabilities keep both identities unpublished; creation awaits setup and a same-turn owner-unload checkpoint before either creation notification or the first assembly. Setup composes, it never drives or publishes: driving verbs and ordinary agent/session insertion both reject until the owning publication boundary. - `ctx.agents.register(agent: Agent): () => Promise | void` — record an **already-constructed** agent. Disposed with the calling fiber. -- Advanced ordered lifecycle: `enter(agent): () => void` inserts without announcing, and `announce(agent)` emits `agent/created` only for that exact live entry. The async factory uses this split after setup; ordinary plugins use `register()`. +- Advanced ordered lifecycle: `reserve(id)` returns an opaque unpublished-identity capability owned by the calling fiber (owner unload releases an abandoned reservation); `enter(agent, reservation?): () => void` inserts under one captured, runtime-pinned id without announcing; and `announce(agent)` emits `agent/created` exactly once for that exact live entry, rejecting repeat or reentrant announcement. While reserved, bare `register`/`enter` calls for the id reject, including from setup. The factory uses this split; ordinary plugins use `register()`. - `ctx.agents.get(id: AgentId): Agent | undefined` - `ctx.agents.list(): Agent[]` @@ -20,7 +20,7 @@ The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh- Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. - `ctx.agents.setFactory(factory: AgentFactory): () => Promise | void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose. -- `ctx.agents.create(options: CreateAgentOptions): Promise` — snapshot caller-owned IDs/options/metadata and hand the one-read raw seed synchronously to the session boundary for one-pass lossless-JSON materialization, construct and await optional setup while unpublished, insert and announce both session and agent, open the `agent/session-start` driving boundary, then start a new loop on the caller-supplied `sessionId`. Agent/session IDs are reserved across setup; seed rejection, setup rejection, or owner unload publishes nothing. Publication is rollback-covered: if a creation listener throws, entries and scope unwind but effects of already-delivered notifications remain observable; an agent whose announcement began emits `agent/disposed` during that rollback. Rejects if no factory is registered. +- `ctx.agents.create(options: CreateAgentOptions): Promise` — snapshot caller-owned IDs/options/metadata and hand the one-read raw seed synchronously to the session boundary for one-pass lossless-JSON materialization, construct and await optional setup while unpublished, insert and announce both session and agent, open the `agent/session-start` driving boundary, then start a new loop on the caller-supplied `sessionId`. Registry/store reservation capabilities block every competing public insertion across setup; seed rejection, setup rejection, or owner unload publishes nothing. Publication is rollback-covered: if a creation listener throws, entries and scope unwind but effects of already-delivered notifications remain observable; any creation announcement that began is paired by `agent/disposed` or `session/disposed`. Rejects if no factory is registered. - `ctx.agents.resume(options: ResumeAgentOptions): Promise` — snapshot caller-owned IDs/options, load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh agent scope, await optional setup while unpublished, then follow the same insert → announce → session-start → loop-start boundary. The IDs are reserved across persistence load and setup; load/setup rejection or owner unload publishes nothing. Rejects if no factory is registered or session persistence is unconfigured. `AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit plus every outstanding idle-injection flush (quiescence — NOT just the `disposed` status flip), unregisters the agent, removes its session from the store, and finally unwinds its scoped world. This order captures every agent-started `session/flush` before the session is detached and keeps scoped listeners alive through those checkpoints. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge and in-process subagent backends are production consumers; config-created agents are owned by the loop fiber and never need a handle. diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts index b02dfeab57..881faa675f 100644 --- a/packages/core/agent/src/dispatch.ts +++ b/packages/core/agent/src/dispatch.ts @@ -45,7 +45,10 @@ type Tail = Params extends [Agent, ...in */ export interface AgentEventDispatch { /** - * Fire-and-forget notification (Cordis `emit`) in the agent's scope. + * Fire-and-forget notification in the agent's scope. Every listener is + * invoked; synchronous throws and returned-promise rejections are logged and + * contained per listener, so a notification cannot veto lifecycle progress + * or starve a later observer. * @param name - the agent-subject event to emit. * @param rest - the event's arguments after the injected agent. */ @@ -96,9 +99,22 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { // tuple — hence one contained, shape-preserving cast per method. return { emit(name, ...rest) { - // eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function - const emit = ctx.emit as (thisArg: Scoped, name: string, ...args: unknown[]) => void - emit(carrier, name, agent, ...rest) + // Cordis emit invokes callbacks through Array.map: one synchronous throw + // starves later listeners, and returned promises are discarded. Agent + // notifications are non-vetoing, so resolve the same filtered callback + // set ourselves and contain both failure modes independently. + const args: unknown[] = [carrier, name, agent, ...rest] + const callbacks = ctx.events.dispatch('emit', args) + for (const callback of callbacks) { + try { + const returned: unknown = callback(...args) + void Promise.resolve(returned).catch((error: unknown) => { + ctx.logger.warn(`agent event "${name}" listener rejected: ${renderThrown(error)}`) + }) + } catch (error: unknown) { + ctx.logger.warn(`agent event "${name}" listener threw: ${renderThrown(error)}`) + } + } }, async serial(name, ...rest) { // eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function @@ -129,6 +145,15 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { } } +/** Render an arbitrary thrown value without allowing coercion to throw again. */ +function renderThrown(value: unknown): string { + try { + return value instanceof Error ? `${value.name}: ${value.message}` : String(value) + } catch { + return '' + } +} + /** * The assembly context for one agent's prompt: the typed `agent` DX field and * the `scope` layer selector, set together (setting `agent` without `scope` diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 959ca4784c..be7bb02c67 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -9,6 +9,7 @@ import { Context, Service } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type { Agent, AgentId, AgentOptions } from './types.ts' +import { agentEvents } from './dispatch.ts' export * from './types.ts' export { agentEvents, assembleContextFor } from './dispatch.ts' @@ -141,9 +142,9 @@ export interface AgentFactory { * creation notifications in order, unlocks driving at * `agent/session-start`, and only then starts the loop. The sequence is * rollback-covered, but notifications delivered before a later listener - * failure remain observable; if agent announcement began, rollback emits - * `agent/disposed`, while the session entry is removed without a separate - * disposal event. The owner disposes the resolved handle to stop/drain, + * failure remain observable; every agent or session creation announcement + * that began is paired by `agent/disposed` or `session/disposed` during + * rollback. The owner disposes the resolved handle to stop/drain, * unregister, remove the session, and unwind the scope. * @param options - agent/session identity, configuration, and optional setup. * @returns the owned handle after setup, both announcements, and loop start complete. @@ -164,6 +165,33 @@ export interface AgentFactory { /** Thrown when create/resume is called before an agent factory is registered. */ const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plugin)' +/** Render an arbitrary thrown value without allowing coercion to throw again. */ +function renderThrown(value: unknown): string { + try { + return value instanceof Error ? `${value.name}: ${value.message}` : String(value) + } catch { + return '' + } +} + +/** + * Unforgeable ownership handle for one unpublished agent id. The factory holds + * this object across asynchronous setup; while it is live, ordinary public + * registration of that id fails, so setup cannot publish the factory's agent + * (or a replacement with the same id) ahead of the transaction. Callers obtain + * handles only from {@link AgentRegistry.reserve}. + */ +export interface AgentRegistrationReservation { + /** The reserved registry id. */ + readonly id: AgentId + /** + * Release the unpublished reservation; idempotent. The registry also + * releases it automatically when the fiber that called `reserve` disposes. + * @returns nothing. + */ + release(): void +} + /** * Agent registry (`ctx.agents`): tracks live agents so UI, hook, and * orchestrator plugins can find them without depending on the concrete loop @@ -173,6 +201,10 @@ const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plug */ export class AgentRegistry extends Service { private store = new Map() + /** The one accepted registry key for each live agent; never reread caller state. */ + private acceptedIds = new WeakMap() + /** Unpublished identities held across factory setup/load transactions. */ + private reservations = new Map() /** Entries whose `agent/created` announcement phase began. */ private announced = new WeakSet() private factory: AgentFactory | undefined @@ -188,6 +220,49 @@ export class AgentRegistry extends Service { ctx.accessor('agent', { get: () => undefined }) } + /** + * Reserve an unpublished agent id. Registration through {@link register} or + * bare {@link enter} fails until the returned capability is released; the + * owning factory passes the exact capability back to `enter` at publication. + * This makes “setup cannot publish” structural rather than a cooperative + * convention, including attempts to register a different object under the + * reserved id. The reservation belongs to the calling fiber and is released + * automatically if that owner unloads before the transaction settles. + * @param id - the id the factory transaction will publish. + * @returns the opaque reservation capability. + * @throws if the id is malformed, live, or already reserved. + */ + reserve(id: AgentId): AgentRegistrationReservation { + if (typeof id !== 'string') throw new TypeError('agent id must be a string') + if (this.store.has(id) || this.reservations.has(id)) { + throw new Error(`agent "${id}" is already registered or reserved`) + } + let active = true + const rawRelease = (): void => { + if (!active) return + active = false + this.reservations.delete(id) + } + let disposeEffect!: () => Promise | void + const reservation: AgentRegistrationReservation = Object.freeze({ + id, + release: () => { + rawRelease() + // Remove the now-inert ownership effect on manual transaction settle; + // its cleanup is the exact idempotent raw release above. + void disposeEffect() + }, + }) + this.reservations.set(id, reservation) + try { + disposeEffect = this.ctx.effect(() => rawRelease, `agents.reserve(${id})`) + } catch (error: unknown) { + rawRelease() + throw error + } + return reservation + } + /** * Register the agent-creation factory (the loop calls this on construction, * effect-scoped). Throws if a factory is already registered. Returns the @@ -269,43 +344,87 @@ export class AgentRegistry extends Service { * returned detach closure into its pre-installed composite teardown before * calling {@link announce}. Ordinary callers use {@link register}. * @param agent - the prepared, unpublished agent. + * @param reservation - the exact unpublished-id capability, when a factory + * reserved this id across setup. * @returns an idempotent closure that removes this exact entry and emits * `agent/disposed` with listener failures contained. */ - enter(agent: Agent): () => void { - if (this.store.has(agent.id)) { - throw new Error(`agent "${agent.id}" is already registered`) + enter(agent: Agent, reservation?: AgentRegistrationReservation): () => void { + const id = agent.id + if (typeof id !== 'string') throw new TypeError('agent id must be a string') + const held = this.reservations.get(id) + if (reservation === undefined) { + if (held !== undefined) throw new Error(`agent "${id}" is reserved for unpublished creation`) + } else if (reservation.id !== id || held !== reservation) { + throw new Error(`agent "${id}" registration reservation is not active for this id`) } - this.store.set(agent.id, agent) + if (this.acceptedIds.has(agent)) { + throw new Error(`agent "${id}" is already registered`) + } + if (this.store.has(id)) { + throw new Error(`agent "${id}" is already registered`) + } + try { + // Registration accepts ownership of the public identity contract. Pin an + // own data slot from the one captured value so a custom JavaScript Agent + // with a getter or writable field cannot later present a different id to + // event listeners while the registry still owns the accepted key. + Object.defineProperty(agent, 'id', { + value: id, + enumerable: true, + writable: false, + configurable: false, + }) + } catch { + // Only the engine's property-definition failure is swallowed; the stable + // public error below is the registration contract exposed to callers. + throw new TypeError('agent id must be installable as a stable own property') + } + this.store.set(id, agent) + this.acceptedIds.set(agent, id) let entered = true return () => { if (!entered) return entered = false - this.store.delete(agent.id) + this.store.delete(id) + this.acceptedIds.delete(agent) // An insertion rolled back before announce was never externally created, // so emitting disposed would invent an impossible lifecycle edge. Marking // happens before the created emit: if a later created listener throws, // earlier listeners may already have observed it and must see disposal. if (!this.announced.delete(agent)) return - try { - this.ctx.emit(scopeTarget(agent, agent), 'agent/disposed', agent) - } catch (error: unknown) { - this.ctx.logger.warn(`agent "${agent.id}": agent/disposed listener threw: ${String(error)}`) - } + agentEvents(this.ctx, agent).emit('agent/disposed') } } /** * Announce an agent previously inserted with {@link enter}. * @param agent - the live inserted agent to announce. - * @throws if `agent` is not the exact live registry entry for its id. + * @throws if `agent` is not the exact live registry entry for its id, or its + * creation announcement already began (including a reentrant call from a + * creation listener). */ announce(agent: Agent): void { - if (this.store.get(agent.id) !== agent) { - throw new Error(`agent "${agent.id}" is not live in this registry`) + const id = this.acceptedIds.get(agent) + if (id === undefined || this.store.get(id) !== agent) { + throw new Error(`agent "${id ?? ''}" is not live in this registry`) } + if (this.announced.has(agent)) { + throw new Error(`agent "${id}" was already announced`) + } + // Mark before dispatch so a listener cannot recursively create a second + // lifecycle edge; detach still pairs a partially delivered first edge. this.announced.add(agent) - this.ctx.emit(scopeTarget(agent, agent), 'agent/created', agent) + const args: unknown[] = [scopeTarget(agent, agent), 'agent/created', agent] + for (const callback of this.ctx.events.dispatch('emit', args)) { + // A synchronous creation failure vetoes publication and rolls back. + // Returned-promise rejection happens after this synchronous boundary, so + // observe and report it instead of leaking an unhandled rejection. + const returned: unknown = callback(...args) + void Promise.resolve(returned).catch((error: unknown) => { + this.ctx.logger.warn(`agent "${id}": agent/created listener rejected: ${renderThrown(error)}`) + }) + } } /** diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 4196eb43d6..b6a32fa050 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -288,7 +288,10 @@ declare module 'cordis' { * {@link AgentRegistry}. Its session is already live in the session store, * but concrete factories may keep driving verbs locked until the subsequent * `agent/session-start` boundary; that event is the first supported place - * to inject or queue work during startup. + * to inject or queue work during startup. A synchronous listener throw + * vetoes publication and rollback emits the matching disposal edges; + * returned-promise rejection is observed and logged but cannot + * retroactively veto this synchronous boundary. * @param agent - the newly registered agent with its live session and completed setup. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered * through `agent.ctx` fires only for that agent's dispatches; a listener on a diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 4e89dc0e84..87a0e51e9d 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Agent, AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { Agent, AgentId, agentEvents } from '@deepseek-ai/dsh-agent' function stubAgent(rawId: string): Agent { const id = AgentId(rawId) @@ -78,6 +78,32 @@ describe('AgentRegistry', () => { expect(ctx.agents.get(AgentId('main'))).toBeUndefined() }) + it('observes async agent/created rejection without rolling back or starving peers', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + const hostile = { [Symbol.toPrimitive]() { throw new Error('cannot stringify') } } + const heard: string[] = [] + ctx.on('agent/created', () => Promise.reject(new Error('ordinary async failure')) as never) + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- hostile thrown values are the boundary under test + ctx.on('agent/created', () => Promise.reject(hostile) as never) + ctx.on('agent/created', (agent) => { heard.push(agent.id) }) + + const agent = stubAgent('async-created') + const dispose = ctx.agents.register(agent) + await Promise.resolve() + await Promise.resolve() + + expect(ctx.agents.get(agent.id)).toBe(agent) + expect(heard).toEqual(['async-created']) + expect(warnings).toEqual([ + 'agent "async-created": agent/created listener rejected: Error: ordinary async failure', + 'agent "async-created": agent/created listener rejected: ', + ]) + await dispose() + }) + it('splits insertion from announcement and makes the detach exact/idempotent', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) @@ -107,6 +133,149 @@ describe('AgentRegistry', () => { // no disposed-without-created notification. expect(disposed).toEqual([first]) }) + + it('captures and pins one runtime id before insertion, announcement, and detach', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const existing = stubAgent('occupied') + const disposeExisting = ctx.agents.register(existing) + const candidate = stubAgent('placeholder') + let reads = 0 + Object.defineProperty(candidate, 'id', { + configurable: true, + get() { + reads += 1 + return reads === 1 ? AgentId('accepted') : AgentId('occupied') + }, + }) + + const detach = ctx.agents.enter(candidate) + expect(reads).toBe(1) + expect(candidate.id).toBe('accepted') + expect(reads).toBe(1) + expect(Object.getOwnPropertyDescriptor(candidate, 'id')).toMatchObject({ + configurable: false, + writable: false, + value: 'accepted', + }) + expect(ctx.agents.get(AgentId('accepted'))).toBe(candidate) + expect(ctx.agents.get(AgentId('occupied'))).toBe(existing) + expect(() => ctx.agents.enter(candidate)).toThrow(/already registered/) + + ctx.agents.announce(candidate) + detach() + expect(ctx.agents.get(AgentId('accepted'))).toBeUndefined() + expect(ctx.agents.get(AgentId('occupied'))).toBe(existing) + await disposeExisting() + + expect(() => ctx.agents.enter({ ...stubAgent('bad'), id: 42 } as unknown as Agent)) + .toThrow(/id must be a string/) + const pinnedAccessor = stubAgent('pinned') + Object.defineProperty(pinnedAccessor, 'id', { + configurable: false, + get: () => AgentId('pinned'), + }) + expect(() => ctx.agents.enter(pinnedAccessor)).toThrow(/installable as a stable own property/) + }) + + it('uses an opaque one-id reservation to gate unpublished factory insertion', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const held = ctx.agents.reserve(AgentId('held')) + + expect(() => ctx.agents.reserve(AgentId('held'))).toThrow(/already registered or reserved/) + expect(() => ctx.agents.enter(stubAgent('held'))).toThrow(/reserved for unpublished creation/) + const other = ctx.agents.reserve(AgentId('other')) + expect(() => ctx.agents.enter(stubAgent('held'), other)).toThrow(/not active for this id/) + + const agent = stubAgent('held') + const detach = ctx.agents.enter(agent, held) + ctx.agents.announce(agent) + held.release() + held.release() + expect(ctx.agents.get(AgentId('held'))).toBe(agent) + expect(() => ctx.agents.reserve(AgentId('held'))).toThrow(/already registered or reserved/) + detach() + other.release() + + const expired = ctx.agents.reserve(AgentId('expired')) + expired.release() + expect(() => ctx.agents.enter(stubAgent('expired'), expired)).toThrow(/not active for this id/) + expect(() => ctx.agents.reserve(42 as unknown as AgentId)).toThrow(/id must be a string/) + }) + + it('owns reservations by the calling fiber and rolls back failed ownership registration', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + let held!: import('@deepseek-ai/dsh-agent').AgentRegistrationReservation + let scopedAgents!: AgentRegistry + const owner = await ctx.plugin(Object.assign((inner: Context) => { + scopedAgents = inner.agents + held = inner.agents.reserve(AgentId('fiber-held')) + }, { inject: ['agents'] })) + + expect(() => ctx.agents.reserve(AgentId('fiber-held'))).toThrow(/already registered or reserved/) + await owner.dispose() + const reused = ctx.agents.reserve(AgentId('fiber-held')) + reused.release() + held.release() // idempotent after the automatic owner-disposal release + + // A disposed tracker cannot own a new effect. The failed effect install + // must remove the map entry it tentatively reserved before propagating. + expect(() => scopedAgents.reserve(AgentId('inactive-owner'))).toThrow(/inactive context/) + const recovered = ctx.agents.reserve(AgentId('inactive-owner')) + recovered.release() + }) + + it('rejects direct and reentrant repeat announcements to preserve one lifecycle pair', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + let created = 0 + let disposed = 0 + let reentrantError = '' + ctx.on('agent/created', (agent) => { + created += 1 + try { + ctx.agents.announce(agent) + } catch (error: unknown) { + reentrantError = String(error) + } + }) + ctx.on('agent/disposed', () => { disposed += 1 }) + + const agent = stubAgent('once') + const detach = ctx.agents.enter(agent) + ctx.agents.announce(agent) + expect(reentrantError).toMatch(/already announced/) + expect(() => { ctx.agents.announce(agent) }).toThrow(/already announced/) + detach() + expect({ created, disposed }).toEqual({ created: 1, disposed: 1 }) + }) +}) + +describe('agentEvents()', () => { + it('contains synchronous throws and returned-promise rejections per listener', async () => { + const ctx = new Context() + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + const agent = stubAgent('contained') + const heard: string[] = [] + const hostile = { [Symbol.toPrimitive]() { throw new Error('cannot stringify') } } + + ctx.on('agent/status', () => { throw hostile }) + ctx.on('agent/status', () => Promise.reject(new Error('async listener')) as never) + ctx.on('agent/status', (_subject, status) => { heard.push(status) }) + + expect(() => { agentEvents(ctx, agent).emit('agent/status', 'running') }).not.toThrow() + await Promise.resolve() + await Promise.resolve() + + expect(heard).toEqual(['running']) + expect(warnings).toEqual([ + 'agent event "agent/status" listener threw: ', + 'agent event "agent/status" listener rejected: Error: async listener', + ]) + }) }) describe('AgentRegistry factory seam', () => { diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md index f779dab57e..727acea4fb 100644 --- a/packages/core/scope/README.md +++ b/packages/core/scope/README.md @@ -9,7 +9,7 @@ Scoped-context registration primitive. `createScope(ctx, key)` mints a Cordis co - `Scope.rawDispose` The EXACT Cordis disposer for the backing fiber — a composite (generator) effect yields THIS function to nest the scope's teardown at that yield position (Cordis dedupes nested effects by function identity; yielding a wrapper leaves the scope disposing as a concurrent sibling). - `Scope.dispose(): Promise` Idempotent, shared quiescence boundary for every registration made through the scope. Racing/repeat calls await the same teardown, including when `rawDispose` invoked the underlying single-shot Cordis disposer first. - `scopeOf(ctx: Context): ScopeKey | undefined` The tag a context (or any context derived from it) carries; `undefined` = context-global. -- `scopeTarget(base: T, key?: ScopeKey): Scoped` Build the dispatch `thisArg` for a scope-filtered event: composes `base`'s own `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). Listener `this` stays `base`-shaped. `{ global: true }` listeners bypass filtering (Cordis semantics). +- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped` Build the dispatch `thisArg` for a scope-filtered event: capture and compose `base`'s own `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). The carrier uses a dedicated surrogate proxy target whose immutable filter slot cannot be replaced by a base property pinned before, during, or after construction; ordinary property access, writes, own-key visibility, methods, invocation, and construction delegate to `base`, and callable carriers match the base's constructable/non-constructable shape. For non-overlay base-owned properties, descriptor queries preserve values and flags except that configurable is normalized to `true`, as required to report those properties through an extensible surrogate. Listener `this` stays `base`-shaped. `{ global: true }` listeners bypass filtering (Cordis semantics). - `Scoped` The compile-time carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. - `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name. - `scopeHost(ctx, services)` Test/tooling host that snapshots the requested service list before activation, fails loud with stable missing-service diagnostics, and whose shared `dispose()` waits for both the host fiber and every minted scope, including a child already tearing down through `rawDispose`. diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index 276b406dcd..9c917fc89d 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -171,6 +171,20 @@ export function scopeOf(ctx: Context): ScopeKey | undefined { return (ctx as Context & { [kScope]?: ScopeKey })[kScope] } +/** Whether a callable has JavaScript's internal construction capability. */ +function isConstructable(value: (...args: unknown[]) => unknown): boolean { + try { + // A Proxy has [[Construct]] iff its target does. Its trap returns before + // the engine invokes `value` or reads `value.prototype`, so a hostile but + // constructable callable cannot be mistaken for a non-constructor. + Reflect.construct(new Proxy(value, { construct: () => ({}) }), []) + return true + } catch { + // The harmless outer trap leaves lack of [[Construct]] as the only failure. + return false + } +} + /** * Build the dispatch carrier for a scope-filtered event: `base` overlaid with * a `Context.filter` that admits a listener iff @@ -206,7 +220,10 @@ export function scopeOf(ctx: Context): ScopeKey | undefined { * @returns the carrier to pass as the dispatch `thisArg`. */ export function scopeTarget(base: T, key: ScopeKey | undefined): Scoped { - const baseFilter = (base as { [CordisContext.filter]?: (ctx: Context) => boolean })[CordisContext.filter] + const baseFilter: unknown = (base as { [CordisContext.filter]?: unknown })[CordisContext.filter] + if (baseFilter !== undefined && typeof baseFilter !== 'function') { + throw new TypeError('scope target Context.filter must be a function when present') + } const filter = (ctx: Context): boolean => { if (baseFilter && !baseFilter.call(base, ctx)) return false const tag = scopeOf(ctx) @@ -214,34 +231,57 @@ export function scopeTarget(base: T, key: ScopeKey | undefined } const overlay: Record = { [CordisContext.filter]: filter, - [kCarrier]: { key }, + [kCarrier]: Object.freeze({ key }), } - // A hand-rolled proxy, NOT cordis withProps: withProps delegates gets with - // the PROXY as receiver, so a getter on `base` runs with proxy `this` and a - // method call through the carrier gets a proxy receiver — either one throws - // on a native `#private` field of the subject (TypeError: private member - // not declared). Cordis hands the carrier to listeners as `this`, and the - // event declarations type it `Scoped` — so subject method calls - // through it are a SUPPORTED shape and must reach the real object: gets - // delegate with `base` as receiver, functions come back bound to `base`, - // and sets land on `base` directly. - return new Proxy(base, { + // Use a dedicated extensible proxy TARGET, never `base` itself. Proxy get + // invariants force a trap to return a base's non-configurable/non-writable + // own value verbatim; if a caller pinned Context.filter during or after + // construction, a base-target proxy would therefore silently replace the + // composed scope predicate with the caller's filter. The surrogate owns the + // two immutable overlay slots, so later descriptor changes on `base` cannot + // affect isolation. It shares the base prototype and delegates ordinary + // reads/writes/keys to preserve the supported transparent shape. Callable + // targets use native bound built-ins so V8 contributes no user-code surface; + // the chosen built-in matches whether `base` has [[Construct]], and the traps + // below delegate the actual call/construction to `base`. + const callableBase = typeof base === 'function' + ? base as unknown as (...args: unknown[]) => unknown + : undefined + const constructable = callableBase !== undefined && isConstructable(callableBase) + const target: object = callableBase === undefined + ? {} + : constructable + ? Object.bind(undefined) + : Math.max.bind(undefined) + Reflect.setPrototypeOf(target, Reflect.getPrototypeOf(base)) + Object.defineProperties(target, { + [CordisContext.filter]: { + value: filter, + enumerable: false, + writable: false, + configurable: false, + }, + [kCarrier]: { + value: overlay[kCarrier], + enumerable: false, + writable: false, + configurable: false, + }, + }) + const carrier = new Proxy(target, { get(target, prop) { - // Proxy get invariants pin what this trap may report for a - // non-configurable OWN property of the base: a non-writable data prop - // must be reported AS-IS (neither overlaid nor bound), a getterless - // accessor as undefined — checked FIRST so even an overlay key - // colliding with a frozen own prop of a (pathological) base yields the - // base's value instead of an engine TypeError. Such a base forgoes - // scope filtering; no production base freezes these keys. + // The callable surrogate has engine-owned pinned properties (`prototype`, + // `caller`, …); honor those target invariants. For object carriers the + // only pinned target properties are the exact overlay values above. const own = Reflect.getOwnPropertyDescriptor(target, prop) const pinned = own !== undefined && own.configurable === false && own.get === undefined && own.writable !== true - // hasOwn, not `in`: the overlay literal inherits Object.prototype, so - // `in` would claim `toString`/`constructor` and shadow the subject's. - if (!pinned && Object.hasOwn(overlay, prop)) return overlay[prop] - const value: unknown = Reflect.get(target, prop, target) - if (typeof value !== 'function' || pinned) return value + if (pinned) { + const value: unknown = Reflect.get(target, prop, target) + return value + } + const value: unknown = Reflect.get(base, prop, base) + if (typeof value !== 'function') return value // `constructor` is looked up, never invoked as a subject method — keep // the real one (withProps special-cases it the same way), so // `carrier.constructor` still identifies the subject's class. @@ -249,12 +289,66 @@ export function scopeTarget(base: T, key: ScopeKey | undefined // `Function.prototype.bind` types as `any`; the value is structurally // T[prop] and the trap's contract is untyped (`any`), so unknown is the // honest safe return. - return value.bind(target) as unknown + return value.bind(base) as unknown }, - set(target, prop, value) { - return Reflect.set(target, prop, value, target) + set(_target, prop, value) { + if (Object.hasOwn(overlay, prop)) return false + return Reflect.set(base, prop, value, base) }, - }) as Scoped + has(_target, prop) { + // A Proxy may not hide a non-configurable target key. Configurable + // surrogate-only keys (bound-function name/length) are omitted; the + // base's own/inherited surface remains authoritative. + const own = Reflect.getOwnPropertyDescriptor(target, prop) + return own?.configurable === false || Reflect.has(base, prop) + }, + ownKeys(target) { + const requiredTargetKeys = Reflect.ownKeys(target).filter((prop) => { + return Reflect.getOwnPropertyDescriptor(target, prop)?.configurable === false + }) + return [...new Set([...requiredTargetKeys, ...Reflect.ownKeys(base)])] + }, + getOwnPropertyDescriptor(target, prop) { + const targetDescriptor = Reflect.getOwnPropertyDescriptor(target, prop) + if (targetDescriptor?.configurable === false) return targetDescriptor + const baseDescriptor = Reflect.getOwnPropertyDescriptor(base, prop) + if (baseDescriptor !== undefined) return { ...baseDescriptor, configurable: true } + // Configurable surrogate-only function metadata is intentionally hidden. + return undefined + }, + defineProperty(_target, prop, attributes) { + if (Object.hasOwn(overlay, prop)) return false + return Reflect.defineProperty(base, prop, attributes) + }, + deleteProperty(_target, prop) { + if (Object.hasOwn(overlay, prop)) return false + return Reflect.deleteProperty(base, prop) + }, + preventExtensions() { + // Keeping the surrogate extensible is required for ownKeys to report + // caller-owned base fields that may change over the carrier's lifetime. + return false + }, + setPrototypeOf() { + // The carrier prototype and base delegation must not be split. + return false + }, + apply(_target, thisArg, args) { + const callable = callableBase as (...values: unknown[]) => unknown + const result: unknown = Reflect.apply(callable, thisArg, args) + return result + }, + construct(_target, args, newTarget) { + const constructor = callableBase as unknown as new (...values: unknown[]) => object + const result: unknown = Reflect.construct( + constructor, + args, + newTarget === carrier ? constructor : newTarget, + ) + return result as object + }, + }) + return carrier as Scoped } /** @@ -266,10 +360,8 @@ export function scopeTarget(base: T, key: ScopeKey | undefined * @returns true iff `value` came from {@link scopeTarget}. */ export function isScopeCarrier(value: unknown): value is Scoped { - if (typeof value !== 'object' || value === null) return false - // A property READ, not an `in` check: the carrier overlays its marks in the - // get trap only (no `has` trap), so `kCarrier in carrier` would fall - // through to the wrapped base and always answer false. + if ((typeof value !== 'object' && typeof value !== 'function') || value === null) return false + // A property read checks the immutable marker owned by the surrogate target. return (value as { [kCarrier]?: { key: ScopeKey | undefined } })[kCarrier] !== undefined } diff --git a/packages/core/scope/tests/scope.spec.ts b/packages/core/scope/tests/scope.spec.ts index e572584fb1..5b379d15e5 100644 --- a/packages/core/scope/tests/scope.spec.ts +++ b/packages/core/scope/tests/scope.spec.ts @@ -232,7 +232,7 @@ describe('scopeTarget dispatch filtering', () => { expect(detached()).toBe(2) }) - it('delegates sets to the base and leaves frozen own function props unbound (proxy invariant)', () => { + it('delegates the ordinary reflective surface while keeping overlays immutable', () => { const frozenFn = (): string => 'frozen' const base: { mutable: number; pinned: () => string; toString: () => string } = { mutable: 0, @@ -243,25 +243,158 @@ describe('scopeTarget dispatch filtering', () => { const carrier = scopeTarget(base, undefined) carrier.mutable = 7 expect(base.mutable).toBe(7) // sets land on the base, not a detached overlay - // A non-configurable, non-writable own data prop must be reported - // unchanged (binding it would violate the proxy get invariant). - expect(carrier.pinned).toBe(frozenFn) + // The surrogate target frees reads from the base property's proxy + // invariant, so even a frozen own method can be safely bound to the base. + expect(carrier.pinned).not.toBe(frozenFn) + expect(carrier.pinned()).toBe('frozen') // The overlay literal inherits Object.prototype; hasOwn (not `in`) keeps // it from shadowing the subject's own prototype-surface members. expect(String(carrier)).toBe('base-str') + expect('mutable' in carrier).toBe(true) + expect(Object.hasOwn(carrier, 'mutable')).toBe(true) + expect(Object.keys(carrier)).toEqual(['mutable', 'pinned', 'toString']) + Object.defineProperty(carrier, 'extra', { value: 1, configurable: true }) + expect((base as typeof base & { extra?: number }).extra).toBe(1) + expect(delete (carrier as typeof carrier & { extra?: number }).extra).toBe(true) + expect(Reflect.preventExtensions(carrier)).toBe(false) + expect(Reflect.setPrototypeOf(carrier, null)).toBe(false) }) - it('honors the get invariant even when an overlay key collides with a frozen own prop of the base', () => { - // Pathological but engine-enforced: a base whose own [Context.filter] is - // a non-configurable, non-writable data prop pins what any proxy over it - // may report for that key. The carrier must yield the base's value (an - // overlay there would be a runtime TypeError from the engine, not a - // filtering choice). Such a base forgoes scope filtering by construction. + it('keeps isolation when the base filter is pinned before, during, or after construction', async () => { + const ctx = new Context() + const keyA = { name: 'A' } + const keyB = { name: 'B' } + const scopeA = await mintScope(ctx, keyA) + const scopeB = await mintScope(ctx, keyB) + const heard: string[] = [] + ctx.on('scope-test/ping', value => void heard.push(`global:${value}`)) + scopeA.ctx.on('scope-test/ping', value => void heard.push(`A:${value}`)) + scopeB.ctx.on('scope-test/ping', value => void heard.push(`B:${value}`)) const pinnedFilter = (): boolean => true - const base = {} - Object.defineProperty(base, Context.filter, { value: pinnedFilter, writable: false, configurable: false }) - const carrier = scopeTarget(base, { name: 'key' }) - expect((carrier as Record)[Context.filter]).toBe(pinnedFilter) + + const pinnedData = {} + Object.defineProperty(pinnedData, Context.filter, { + value: pinnedFilter, + writable: false, + configurable: false, + }) + const pinnedCarrier = scopeTarget(pinnedData, keyA) + ctx.emit(pinnedCarrier, 'scope-test/ping', 'before') + + const duringRead = {} + Object.defineProperty(duringRead, Context.filter, { + configurable: true, + get() { + Object.defineProperty(duringRead, Context.filter, { + value: pinnedFilter, + writable: false, + configurable: false, + }) + return pinnedFilter + }, + }) + ctx.emit(scopeTarget(duringRead, keyA), 'scope-test/ping', 'during') + + const pinnedAfter = { [Context.filter]: pinnedFilter } + const afterCarrier = scopeTarget(pinnedAfter, keyA) + Object.defineProperty(pinnedAfter, Context.filter, { + value: pinnedFilter, + writable: false, + configurable: false, + }) + ctx.emit(afterCarrier, 'scope-test/ping', 'after') + + const pinnedGetterless = {} + Object.defineProperty(pinnedGetterless, Context.filter, { set(_value: unknown) {}, configurable: false }) + ctx.emit(scopeTarget(pinnedGetterless, keyA), 'scope-test/ping', 'getterless') + + expect(heard).toEqual([ + 'global:before', 'A:before', + 'global:during', 'A:during', + 'global:after', 'A:after', + 'global:getterless', 'A:getterless', + ]) + expect((pinnedCarrier as Record)[Context.filter]).not.toBe(pinnedFilter) + expect(Reflect.set(pinnedCarrier, Context.filter, pinnedFilter)).toBe(false) + expect(Reflect.defineProperty(pinnedCarrier, Context.filter, { value: pinnedFilter })).toBe(false) + expect(Reflect.deleteProperty(pinnedCarrier, Context.filter)).toBe(false) + + expect(() => scopeTarget({ [Context.filter]: 1 }, { name: 'A' })).toThrow( + /Context\.filter must be a function/, + ) + }) + + it('preserves callable and constructable bases', () => { + function Subject(this: { value?: number }, value: number): number { + if (new.target) { + this.value = value + return value + } + return value * 2 + } + const carrier = scopeTarget(Subject as typeof Subject & (new (value: number) => { value: number }), { + name: 'callable', + }) + + const called: unknown = Reflect.apply(carrier, { value: 0 }, [3]) + expect(called).toBe(6) + const instance = new carrier(4) + expect(instance).toBeInstanceOf(Subject) + expect(instance.value).toBe(4) + const prototypeDescriptor = Object.getOwnPropertyDescriptor(carrier, 'prototype') + const subjectPrototype: unknown = Reflect.get(Subject, 'prototype') + expect(prototypeDescriptor?.configurable).toBe(true) + expect(prototypeDescriptor?.value).toBe(subjectPrototype) + class Derived extends carrier {} + const derived = new Derived(5) + expect(derived).toBeInstanceOf(Derived) + expect(derived).toBeInstanceOf(Subject) + expect(derived.value).toBe(5) + expect(isScopeCarrier(carrier)).toBe(true) + }) + + it('matches non-constructable and bound-constructor function shapes', () => { + const arrow = (value: number): number => value + 1 + const arrowCarrier = scopeTarget(arrow, { name: 'arrow' }) + const arrowResult: unknown = Reflect.apply(arrowCarrier, undefined, [2]) + expect(arrowResult).toBe(3) + expect('prototype' in arrowCarrier).toBe(false) + expect(Object.getOwnPropertyDescriptor(arrowCarrier, 'prototype')).toBeUndefined() + expect(() => { Reflect.construct(arrowCarrier, []) }).toThrow(TypeError) + + class Subject { + constructor(readonly value: number) {} + } + const bound = Subject.bind(undefined, 7) + const boundCarrier = scopeTarget(bound, { name: 'bound-constructor' }) + expect('prototype' in boundCarrier).toBe(false) + expect(Object.getOwnPropertyDescriptor(boundCarrier, 'prototype')).toBeUndefined() + const instance = new boundCarrier() + expect(instance).toBeInstanceOf(Subject) + expect(instance.value).toBe(7) + }) + + it('detects construction without reading a hostile base prototype', () => { + class Subject { + constructor(readonly value: number) {} + } + let prototypeReads = 0 + const hostile = new Proxy(Subject, { + get(target, prop, receiver) { + if (prop === 'prototype') { + prototypeReads += 1 + throw new Error('hostile prototype getter') + } + return Reflect.get(target, prop, receiver) as unknown + }, + }) + + const carrier = scopeTarget(hostile, { name: 'hostile-constructor' }) + expect(prototypeReads).toBe(0) + const instance: unknown = Reflect.construct(carrier, [9], Subject) + expect(instance).toBeInstanceOf(Subject) + expect(instance).toMatchObject({ value: 9 }) + expect(prototypeReads).toBe(0) }) it('keeps the real constructor: class identity survives the carrier', () => { diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 36052d9e7a..29f89e35c8 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -4,7 +4,7 @@ Event-sourced session log and in-memory store. A `Session` is the append-only so ## Service: `SessionStore` (ctx key: `sessions`) -Creates and holds event-sourced `Session` instances. Persistence is intentionally not implemented here — plugins subscribe to `session/event` and flush on `session/flush`. +Creates and holds event-sourced `Session` instances. Persistence is intentionally not implemented here — plugins subscribe to `session/event`, flush on `session/flush`, and may mirror the paired `session/created`/`session/disposed` lifecycle. ### Public API @@ -16,17 +16,18 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall #### Advanced: ordered-teardown lifecycle primitives -`create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before `onAppend` detaches — `create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect: +`create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before the store-owned append observer detaches — `create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect: - `ctx.sessions.prepare(id?, options?): Session` — read `options.seed`/`options.meta` once, validate and detach the metadata/header, and construct the `Session` WITHOUT entering it into the store. Same options as `create`. -- `ctx.sessions.enter(session): () => void` — wire `onAppend` → `session/event`, capture its scope carrier, and add the session to the store; returns the idempotent DETACH disposer, which clears both notification and carrier state. Does NOT emit `session/created` (the caller installs the disposer first, then calls `announce`, so a throwing listener rolls the attach back). It re-checks the id because public `prepare`/`enter` calls may be interleaved; a stale prepared object must not overwrite a live same-id session. -- `ctx.sessions.announce(session): void` — emit `session/created` for an entered session. +- `ctx.sessions.reserve(id): SessionRegistrationReservation` — hold an unpublished id under the calling fiber and construct its one owned Session through `reservation.prepare(options?)`. Until `release()` or owner unload, bare `prepare`/`create`/`enter` calls for that id reject; the factory later presents the exact capability to `enter`, making setup-time publication structurally impossible without leaking an abandoned reservation across HMR disposal. +- `ctx.sessions.enter(session, reservation?): () => void` — install the module-private `session/event` observer, capture its scope carrier, and add the session under one accepted id; returns the idempotent DETACH disposer, which clears notification, carrier, and accepted-key state. Does NOT emit `session/created` (the caller installs the disposer first, then calls `announce`, so a throwing listener rolls the attach back). It re-checks the id because public `prepare`/`enter` calls may be interleaved; a stale prepared object must not overwrite a live same-id session. A factory passes the opaque capability from `reserve(id)` so setup cannot enter the reserved session or publish a same-id replacement before the owning transaction. +- `ctx.sessions.announce(session): void` — begin the one allowed `session/created` announcement for an entered session; repeat and reentrant calls reject before dispatch. Its detach emits `session/disposed` exactly once, including rollback after a partially delivered creation notification; a never-announced entry emits neither edge. `dsh-agent-loop` is the canonical consumer: after unpublished agent setup it enters both session and agent before announcing either, then nests loop stop, agent removal, session detach, and scope unwind in one ordered lifecycle. The final flush therefore settles before this package detaches the session, whether teardown starts from an `AgentHandle` or owner-fiber unload. ### Live service events -The store announces creation, publishes each append, and provides an awaited durability checkpoint. Exact `session/*` signatures, modes, and scope-carrier behavior live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the append-only payload vocabulary is separately generated into the [persistence catalog](../../../docs/persistence-catalog.md). Persistence consumers write behind from the append notification and drain on the store-owned flush entry point rather than dispatching the event directly. +The store pairs announced creation with disposal, publishes each append, and provides an awaited durability checkpoint. Disposal listener failures, including returned-promise rejections, are contained per observer so teardown cannot be interrupted. Exact `session/*` signatures, modes, and scope-carrier behavior live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the append-only payload vocabulary is separately generated into the [persistence catalog](../../../docs/persistence-catalog.md). Persistence consumers write behind from the append notification and drain on the store-owned flush entry point rather than dispatching the event directly. ### Class: `Session` @@ -37,8 +38,8 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. - `session.deriveEventMessage(event): Message | null` — the per-event projection `deriveMessages()` folds: one event's derived message (an unfrozen clone), or `null` when it produces none (a non-surface event, or an empty-content `assistant/message` hosting only usage). External reconstructors and the dev invariant fold the same function over a log prefix's surface, so no two paths can disagree about what a request's messages were (the reconstructability RFC). - `session.surface: SurfaceManager` — the derived surface, lazily rebuilt from `surfaceOp` markers in the log. Processes only new events (delta) on each access — the log is append-only, so prior events never change. `surface.replaceGeneration` is the rewrite signal: bumped by every folded `replace` and by `invalidate()`, never reset, so an incremental consumer comparing generations cannot be fooled. - `session.events` — a cached, frozen array snapshot over deep-frozen events. Repeated reads without an append return the same array; an append invalidates the cache and the next read returns a new snapshot, while earlier snapshots stay unchanged. Neither a cast nor a retained reference can push into the live log or rewrite an accepted event. -- `session.seq`, `session.id` -- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Construction validates its lossless-JSON shape and requires the header id to match `session.id`, so a caller cannot later mutate persistence routing or lineage through an aliased header. Kept out of the event log (a storage concern, not replayable state); a minimal header (stamped with the current `SESSION_FORMAT_VERSION`) is synthesized for bare `Session` construction. +- `session.seq`, `session.id` — `id` is a non-writable, non-configurable runtime identity slot, not merely TypeScript-readonly. +- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`) published through a non-writable, non-configurable slot. Construction validates its lossless-JSON shape and requires the header id to match `session.id`, so a caller cannot later replace or mutate persistence routing or lineage. Kept out of the event log (a storage concern, not replayable state); a minimal header (stamped with the current `SESSION_FORMAT_VERSION`) is synthesized for bare `Session` construction. ### Lossless JSON utilities diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 51500e9e1b..b000e7083b 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -34,7 +34,10 @@ declare module 'cordis' { interface Events { /** - * A session was created in the store. + * 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. * 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 @@ -45,6 +48,18 @@ declare module 'cordis' { * @mode emit */ 'session/created'(this: Scoped, session: Session): void + /** + * 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. + * @param session - the session that is no longer live in the store. + * @mode emit + */ + 'session/disposed'(this: Scoped, session: Session): void /** * An event was appended to a session log (sync, fire-and-forget). This is * the per-append feed a UI or invariant plugin tails. @@ -253,6 +268,17 @@ function assertSessionEventEnvelope(value: Record, index: numbe } } +/** Render an arbitrary thrown value without allowing coercion to throw again. */ +function renderThrown(value: unknown): string { + try { + return value instanceof Error ? `${value.name}: ${value.message}` : String(value) + } catch { + return '' + } +} + +const appendObservers = new WeakMap void>() + /** * An event-sourced session: an append-only log of {@link SessionEvent}s. * @@ -261,8 +287,6 @@ function assertSessionEventEnvelope(value: Record, index: numbe */ export class Session { private log: SessionEvent[] = [] - /** Set by the store so appends are observable; undefined when detached. */ - onAppend: ((event: SessionEvent) => void) | undefined /** * Derived surface — a cached linked list of message-producing events. @@ -336,6 +360,14 @@ export class Session { }) } this.header = snapshotSessionHeader(id, header) + // TypeScript readonly prevents ordinary typed assignment only. Pin both + // public identity bindings at runtime too: setup/plugins receive the live + // Session object, and replacing either slot would split registry keys, + // persistence routing, and the already-validated header. + Object.defineProperties(this, { + id: { value: id, enumerable: true, writable: false, configurable: false }, + header: { value: this.header, enumerable: true, writable: false, configurable: false }, + }) } /** Cached immutable public snapshot of the private append-only log. */ @@ -359,8 +391,8 @@ export class Session { /** * Append one typed event to the log and synchronously notify observers via - * `onAppend`. The hot path never blocks on I/O — persistence plugins buffer - * asynchronously. + * the store-owned, module-private append observer. The hot path never blocks + * on I/O — persistence plugins buffer asynchronously. * * @param type - The event type (key of {@link SessionEventMap}). * @param data - The event payload; must be JSON-serializable. @@ -444,7 +476,7 @@ export class Session { const acceptedEvent = deepFreeze(event) this.log.push(acceptedEvent as unknown as SessionEvent) this.eventsSnapshot = undefined - this.onAppend?.(acceptedEvent as unknown as SessionEvent) + appendObservers.get(this)?.(acceptedEvent as unknown as SessionEvent) return acceptedEvent } @@ -599,6 +631,29 @@ export class SessionForkError extends Error { } } +/** + * Unforgeable ownership handle for one unpublished session id. A factory keeps + * this capability across load/setup, preventing setup code from entering the + * prepared Session or publishing a replacement under the same id. Obtain it + * only from {@link SessionStore.reserve}. + */ +export interface SessionRegistrationReservation { + /** The reserved store id. */ + readonly id: SessionId + /** + * Construct the one Session owned by this reservation. + * @param options - seed events and creation metadata. + * @returns the still-unpublished Session. + */ + prepare(options?: CreateSessionOptions): Session + /** + * Release the unpublished reservation; idempotent. The store also releases + * it automatically when the fiber that called `reserve` disposes. + * @returns nothing. + */ + release(): void +} + /** * In-memory session store (`ctx.sessions`). * @@ -607,6 +662,14 @@ export class SessionForkError extends Error { */ export class SessionStore extends Service { private store = new Map() + /** The one accepted map key for each live session; never reread caller state. */ + private acceptedIds = new WeakMap() + /** Sessions whose creation announcement began and therefore require a pair. */ + private announced = new WeakSet() + /** Unpublished identities held across factory load/setup transactions. */ + private reservations = new Map() + /** The exact prepared object owned by each reservation capability. */ + private reservedSessions = new WeakMap() /** * Each live session's dispatch carrier, captured at {@link enter} from the * ENTERING context's scope tag (an agent session is entered through @@ -621,6 +684,59 @@ export class SessionStore extends Service { super(ctx, 'sessions') } + /** + * Reserve one unpublished session id across an asynchronous factory + * transaction. Bare `prepare`/`create`/`enter` calls for the id reject until + * release; the capability constructs exactly one Session and is passed back + * to {@link enter} at publication. The reservation belongs to the calling + * fiber, so owner unload releases an abandoned id automatically. + * @param id - the session id the transaction will publish. + * @returns the opaque reservation capability. + * @throws if the id is malformed, live, or already reserved. + */ + reserve(id: SessionId): SessionRegistrationReservation { + if (typeof id !== 'string') throw new TypeError('session id must be a string') + if (this.store.has(id) || this.reservations.has(id)) { + throw new Error(`session "${id}" already exists or is reserved`) + } + let active = true + let prepared = false + const rawRelease = (): void => { + if (!active) return + active = false + this.reservedSessions.delete(reservation) + this.reservations.delete(id) + } + let disposeEffect!: () => Promise | void + const reservation: SessionRegistrationReservation = Object.freeze({ + id, + prepare: (options?: CreateSessionOptions) => { + if (!active) { + throw new Error(`session "${id}" reservation is no longer active`) + } + if (prepared) throw new Error(`session "${id}" reservation already prepared a session`) + prepared = true + const session = this.prepareReserved(id, options, reservation) + this.reservedSessions.set(reservation, session) + return session + }, + release: () => { + rawRelease() + // Remove the now-inert ownership effect on manual transaction settle; + // its cleanup is the exact idempotent raw release above. + void disposeEffect() + }, + }) + this.reservations.set(id, reservation) + try { + disposeEffect = this.ctx.effect(() => rawRelease, `sessions.reserve(${id})`) + } catch (error: unknown) { + rawRelease() + throw error + } + return reservation + } + /** * Create a session owned by the calling fiber: disposing that fiber stops * event notification and removes the session from the store. `options.seed` @@ -630,7 +746,7 @@ export class SessionStore extends Service { * fills `version`/`id`/`createdAt`). * * For an agent whose session must be torn down IN ORDER with its loop (so the - * loop's final flush is captured before `onAppend` detaches), do NOT use this + * loop's final flush is captured before the store-owned observer detaches), do NOT use this * — fold the session lifecycle into the agent's own effect via * {@link prepare} + {@link enter} + {@link announce} (see `dsh-agent-loop`'s * `startOwned`). @@ -647,7 +763,7 @@ export class SessionStore extends Service { // Single effect owned by the calling fiber. Yield the detach BEFORE // announcing so a throwing `session/created` listener rolls the attach back // (the generator effect disposes already-yielded disposers on a throw) - // instead of leaking the store entry + onAppend. + // instead of leaking the store entry + append observer. this.ctx.effect(function* (this: SessionStore) { yield this.enter(session) this.announce(session) @@ -661,7 +777,7 @@ export class SessionStore extends Service { * Pairs with {@link enter} + {@link announce}: a caller that owns a composite * `ctx.effect` (the agent factory) folds the session lifecycle into that ONE * effect so a fiber unload tears the session + agent down as a single ORDERED - * chain rather than as racing sibling effects — which would detach `onAppend` + * chain rather than as racing sibling effects — which would detach the append observer * before the loop's closing `session/flush`, dropping the closing events. * * @param id - the session id; omitted, the store mints `session-`. @@ -672,7 +788,27 @@ export class SessionStore extends Service { * non-absolute path. */ prepare(id?: SessionId, options?: CreateSessionOptions): Session { - const sessionId = SessionId(id ?? `session-${++this.counter}`) + return this.prepareReserved(id, options) + } + + /** Shared prepare implementation, optionally authorized by a reservation. */ + private prepareReserved( + id?: SessionId, + options?: CreateSessionOptions, + reservation?: SessionRegistrationReservation, + ): Session { + let sessionId: SessionId + if (id === undefined) { + do sessionId = SessionId(`session-${++this.counter}`) + while (this.store.has(sessionId) || this.reservations.has(sessionId)) + } else { + sessionId = SessionId(id) + } + if (typeof sessionId !== 'string') throw new TypeError('session id must be a string') + const held = this.reservations.get(sessionId) + if (reservation === undefined && held !== undefined) { + throw new Error(`session "${sessionId}" is reserved for unpublished creation`) + } if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`) const seed = options?.seed const meta = snapshotSessionMeta(options?.meta) @@ -691,9 +827,9 @@ export class SessionStore extends Service { } /** - * Enter a {@link prepare}d session into the store: wire `onAppend` → - * `session/event` and add it to the store. Returns the DETACH disposer - * (`onAppend = undefined` + store removal). Does NOT emit `session/created` — + * Enter a {@link prepare}d session into the store: wire the module-private + * append observer to `session/event` and add it to the store. Returns the + * DETACH disposer (observer + store removal). Does NOT emit `session/created` — * the caller yields this disposer inside its effect and THEN calls * {@link announce}, so a throwing `session/created` listener rolls the attach * back instead of leaking it. @@ -707,11 +843,23 @@ export class SessionStore extends Service { * assume that. * * @param session - a {@link prepare}d session not yet in the store. - * @returns the detach disposer (`onAppend = undefined` + store removal). + * @param reservation - the exact unpublished-id capability when a factory + * reserved this session across setup. + * @returns the detach disposer (observer + store removal). * @throws if a session with this id is already in the store. */ - enter(session: Session): () => void { - if (this.store.has(session.id)) throw new Error(`session "${session.id}" already exists`) + enter(session: Session, reservation?: SessionRegistrationReservation): () => void { + const id = session.id + if (typeof id !== 'string') throw new TypeError('session id must be a string') + const held = this.reservations.get(id) + if (reservation === undefined) { + if (held !== undefined) throw new Error(`session "${id}" is reserved for unpublished creation`) + } else if (reservation.id !== id || held !== reservation + || this.reservedSessions.get(reservation) !== session) { + throw new Error(`session "${id}" registration reservation does not own this prepared session`) + } + if (this.store.has(id)) throw new Error(`session "${id}" already exists`) + if (appendObservers.has(session)) throw new Error(`session "${id}" is already attached to a store`) // The carrier is decided HERE, once, from the ENTERING context's scope tag // (`this.ctx` is the caller's context — the tracker mechanism): every // session/created|event|flush dispatch for this session uses it, so the @@ -720,24 +868,65 @@ export class SessionStore extends Service { const carrier = scopeTarget(session, scopeOf(this.ctx)) this.carriers.set(session, carrier) const emitCtx = this.ctx - session.onAppend = (event) => { emitCtx.emit(carrier, 'session/event', session, event) } - this.store.set(session.id, session) + appendObservers.set(session, (event) => { emitCtx.emit(carrier, 'session/event', session, event) }) + this.acceptedIds.set(session, id) + this.store.set(id, session) let entered = true return () => { if (!entered) return entered = false - session.onAppend = undefined + const wasAnnounced = this.announced.delete(session) + appendObservers.delete(session) + this.acceptedIds.delete(session) this.carriers.delete(session) - this.store.delete(session.id) + this.store.delete(id) + if (wasAnnounced) this.emitDisposed(session, carrier, id) } } - /** Emit `session/created` for an {@link enter}ed session (with the carrier - * {@link enter} captured). Separate from {@link enter} so the caller can - * yield the detach disposer first (rollback safety — see {@link enter}). - * @param session - the entered session to announce to listeners. */ + /** Emit `session/created` exactly once for an {@link enter}ed session (with + * the carrier {@link enter} captured). Separate from {@link enter} so the + * caller can yield the detach disposer first (rollback safety — see + * {@link enter}). + * @param session - the entered session to announce to listeners. + * @throws if the session is not live or its announcement already began, + * including a reentrant call from a creation listener. */ announce(session: Session): void { - this.ctx.emit(this.liveCarrierFor(session), 'session/created', session) + const carrier = this.liveCarrierFor(session) + if (this.announced.has(session)) { + throw new Error(`session "${session.id}" was already announced`) + } + // Mark before emit: Cordis emit may deliver to earlier listeners and then + // throw. Rollback must still pair that partial creation with disposal, and + // a listener cannot recursively create a second lifecycle edge. + this.announced.add(session) + const args: unknown[] = [carrier, 'session/created', session] + for (const callback of this.ctx.events.dispatch('emit', args)) { + // Synchronous throws intentionally propagate and veto publication; the + // yielded detach then emits the paired disposal edge. An async function + // is nevertheless assignable to a void listener, so observe its returned + // promise: rejection is too late to roll back and must be logged instead + // of becoming unhandled. + const returned: unknown = callback(...args) + void Promise.resolve(returned).catch((error: unknown) => { + this.ctx.logger.warn(`session "${session.id}": session/created listener rejected: ${renderThrown(error)}`) + }) + } + } + + /** Emit the paired teardown notification with per-listener containment. */ + private emitDisposed(session: Session, carrier: Scoped, id: SessionId): void { + const args: unknown[] = [carrier, 'session/disposed', session] + for (const callback of this.ctx.events.dispatch('emit', args)) { + try { + const returned: unknown = callback(...args) + void Promise.resolve(returned).catch((error: unknown) => { + this.ctx.logger.warn(`session "${id}": session/disposed listener rejected: ${renderThrown(error)}`) + }) + } catch (error: unknown) { + this.ctx.logger.warn(`session "${id}": session/disposed listener threw: ${renderThrown(error)}`) + } + } } /** @@ -756,8 +945,9 @@ export class SessionStore extends Service { /** Return the exact live session's carrier; detached/prepared objects reject. */ private liveCarrierFor(session: Session): Scoped { - if (this.store.get(session.id) !== session) { - throw new Error(`session "${session.id}" is not live in this store`) + const id = this.acceptedIds.get(session) + if (id === undefined || this.store.get(id) !== session) { + throw new Error(`session "${id ?? session.id}" is not live in this store`) } const carrier = this.carriers.get(session) // enter() installs store + carrier in one synchronous sequence; a live @@ -765,7 +955,7 @@ export class SessionStore extends Service { // to subject-less dispatch (that would silently cross scope boundaries). /* v8 ignore next -- enter installs store and carrier in one synchronous sequence */ if (carrier === undefined) { - throw new Error(`session "${session.id}" has no dispatch carrier`) + throw new Error(`session "${id}" has no dispatch carrier`) } return carrier } diff --git a/packages/core/session/tests/scoped.spec.ts b/packages/core/session/tests/scoped.spec.ts index ee1e710397..80dd8510d2 100644 --- a/packages/core/session/tests/scoped.spec.ts +++ b/packages/core/session/tests/scoped.spec.ts @@ -60,6 +60,23 @@ describe('session dispatch carriers', () => { bare.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) expect(heard).toEqual(['global:turn/start']) }) + + it('reuses the captured owner carrier for the paired disposal notification', async () => { + const ctx = await mount() + const owner = await mintScope(ctx, 'owner') + const other = await mintScope(ctx, 'other') + const heard: string[] = [] + ctx.on('session/disposed', (session) => { heard.push(`global:${session.id}`) }) + owner.ctx.on('session/disposed', (session) => { heard.push(`owner:${session.id}`) }) + other.ctx.on('session/disposed', (session) => { heard.push(`other:${session.id}`) }) + + const session = owner.ctx.sessions.prepare() + const detach = owner.ctx.sessions.enter(session) + owner.ctx.sessions.announce(session) + detach() + + expect(heard).toEqual([`global:${session.id}`, `owner:${session.id}`]) + }) }) describe('sessions.flush()', () => { diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index f7037609e6..ac0f06123c 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -578,6 +578,17 @@ describe('Session', () => { expect(session.header).not.toBe(input) expect(Object.isFrozen(session.header)).toBe(true) expect(Reflect.set(session.header, 'cwd', '/published-mutated')).toBe(false) + expect(Reflect.set(session, 'id', SessionId('redirected'))).toBe(false) + expect(Reflect.set(session, 'header', input)).toBe(false) + expect(Object.getOwnPropertyDescriptor(session, 'id')).toMatchObject({ + configurable: false, + writable: false, + }) + expect(Object.getOwnPropertyDescriptor(session, 'header')).toMatchObject({ + configurable: false, + writable: false, + }) + expect(session.id).toBe('header-owned') expect(session.header.cwd).toBe('/accepted') }) @@ -691,6 +702,10 @@ describe('SessionStore', () => { const session = ctx.sessions.create() expect(created).toEqual([session]) + // The store-owned append observer is module-private. A JavaScript caller + // may create an unrelated property with the old implementation's name, + // but cannot suppress the durable event feed. + expect(Reflect.set(session, 'onAppend', undefined)).toBe(true) session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) expect(events).toHaveLength(1) expect(events[0]![0]).toBe(session) @@ -746,6 +761,114 @@ describe('SessionStore', () => { expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined() }) + it('captures the accepted id once and prevents simultaneous attachment to two stores', async () => { + const firstCtx = new Context() + const secondCtx = new Context() + await firstCtx.plugin(SessionStore) + await secondCtx.plugin(SessionStore) + const session = new Session(SessionId('owned-key')) + const detachFirst = firstCtx.sessions.enter(session) + + expect(Reflect.set(session, 'id', SessionId('redirected'))).toBe(false) + expect(() => secondCtx.sessions.enter(session)).toThrow(/already attached to a store/) + expect(firstCtx.sessions.get(SessionId('owned-key'))).toBe(session) + + detachFirst() + expect(firstCtx.sessions.get(SessionId('owned-key'))).toBeUndefined() + const detachSecond = secondCtx.sessions.enter(session) + expect(secondCtx.sessions.get(SessionId('owned-key'))).toBe(session) + detachSecond() + + expect(() => firstCtx.sessions.enter({ id: 42 } as unknown as Session)).toThrow(/id must be a string/) + }) + + it('uses an opaque one-session reservation to gate unpublished factory insertion', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const held = ctx.sessions.reserve(SessionId('held-session')) + + expect(() => ctx.sessions.reserve(SessionId('held-session'))).toThrow(/already exists or is reserved/) + expect(() => ctx.sessions.prepare(SessionId('held-session'))).toThrow(/reserved for unpublished creation/) + expect(() => ctx.sessions.create(SessionId('held-session'))).toThrow(/reserved for unpublished creation/) + const session = held.prepare({ meta: { cwd: '/held' } }) + expect(() => held.prepare()).toThrow(/already prepared/) + expect(() => ctx.sessions.enter(session)).toThrow(/reserved for unpublished creation/) + + const other = ctx.sessions.reserve(SessionId('other-session')) + expect(() => ctx.sessions.enter(session, other)).toThrow(/does not own this prepared session/) + expect(() => ctx.sessions.enter(new Session(SessionId('held-session')), held)) + .toThrow(/does not own this prepared session/) + + const detach = ctx.sessions.enter(session, held) + ctx.sessions.announce(session) + held.release() + held.release() + expect(ctx.sessions.get(SessionId('held-session'))).toBe(session) + expect(() => ctx.sessions.reserve(SessionId('held-session'))).toThrow(/already exists or is reserved/) + detach() + other.release() + + const expired = ctx.sessions.reserve(SessionId('expired-session')) + expired.release() + expect(() => expired.prepare()).toThrow(/no longer active/) + expect(() => ctx.sessions.enter(new Session(SessionId('expired-session')), expired)) + .toThrow(/does not own this prepared session/) + expect(() => ctx.sessions.reserve(42 as unknown as SessionId)).toThrow(/id must be a string/) + expect(() => ctx.sessions.prepare(42 as unknown as SessionId)).toThrow(/id must be a string/) + + // Auto-generated ids skip unpublished reservations just as they skip live + // store entries; no hidden collision can be published later. + const firstAuto = ctx.sessions.reserve(SessionId('session-1')) + expect(ctx.sessions.prepare().id).toBe('session-2') + firstAuto.release() + }) + + it('owns reservations by the calling fiber and rolls back failed ownership registration', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + let held!: import('@deepseek-ai/dsh-session').SessionRegistrationReservation + let scopedSessions!: SessionStore + const owner = await ctx.plugin(Object.assign((inner: Context) => { + scopedSessions = inner.sessions + held = inner.sessions.reserve(SessionId('fiber-held')) + }, { inject: ['sessions'] })) + + expect(() => ctx.sessions.reserve(SessionId('fiber-held'))).toThrow(/already exists or is reserved/) + await owner.dispose() + const reused = ctx.sessions.reserve(SessionId('fiber-held')) + reused.release() + held.release() // idempotent after the automatic owner-disposal release + + expect(() => scopedSessions.reserve(SessionId('inactive-owner'))).toThrow(/inactive context/) + const recovered = ctx.sessions.reserve(SessionId('inactive-owner')) + recovered.release() + }) + + it('rejects direct and reentrant repeat announcements to preserve one lifecycle pair', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + let created = 0 + let disposed = 0 + let reentrantError = '' + ctx.on('session/created', (session) => { + created += 1 + try { + ctx.sessions.announce(session) + } catch (error: unknown) { + reentrantError = String(error) + } + }) + ctx.on('session/disposed', () => { disposed += 1 }) + + const session = ctx.sessions.prepare(SessionId('once')) + const detach = ctx.sessions.enter(session) + ctx.sessions.announce(session) + expect(reentrantError).toMatch(/already announced/) + expect(() => { ctx.sessions.announce(session) }).toThrow(/already announced/) + detach() + expect({ created, disposed }).toEqual({ created: 1, disposed: 1 }) + }) + it('synthesizes a minimal current-version header for a bare-created session', async () => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -864,11 +987,13 @@ describe('SessionStore', () => { expect(observed).toBe(0) }) - it('rolls back the session (and onAppend) when a session/created listener throws (P1-1)', async () => { + it('pairs a partial session/created announcement with disposal during rollback', async () => { const ctx = new Context() await ctx.plugin(SessionStore) let threw = false + const disposed: Session[] = [] + ctx.on('session/disposed', (session) => { disposed.push(session) }) ctx.on('session/created', () => { if (!threw) { threw = true; throw new Error('boom created listener') } }) @@ -876,9 +1001,10 @@ describe('SessionStore', () => { // The throwing emit must roll the store entry back, not leak it. expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('boom created listener') expect(ctx.sessions.get(SessionId('fixed'))).toBeUndefined() // rolled back, not leaked + expect(disposed.map(session => session.id)).toEqual(['fixed']) // A subsequent create of the SAME id succeeds (the already-exists check is - // not wedged) and its onAppend is correctly wired (events observable). + // not wedged) and its store-owned observer is correctly wired (events observable). const events: SessionEvent[] = [] ctx.on('session/event', (_session, event) => void events.push(event)) const session = ctx.sessions.create(SessionId('fixed')) @@ -886,6 +1012,59 @@ describe('SessionStore', () => { session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) expect(events).toHaveLength(1) }) + + it('observes async session/created rejection without rolling back or starving peers', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + const heard: string[] = [] + ctx.on('session/created', () => Promise.reject(new Error('late creation failure')) as never) + ctx.on('session/created', (session) => { heard.push(session.id) }) + + const session = ctx.sessions.create(SessionId('async-created')) + await Promise.resolve() + await Promise.resolve() + + expect(ctx.sessions.get(session.id)).toBe(session) + expect(heard).toEqual(['async-created']) + expect(warnings).toEqual([ + 'session "async-created": session/created listener rejected: Error: late creation failure', + ]) + }) + + it('contains synchronous and async session/disposed listener failures per observer', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + const hostile = { [Symbol.toPrimitive]() { throw new Error('cannot stringify') } } + const printable = { toString: () => 'printable failure' } + const heard: string[] = [] + ctx.on('session/disposed', () => { throw hostile }) + ctx.on('session/disposed', () => Promise.reject(new Error('async disposed')) as never) + ctx.on('session/disposed', () => { throw printable }) + ctx.on('session/disposed', (session) => { heard.push(session.id) }) + + const unannounced = ctx.sessions.prepare(SessionId('never-announced')) + const detachUnannounced = ctx.sessions.enter(unannounced) + detachUnannounced() + expect(heard).toEqual([]) + + const announced = ctx.sessions.prepare(SessionId('contained-disposal')) + const detach = ctx.sessions.enter(announced) + ctx.sessions.announce(announced) + expect(() => { detach() }).not.toThrow() + await Promise.resolve() + await Promise.resolve() + + expect(heard).toEqual(['contained-disposal']) + expect(warnings).toEqual([ + 'session "contained-disposal": session/disposed listener threw: ', + 'session "contained-disposal": session/disposed listener threw: printable failure', + 'session "contained-disposal": session/disposed listener rejected: Error: async disposed', + ]) + }) }) describe('todo/write event', () => { diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 439ebebfc7..dd395b3f88 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -13,10 +13,10 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool- ### Public API -- `ctx.systemPrompt.section(section: PromptSection): () => Promise | void` Contribute a section. The registry snapshots `name`, `order`, and the text value/callback, so later caller-object mutation cannot rename a stored section. The layer is the CALLING context's scope: `agent.ctx` contributes to that agent alone, SHADOWING a same-named global section there (the per-agent persona mechanism — a scoped `deployment:persona`). Duplicate names within one layer throw, and a globally protected section name cannot be shadowed. Disposed with the calling fiber. -- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise | void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set for `context.scope`; `knownNames` (defaulting to the same captured schemas' names) is the pre-restriction universe `toolOrder` validates against. Assembly reads the result, each schema field, and the optional known-name list once before detaching them, rejects non-string schema names/descriptions or known names, and uses those same accepted strings for validation and the model-visible collection. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber. -- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => Promise | void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables (via `agent.ctx`) shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber. -- `ctx.systemPrompt.protect(protection: PromptProtection): () => Promise | void` Make named section/tool contributions authoritative after the assembly waterfall. Protection restores canonical registry/provider presence and definition; restored entries keep canonical order with one another and anchor before their first surviving later unprotected canonical neighbor (or at the end), without undoing listener reordering of unprotected entries. Canonical absence is authoritative too, so a mode-hidden tool cannot be fabricated by a listener. Calling through `agent.ctx` protects only that agent's assemblies. A global section protection additionally reserves its name against scoped shadows; registering either side of that conflict fails loudly instead of treating the shadow as canonical. Each input array is read once and snapshotted, empty protections throw, and disposal removes the protection. +- `ctx.systemPrompt.section(section: PromptSection): () => Promise | void` Contribute a section. The registry reads `name`, `order`, and the text value/callback once, validates their fixed string/finite-number/string-or-function types, and stores only that accepted record; later caller-object mutation cannot rename or reshape it. The layer is the CALLING context's scope: `agent.ctx` contributes to that agent alone, SHADOWING a same-named global section there (the per-agent persona mechanism — a scoped `deployment:persona`). Duplicate names within one layer throw, and a globally protected section name cannot be shadowed. Disposed with the calling fiber. +- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise | void` Contribute tool schemas, evaluated at each assembly with that assembly's context; a non-function provider rejects before effect storage. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set for `context.scope`; `knownNames` (defaulting to the same captured schemas' names) is the pre-restriction universe `toolOrder` validates against. Assembly reads the result, each schema field, and the optional known-name list once before detaching them, rejects non-string schema names/descriptions or known names, and uses those same accepted strings for validation and the model-visible collection. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber. +- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => Promise | void` Contribute a prompt variable, referenced from section text as `{{name}}`. The fixed string name and function provider types reject before effect storage. Scoped variables (via `agent.ctx`) shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber. +- `ctx.systemPrompt.protect(protection: PromptProtection): () => Promise | void` Make named section/tool contributions authoritative after the assembly waterfall. Protection restores canonical registry/provider presence and definition; restored entries keep canonical order with one another and anchor before their first surviving later unprotected canonical neighbor (or at the end), without undoing listener reordering of unprotected entries. Canonical absence is authoritative too, so a mode-hidden tool cannot be fabricated by a listener. Calling through `agent.ctx` protects only that agent's assemblies. A global section protection additionally reserves its name against scoped shadows; registering either side of that conflict fails loudly instead of treating the shadow as canonical. Each optional field and array slot is read once, non-array fields or non-string names reject before effect storage, and the accepted arrays are deduplicated and frozen. Finalization materializes each waterfall-produced entry name once, so a stateful getter cannot evade canonical replacement. Empty protections throw, and disposal removes the protection. - `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer (scoped shadows global). Provider output becomes one coherent detached snapshot before `toolOrder` validation. Runs through the scope-filtered `system-prompt/assemble` waterfall, then restores protected contributions from the pre-waterfall canonical assembly. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe (a restricted-away KNOWN tool is a normal absence), or when a provider returns the reserved rest-entry name. ### Live events diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 8d3cc501a8..b810d07559 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -234,11 +234,41 @@ function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined, knownN name === TOOL_ORDER_REST ? rest : tools.filter(tool => tool.name === name)) } +/** Snapshot one waterfall-produced named entry with a stable, own data `name`. */ +function snapshotNamedEntry(entry: T): { entry: T; name: string } { + // Read the name exactly once before protection matching. The waterfall owns + // its output and may return accessor-backed records; retaining such an entry + // would let a getter answer "unprotected" during filtering and the protected + // name later when a consumer reads the final assembly. + const name = entry.name + const snapshot: Record = {} + Object.defineProperty(snapshot, 'name', { + value: name, + enumerable: true, + configurable: true, + writable: true, + }) + // Copy every other enumerable field once while deliberately skipping name. + // defineProperty keeps a literal "__proto__" extension field ordinary data. + for (const key of Object.keys(entry)) { + if (key === 'name') continue + Object.defineProperty(snapshot, key, { + value: (entry as unknown as Record)[key], + enumerable: true, + configurable: true, + writable: true, + }) + } + return { entry: snapshot as T, name } +} + /** Restore protected named entries from `canonical`, anchored before their next unprotected canonical neighbor. */ function restoreProtected( canonical: readonly T[], result: readonly T[], protectedNames: ReadonlySet, ): T[] { - const restored = result.filter(entry => !protectedNames.has(entry.name)) + const restored = result + .map(snapshotNamedEntry) + .filter(record => !protectedNames.has(record.name)) for (const [index, entry] of canonical.entries()) { if (!protectedNames.has(entry.name)) continue // Protected entries are inserted in canonical order. Anchor each one @@ -251,9 +281,29 @@ function restoreProtected( .map(candidate => candidate.name), ) const next = restored.findIndex(candidate => following.has(candidate.name)) - restored.splice(next < 0 ? restored.length : next, 0, structuredClone(entry)) + restored.splice(next < 0 ? restored.length : next, 0, { + entry: structuredClone(entry), + name: entry.name, + }) } - return restored + return restored.map(record => record.entry) +} + +/** Validate and detach one protection-name array without rereading an element. */ +function snapshotProtectionNames(value: unknown, field: 'sections' | 'tools'): readonly string[] { + if (!Array.isArray(value)) { + throw new TypeError(`systemPrompt.protect() ${field} must be an array of strings`) + } + const names: string[] = [] + const length = value.length + for (let index = 0; index < length; index += 1) { + const name: unknown = value[index] + if (typeof name !== 'string') { + throw new TypeError(`systemPrompt.protect() ${field} must be an array of strings`) + } + names.push(name) + } + return Object.freeze([...new Set(names)]) } /** Lexicographic (code-unit) name comparison — locale-independent, so the order is identical on every machine. */ @@ -433,9 +483,10 @@ export class SystemPrompt extends Service { * `deployment:persona`) unless that global name is protected: global * protection reserves its section name against scoped shadows so the * registration owner—not a later scope—defines the canonical value. The - * registry snapshots `name`, `order`, and `text` before checking/storing, so - * later caller-object mutation cannot rename a contribution. Throws - * if the SAME layer already has the name (a + * registry reads `name`, `order`, and `text` once, validates their fixed + * string/finite-number/string-or-function types, and stores only that + * accepted record, so later caller-object mutation cannot rename or reshape + * a contribution. Throws if the SAME layer already has the name (a * duplicate would silently double prompt text — e.g. a double-loaded tool * plugin; the global-duplicate message names `agent.ctx` as the per-agent * alternative). Removed when the calling fiber is disposed. Emits @@ -446,12 +497,23 @@ export class SystemPrompt extends Service { * yield it directly — exact identity nests the teardown in order. */ section(section: PromptSection): () => Promise | void { - const scope = scopeOf(this.ctx) - const snapshot: PromptSection = { - name: section.name, - order: section.order, - text: section.text, + const input: unknown = section + if (typeof input !== 'object' || input === null) { + throw new TypeError('systemPrompt.section() requires a section object') } + const accepted = input as PromptSection + const name = accepted.name + const order = accepted.order + const text = accepted.text + if (typeof name !== 'string') throw new TypeError('prompt section name must be a string') + if (typeof order !== 'number' || !Number.isFinite(order)) { + throw new TypeError(`prompt section "${name}" order must be a finite number`) + } + if (typeof text !== 'string' && typeof text !== 'function') { + throw new TypeError(`prompt section "${name}" text must be a string or function`) + } + const scope = scopeOf(this.ctx) + const snapshot: PromptSection = { name, order, text } if (scope !== undefined && this.protections.some(record => record.sections?.includes(snapshot.name))) { throw new Error(`prompt section "${snapshot.name}" is globally protected and cannot be shadowed in an agent scope`) } @@ -498,7 +560,8 @@ export class SystemPrompt extends Service { * `schemas`/`knownNames` split). The layer is decided by the calling * context: a scoped provider (registered through `agent.ctx`) is consulted * only for that scope's assemblies. Removed when the calling fiber is - * disposed. A provider must not return a schema named + * disposed. A non-function provider is rejected before any effect is stored. + * A provider must not return a schema named * {@link TOOL_ORDER_REST}; that name is reserved for * {@link Config.toolOrder}'s rest entry and rejects the assembly. Emits * `system-prompt/change`. @@ -508,6 +571,9 @@ export class SystemPrompt extends Service { * yield it directly — exact identity nests the teardown in order. */ tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise | void { + if (typeof provider !== 'function') { + throw new TypeError('system prompt tool provider must be a function') + } const scope = scopeOf(this.ctx) const dispose = this.ctx.effect(function* (this: SystemPrompt) { const layer = scope === undefined @@ -545,10 +611,11 @@ export class SystemPrompt extends Service { * deployment must not claim facts it does not have). The layer is decided * by the calling context: a scoped variable (registered through * `agent.ctx`) resolves only for that scope's assemblies and SHADOWS a - * same-named global variable there. Throws on a name that does not match - * `[a-z][a-z0-9_]*` (it could never be referenced) or one already - * registered in the SAME layer. Removed when the calling fiber is disposed; - * emits `system-prompt/change` on register/unregister. + * same-named global variable there. The fixed name and callback types are + * validated before effect storage. Throws on a name that does not match + * `[a-z][a-z0-9_]*` (it could never be referenced) or one already registered + * in the SAME layer. Removed when the calling fiber is disposed; emits + * `system-prompt/change` on register/unregister. * @param name - the reference name (matches `[a-z][a-z0-9_]*`). * @param provider - evaluated at every {@link assemble} for the value. * @returns the disposer that removes the variable. The exact @@ -556,11 +623,16 @@ export class SystemPrompt extends Service { * yield it directly — exact identity nests the teardown in order. */ variable(name: string, provider: (context: AssembleContext) => string | undefined): () => Promise | void { + const inputName: unknown = name + if (typeof inputName !== 'string') throw new TypeError('prompt variable name must be a string') + if (!VARIABLE_NAME.test(inputName)) { + throw new Error(`invalid prompt variable name "${inputName}" (must match ${String(VARIABLE_NAME)})`) + } + if (typeof provider !== 'function') { + throw new TypeError(`prompt variable "${inputName}" provider must be a function`) + } const scope = scopeOf(this.ctx) const dispose = this.ctx.effect(function* (this: SystemPrompt) { - if (!VARIABLE_NAME.test(name)) { - throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`) - } const layer = scope === undefined ? this.variableProviders : this.scopedVariableProviders.get(scope) ?? (() => { @@ -599,9 +671,13 @@ export class SystemPrompt extends Service { * restored AFTER the whole waterfall, so listener registration order cannot * strip, replace, duplicate, or fabricate it. Canonical absence is restored * too: if the protected name is intentionally absent for an assembly, a - * listener-injected entry with that name is removed. Each input array is - * read once and snapshotted; an empty protection throws because it cannot - * affect output. + * listener-injected entry with that name is removed. Each optional field and + * array slot is read once; non-array fields or non-string names reject before + * effect storage, and the accepted deduplicated arrays are frozen. During + * finalization each waterfall-produced entry name is likewise read once into + * an owned data record, so a stateful getter cannot look unprotected during + * filtering and later impersonate a protected name. An empty protection + * throws because it cannot affect output. * Removed with the calling fiber and emits `system-prompt/change` on * registration/unregistration. A global section protection also reserves the * name against scoped section shadows; registering protection when such a @@ -610,13 +686,24 @@ export class SystemPrompt extends Service { * @returns the exact Cordis effect disposer that removes the protection. */ protect(protection: PromptProtection): () => Promise | void { - const scope = scopeOf(this.ctx) - const sections = protection.sections - const tools = protection.tools - const snapshot: PromptProtection = { - ...sections !== undefined ? { sections: [...new Set(sections)] } : {}, - ...tools !== undefined ? { tools: [...new Set(tools)] } : {}, + const input: unknown = protection + if (typeof input !== 'object' || input === null) { + throw new TypeError('systemPrompt.protect() requires a protection object') } + const accepted = input as PromptProtection + const inputSections = accepted.sections + const inputTools = accepted.tools + const sections = inputSections === undefined + ? undefined + : snapshotProtectionNames(inputSections, 'sections') + const tools = inputTools === undefined + ? undefined + : snapshotProtectionNames(inputTools, 'tools') + const scope = scopeOf(this.ctx) + const snapshot: PromptProtection = Object.freeze({ + ...sections !== undefined ? { sections } : {}, + ...tools !== undefined ? { tools } : {}, + }) if ((snapshot.sections?.length ?? 0) === 0 && (snapshot.tools?.length ?? 0) === 0) { throw new Error('systemPrompt.protect() requires at least one section or tool name') } diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index 9eef467a8e..c37ddd9c6c 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -111,6 +111,86 @@ describe('SystemPrompt', () => { expect(contributed(assembly).map(s => s.text)).toEqual(['first']) }) + it('rejects malformed fixed registration fields before storing an effect', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + const badName = { value: 'name' } + const badText = { value: 'text' } + + expect(() => ctx.systemPrompt.section(null as unknown as Parameters[0])) + .toThrow('requires a section object') + expect(() => ctx.systemPrompt.section(1 as unknown as Parameters[0])) + .toThrow('requires a section object') + expect(() => ctx.systemPrompt.section({ name: badName as unknown as string, order: 1, text: 'x' })) + .toThrow('prompt section name must be a string') + expect(() => ctx.systemPrompt.section({ name: 'bad-order', order: '1' as unknown as number, text: 'x' })) + .toThrow('order must be a finite number') + expect(() => ctx.systemPrompt.section({ name: 'bad-order', order: Number.NaN, text: 'x' })) + .toThrow('order must be a finite number') + expect(() => ctx.systemPrompt.section({ name: 'bad-text', order: 1, text: badText as unknown as string })) + .toThrow('text must be a string or function') + expect(() => ctx.systemPrompt.tools(1 as unknown as Parameters[0])) + .toThrow('tool provider must be a function') + expect(() => ctx.systemPrompt.variable({} as unknown as string, () => 'x')) + .toThrow('prompt variable name must be a string') + expect(() => ctx.systemPrompt.variable('valid', 1 as unknown as Parameters[1])) + .toThrow('provider must be a function') + expect(() => ctx.systemPrompt.protect(null as unknown as Parameters[0])) + .toThrow('requires a protection object') + expect(() => ctx.systemPrompt.protect(1 as unknown as Parameters[0])) + .toThrow('requires a protection object') + expect(() => ctx.systemPrompt.protect({ sections: 'x' as unknown as string[] })) + .toThrow('sections must be an array of strings') + expect(() => ctx.systemPrompt.protect({ tools: 'x' as unknown as string[] })) + .toThrow('tools must be an array of strings') + expect(() => ctx.systemPrompt.protect({ sections: ['ok', {} as unknown as string] })) + .toThrow('sections must be an array of strings') + expect(() => ctx.systemPrompt.protect({ tools: [{} as unknown as string] })) + .toThrow('tools must be an array of strings') + + expect(Object.isFrozen(badName)).toBe(false) + expect(Object.isFrozen(badText)).toBe(false) + expect(contributed(await ctx.systemPrompt.assemble())).toEqual([]) + }) + + it('reads each section field and protection-name slot once at registration', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + const reads = { name: 0, order: 0, text: 0, sections: 0, item: 0 } + const section = Object.defineProperties({}, { + name: { + enumerable: true, + get: () => (++reads.name === 1 ? 'stable' : 42), + }, + order: { + enumerable: true, + get: () => (++reads.order === 1 ? 10 : Number.NaN), + }, + text: { + enumerable: true, + get: () => (++reads.text === 1 ? 'stable text' : null), + }, + }) as unknown as Parameters[0] + const names = new Array(1) + Object.defineProperty(names, 0, { + enumerable: true, + get: () => (++reads.item === 1 ? 'stable' : 'drifted'), + }) + const protection = { + get sections(): string[] { + reads.sections += 1 + return reads.sections === 1 ? names : ['drifted'] + }, + } + + ctx.systemPrompt.section(section) + ctx.systemPrompt.protect(protection) + const assembly = await ctx.systemPrompt.assemble() + + expect(reads).toEqual({ name: 1, order: 1, text: 1, sections: 1, item: 1 }) + expect(assembly.sections).toContainEqual({ name: 'stable', order: 10, text: 'stable text' }) + }) + it('rolls back a section when a system-prompt/change listener throws (P1-1)', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) @@ -282,6 +362,56 @@ describe('SystemPrompt', () => { expect(assembly.sections).toContainEqual({ name: 'protected', order: 10, text: 'canonical' }) }) + it('materializes waterfall entry names once before restoring protected definitions', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + ctx.systemPrompt.section({ name: 'protected', order: 10, text: 'canonical section' }) + ctx.systemPrompt.tools(() => ({ schemas: [{ name: 'protected', description: 'canonical tool', parameters: {} }] })) + ctx.systemPrompt.protect({ sections: ['protected'], tools: ['protected'] }) + let sectionNameReads = 0 + let toolNameReads = 0 + const hostileSection = { + get name(): string { + sectionNameReads += 1 + return sectionNameReads === 1 ? 'impostor-section' : 'protected' + }, + order: 999, + text: 'listener section', + } + const hostileTool = { + get name(): string { + toolNameReads += 1 + return toolNameReads === 1 ? 'impostor-tool' : 'protected' + }, + description: 'listener tool', + parameters: {}, + } + ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { + const result = await next() + result.sections = [ + ...result.sections.filter(section => section.name !== 'protected'), + hostileSection, + ] + result.tools = [ + ...result.tools.filter(tool => tool.name !== 'protected'), + hostileTool, + ] + return result + }) + + const assembly = await ctx.systemPrompt.assemble() + + expect(sectionNameReads).toBe(1) + expect(toolNameReads).toBe(1) + expect(assembly.sections.map(section => section.name)).toEqual([ + 'harness:identity', + 'deployment:persona', + 'impostor-section', + 'protected', + ]) + expect(assembly.tools.map(tool => tool.name)).toEqual(['impostor-tool', 'protected']) + }) + it('protects canonical absence and rejects an empty protection', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 0776ff39f6..74f1ff14c4 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -22,7 +22,7 @@ tools: - `ctx.tools.knownNames(scope?: ScopeKey): string[]` The PRE-restriction end-capability name universe `restrict` validates against: a typo fails loud while a restricted-away tool stays a normal absence. Presentation providers add reserved transport names separately when validating `toolOrder`. - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)). - `ctx.tools.guard(guard: ToolGuard): () => Promise | void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber. -- `ctx.tools.execute(exec: ToolExecutionInput): Promise` Read each caller-owned top-level field once, snapshot the single-use call into a pipeline-owned execution, assign its opaque correlation token, materialize `arguments` through one lossless-JSON traversal, deep-freeze them, and protect identity before running `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute`; optional `signal` is the only operational field an around-dispatch wrapper may add, replace, or remove. After the required `callId`/`name` correlation identity is captured, the same captured optional fields build the normalized error shell if a later accessor or validation fails, so policy, dispatch, routing, and `tools/result` cannot observe different caller values. Every top-level result field is likewise captured once and the complete result or post-decision is losslessly materialized before final observation. Invalid input—including cloneable mutable exotics—and malformed or non-JSON listener/tool results normalize to `isError` outcomes rather than bypassing policy or failing later at the session log. A throwing `callId` or `name` accessor rejects because no trustworthy result identity exists yet. +- `ctx.tools.execute(exec: ToolExecutionInput): Promise` Read each caller-owned top-level field once, require `callId` and `name` to yield strings, snapshot the single-use call into a pipeline-owned execution, assign its opaque correlation token, materialize `arguments` through one lossless-JSON traversal, deep-freeze them, and protect identity before running `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute`; optional `signal` is the only operational field an around-dispatch wrapper may add, replace, or remove. After the required string correlation identity is captured, the same captured optional fields build the normalized error shell if a later accessor or validation fails, so policy, dispatch, routing, and `tools/result` cannot observe different caller values. Every top-level result field is likewise captured once and the complete result or post-decision is losslessly materialized before final observation. Invalid later input—including cloneable mutable exotics—and malformed or non-JSON listener/tool results normalize to `isError` outcomes rather than bypassing policy or failing later at the session log. A throwing accessor or non-string value in `callId` or `name` rejects before `tools/result` because no trustworthy result identity exists yet. ### Injected services @@ -35,7 +35,7 @@ The live registry pipeline has three transformable waterfalls followed by the ow ### Key types - `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). It also carries an optional cooperative timeout budget `timeoutMs?: number` (ms) enforced by `@deepseek-ai/dsh-timeout-policy`, never sent to the model. Registration stores a frozen snapshot with detached JSON parameters and once-bound callback identities. -- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; `arguments` must be losslessly JSON-serializable, and callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token. +- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; `callId` and `name` must be strings, `arguments` must be losslessly JSON-serializable, and callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token. - `ToolExecutionToken` — a frozen, property-free identity value assigned by the registry. It supports equality correlation only and exposes no live outer execution state. - `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object. - `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. The registry validates the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). `additionalContext` (a `HookContext`) ferries any `tools/post-execute` context up to the loop, which buffers it and appends it as a `context/message` after all `tool/result`s in the step. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards it onto the `tool/result` session event for result-card rendering. diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index b466875c75..d757f6cffa 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -951,22 +951,33 @@ export class ToolRegistry extends Service { * Caller-owned arguments are validated and detached in one recursive * lossless-JSON traversal; a violation normalizes to an error before policy * or dispatch. - * @param exec - the single-use call input; every top-level field is read once - * and that identity snapshot is protected before policy runs (and reused by - * the normalized error shell if validation fails). - * @returns the final result after every waterfall. Once the required - * `callId` and `name` correlation identity has been captured, later - * accessor, validation, listener, and tool failures resolve as `isError` - * results rather than rejections. A throwing `callId` or `name` accessor - * rejects because no trustworthy result identity exists yet. + * @param exec - the single-use call input; every top-level field is read once. + * `callId` and `name` must each yield a string before that identity snapshot + * is protected and policy begins. + * @returns the final result after every waterfall. Once the required string + * `callId` and `name` correlation identity has been captured, later accessor, + * validation, listener, and tool failures resolve as `isError` results rather + * than rejections. A throwing accessor or non-string value in either identity + * field rejects because no trustworthy result correlation exists yet. */ async execute(exec: ToolExecutionInput): Promise { // callId/name are the minimum correlation identity needed to construct a - // result at all. Every other caller-controlled accessor is read once - // INSIDE the normalization boundary; if one throws, the error shell uses - // the fields captured before it and never rereads the hostile record. + // result at all. Capture each once, then validate the captured scalar before + // anything can treat it as a trustworthy identity. A JavaScript/casted + // caller that supplies another type rejects at this outer boundary: an error + // result carrying the same malformed value would not satisfy the correlation + // contract and might itself fail lossless-JSON materialization. Every other + // caller-controlled accessor is read once INSIDE the normalization boundary; + // if one throws, the error shell uses the fields captured before it and never + // rereads the hostile record. const callId = exec.callId const name = exec.name + if (typeof callId !== 'string') { + throw new TypeError('tool execution callId must be a string') + } + if (typeof name !== 'string') { + throw new TypeError('tool execution name must be a string') + } let agent: Agent | undefined let parent: ToolExecutionToken | undefined let signal: AbortSignal | undefined diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 5bff94c960..1b83a8f633 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -7,7 +7,7 @@ import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@de import ToolRegistry, { defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, type DefineToolOptions, type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision, - type ToolDefinition, type ToolExecution, type ToolExecutionResult, type ToolGuard, + type ToolDefinition, type ToolExecution, type ToolExecutionInput, type ToolExecutionResult, type ToolGuard, } from '@deepseek-ai/dsh-tools' async function setup() { @@ -83,6 +83,95 @@ describe('ToolRegistry', () => { expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'hi' }], isError: false }) }) + it.each([ + { field: 'callId', value: 1n }, + { field: 'callId', value: 123 }, + { field: 'name', value: 1n }, + { field: 'name', value: 123 }, + ] as const)('rejects a non-string $field before final observation', async ({ field, value }) => { + const ctx = await setup() + let observed = 0 + ctx.on('tools/result', () => { observed += 1 }) + const input: Record = { + callId: CallId('valid-call'), + name: 'missing', + arguments: {}, + } + input[field] = value + + await expect(ctx.tools.execute(input as unknown as ToolExecutionInput)) + .rejects.toThrow(`tool execution ${field} must be a string`) + expect(observed).toBe(0) + }) + + it('reads correlation accessors once and normalizes a later hostile accessor', async () => { + const ctx = await setup() + const reads = { callId: 0, name: 0, arguments: 0 } + let observed: { callId: unknown; name: unknown; isError: boolean } | undefined + ctx.on('tools/result', (exec, result) => { + observed = { callId: exec.callId, name: exec.name, isError: result.isError } + }) + const input = Object.defineProperties({}, { + callId: { + enumerable: true, + get: () => { + reads.callId += 1 + if (reads.callId > 1) throw new Error('callId reread') + return CallId('one-read-call') + }, + }, + name: { + enumerable: true, + get: () => { + reads.name += 1 + if (reads.name > 1) throw new Error('name reread') + return 'missing' + }, + }, + arguments: { + enumerable: true, + get: () => { + reads.arguments += 1 + throw new Error('arguments accessor broke') + }, + }, + }) as unknown as ToolExecutionInput + + const result = await ctx.tools.execute(input) + + expect(reads).toEqual({ callId: 1, name: 1, arguments: 1 }) + expect(result).toMatchObject({ callId: CallId('one-read-call'), isError: true }) + expect(result.content[0]).toMatchObject({ text: 'Error: arguments accessor broke' }) + expect(observed).toEqual({ callId: CallId('one-read-call'), name: 'missing', isError: true }) + }) + + it('reads callId once before a hostile name accessor rejects correlation', async () => { + const ctx = await setup() + const reads = { callId: 0, name: 0 } + let observed = 0 + ctx.on('tools/result', () => { observed += 1 }) + const input = Object.defineProperties({ arguments: {} }, { + callId: { + enumerable: true, + get: () => { + reads.callId += 1 + return CallId('hostile-name') + }, + }, + name: { + enumerable: true, + get: () => { + reads.name += 1 + throw new Error('name accessor broke') + }, + }, + }) as unknown as ToolExecutionInput + + await expect(ctx.tools.execute(input)).rejects.toThrow('name accessor broke') + expect(reads).toEqual({ callId: 1, name: 1 }) + expect(observed).toBe(0) + }) + it('threads a tool-attached meta (object return form) onto the result', async () => { const ctx = await setup() ctx.tools.register({ diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index e9f7beb540..e5b40ccfdc 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -9,11 +9,11 @@ The shared **in-process subagent run driver**. A library with no provider or imp Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`): 1. reads every public request and seed field once before asynchronous owner setup: the parent and signal remain identity capabilities, while tool filter, seed, agent options, output schema, and prompt are each materialized by the shared one-pass lossless-JSON snapshot. It computes child depth = `depthOf(parent) + 1`, rejects `request.maxDepth` overflow with `SubagentDepthError`, reports an invalid schema as `OutputSchemaError`, and derives both the child prefix and `seedLength` from the same detached seed; -2. first installs provider ownership, then attaches the request abort listener and creates one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves no child or orphaned listener. Async child creation goes through that fiber's `ctx.agents` service with fresh IDs, lineage/seed, inherited model, and an unpublished setup transaction for persona, tool restriction, and structured output. Parent teardown, provider teardown, and manual `run.dispose()` all dispose this exact node, preventing publication after it becomes inactive and awaiting the same quiescence boundary. `startInProcessRun` still returns its `SubagentRun` immediately: `run.started` resolves only after `ctx.agents.create()` has published the child (and rejects if publication never happens), while cancellation during creation is recorded and applied when a child exists; +2. first installs provider ownership, then attaches the request abort listener and creates one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves no child or orphaned listener. Async child creation goes through that fiber's `ctx.agents` service with fresh IDs, lineage/seed, inherited model, and an unpublished setup transaction for persona, tool restriction, and structured output. Parent teardown, provider teardown, manual `run.dispose()`, and cancellation before readiness all dispose this exact node, preventing publication after it becomes inactive and sharing the same quiescence boundary. `startInProcessRun` still returns its `SubagentRun` immediately: `run.started` resolves only after `ctx.agents.create()` has published the child and rejects when pre-readiness cancellation rolls the transaction back; 3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts); there is deliberately NO re-prompt for a structured child that finished cleanly without calling `structured_output` — the shortfall maps to an `error` result for the parent; 4. reads the result, scoped to the child's OWN events (everything at or after `seedLength`, so a seeded child that produced no message of its own never returns the seeded parent's last message): the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. A structured run surfaces the captured value as `result.structured`; a structured child that finished cleanly WITHOUT ever capturing settles `error` (a clean finish without the demanded result is a failure, not a success with a missing field). -`SubagentService` waits for `run.started` before emitting `subagent/start`, so a synchronous start observer can resolve the published child with `ctx.agents.get(run.id)`; the result driver awaits the same boundary before sending the prompt. An attempt that never publishes rejects readiness and emits no false start/end pair; its result reports a deliberate cancel/dispose as `aborted` and propagates an infrastructure fault. `dispose()` awaits creation or rollback and then delegates to `AgentHandle.dispose()` (stop and drain → remove agent → detach session → unwind scope); `cancel()` records its request even before publication and cancels the child immediately once available. A cancel landing before any `turn/end` still settles `aborted`, honoring the cancel contract rather than the generic no-turn `error`. +`SubagentService` waits for `run.started` before emitting `subagent/start`, so a synchronous start observer can resolve the published child with `ctx.agents.get(run.id)`; the result driver awaits the same boundary before sending the prompt. An attempt that never publishes rejects readiness and emits no false start/end pair; its result reports a deliberate cancel/dispose as `aborted` and propagates an infrastructure fault. `dispose()` awaits creation or rollback and then delegates to `AgentHandle.dispose()` (stop and drain → remove agent → detach session → unwind scope). Before readiness, `cancel()` deactivates the unpublished owner so no agent, session, or lifecycle event can escape; after readiness it cancels the live child immediately. Either path records the cancellation, so a cancel landing before any `turn/end` settles `aborted`, honoring the cancel contract rather than the generic no-turn `error`. ### `InProcessRunOptions` diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 518ea6a8b4..511b686c13 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -110,7 +110,10 @@ async function quiesceFiber(fiber: Fiber): Promise { * before the turn starts). The final `assistant/message` is the result output, * the matching `turn/end.reason` the stop reason. `dispose()` delegates to the * factory's {@link AgentHandle.dispose} (stop loop → await quiescence → remove - * session); `cancel()` cancels the child's in-flight turn. + * session). `cancel()` cancels a published child's in-flight turn; before + * readiness it instead deactivates the unpublished run-owner transaction, so + * `started` rejects, no agent/session lifecycle is published, and `result` + * resolves `aborted`. * * Throws {@link SubagentDepthError} before creating anything when the child's * depth (parent depth + 1) would exceed `request.maxDepth`. @@ -219,9 +222,10 @@ export function startInProcessRun( // Install it after provider ownership succeeds but BEFORE awaiting creation, // so an inactive provider cannot leave an orphaned listener and abort/dispose // during async setup is still recorded and applied the moment a child exists. - // `cancelled` records that a cancel was requested at all, so the pre-turn - // cancel window — where the child clears the queued prompt before any - // `turn/end` is logged — settles as `aborted` (honoring the cancel contract) + // `cancelled` records that a cancel was requested at all. Before readiness, + // cancellation deactivates the unpublished run-owner transaction so the + // factory cannot publish an agent or session. After readiness, it cancels the + // live child. Either path settles as `aborted` (honoring the cancel contract) // rather than falling through to the no-turn `error` mapping. let cancelled = false // An accessor, not an inline read: `cancelled` mutates from closures (the @@ -230,13 +234,6 @@ export function startInProcessRun( const isCancelled = (): boolean => cancelled let child: Agent | undefined let handle: AgentHandle | undefined - let disposeRequested = false - const isDisposeRequested = (): boolean => disposeRequested - const requestCancel = (reason: string): void => { - cancelled = true - child?.cancel(reason) - } - const onAbort = (): void => { requestCancel('subagent cancelled') } // One run-owned Cordis fiber is the common ownership node. Install the // provider effect FIRST: a start racing an already-unloading provider fails @@ -250,11 +247,28 @@ export function startInProcessRun( let ownerFiber: (Fiber & PromiseLike) | undefined let ownerSetupError: unknown let ownerDisposing: Promise | undefined - const disposeOwner = (): Promise => (ownerDisposing ??= ownerFiber === undefined - ? Promise.resolve() - : quiesceFiber(ownerFiber)) - let manualDisposeRequested = false - const isManualDisposeRequested = (): boolean => manualDisposeRequested + const disposeOwner = (): Promise => { + if (ownerDisposing !== undefined) return ownerDisposing + // An already-aborted request is observed before the owner fiber is minted. + // Do not memoize that no-op: the post-plugin cancellation check below must + // still be able to claim and deactivate the real fiber. + if (ownerFiber === undefined) return Promise.resolve() + ownerDisposing = quiesceFiber(ownerFiber) + // Pre-readiness cancellation is synchronous fire-and-forget at the public + // `cancel()` boundary. Observe a teardown rejection here; dispose() still + // awaits the same memoized promise and reports it to an explicit caller. + void ownerDisposing.catch(() => undefined) + return ownerDisposing + } + const requestCancel = (reason: string): void => { + cancelled = true + if (child === undefined) { + if (ownerFiber !== undefined) void disposeOwner() + return + } + child.cancel(reason) + } + const onAbort = (): void => { requestCancel('subagent cancelled') } const unlinkProvider = ctx.effect(() => () => { requestCancel('subagent provider disposed') return disposeOwner() @@ -265,6 +279,10 @@ export function startInProcessRun( ownerFiber = parent.ctx.plugin(Object.assign(subagentRunOwner, { inject: ['agents', 'sessions', 'llm', 'tools', 'systemPrompt'], })) + // `signal.aborted` is checked before this fiber exists. Once it does, make + // that recorded cancellation effective immediately; awaiting creation must + // observe an inactive owner instead of reaching the publication boundary. + if (isCancelled()) void disposeOwner() } catch (error: unknown) { ownerSetupError = error } @@ -299,8 +317,6 @@ export function startInProcessRun( }) handle = created child = created.agent - - if (isCancelled()) created.agent.cancel('subagent cancelled') return created.agent })() @@ -322,10 +338,9 @@ export function startInProcessRun( // without manufacturing an unreachable runtime branch. liveChild = child as Agent } catch (error: unknown) { - if (isManualDisposeRequested()) return { output: [], stopReason: 'aborted' } + if (isCancelled()) return { output: [], stopReason: 'aborted' } throw error instanceof Error ? error : new Error('subagent child creation failed with a non-Error value', { cause: error }) } - if (isCancelled() || isDisposeRequested()) return { output: [], stopReason: 'aborted' } liveChild.send(prompt) await liveChild.whenIdle() // Deliberately NO re-prompt when a structured child finishes cleanly @@ -348,8 +363,6 @@ export function startInProcessRun( async dispose(): Promise { return (disposing ??= (async () => { signal?.removeEventListener('abort', onAbort) - disposeRequested = true - manualDisposeRequested = true requestCancel('subagent disposed during creation') // Removing provider ownership and disposing the common run-owner fiber // are the same quiescence transaction; parent disposal may already have diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index f8e18ed572..cb394d8d95 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -269,6 +269,38 @@ describe('startInProcessRun', () => { await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' }) }) + it('observes detached pre-readiness teardown failure and reports it to explicit dispose', async () => { + const { ctx, parent } = await setup([]) + function inertOwner(): void {} + const ownerFiber = ctx.plugin(inertOwner) + await ownerFiber + const disposeFailure = new Error('owner dispose exploded') + const disposeSpy = vi.spyOn(ownerFiber, 'dispose').mockImplementation(() => { throw disposeFailure }) + const rejectingOwnerCtx = { + agents: { create: () => Promise.reject(new Error('creation stopped by cancellation')) }, + } as unknown as Context + const parentWithFailingTeardown = { + options: parent.options, + session: parent.session, + ctx: { + plugin(plugin: (inner: Context) => void) { + plugin(rejectingOwnerCtx) + return ownerFiber + }, + }, + } as unknown as Agent + const run = startInProcessRun(ctx, { + prompt: [{ type: 'text', text: 'must never start' }], + parent: parentWithFailingTeardown, + }, {}) + + run.cancel('cancel before readiness') + await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' }) + await expect(run.dispose()).rejects.toBe(disposeFailure) + disposeSpy.mockRestore() + await ownerFiber.dispose() + }) + it('does not attach an abort listener when provider ownership is already inactive', async () => { const { ctx, parent } = await setup([]) let providerCtx: Context | undefined diff --git a/packages/subagent/subagent-spawn/README.md b/packages/subagent/subagent-spawn/README.md index e411d44ab0..07c237b303 100644 --- a/packages/subagent/subagent-spawn/README.md +++ b/packages/subagent/subagent-spawn/README.md @@ -6,7 +6,7 @@ The run mechanics live in the shared [`@deepseek-ai/dsh-subagent-inprocess`](../ ## What it does -`start(request)` delegates to `startInProcessRun(ctx, request, {})` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. The driver creates one run-owner fiber under `parent.ctx`; parent teardown, this provider's teardown, and manual disposal all converge there before child publication. Its `run.started` boundary resolves only after the fresh child is published, so `subagent/start` observers see a live registry entry. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose). +`start(request)` delegates to `startInProcessRun(ctx, request, {})` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. The driver creates one run-owner fiber under `parent.ctx`; parent teardown, this provider's teardown, manual disposal, and cancellation before readiness all converge there before child publication. Its `run.started` boundary resolves only after the fresh child is published, so `subagent/start` observers see a live registry entry; a same-tick cancel deactivates the unpublished transaction instead, rejects readiness, resolves the result as `aborted`, and emits no agent/session or subagent lifecycle. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose). ## Capabilities diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 3d23fca285..f00eddeb25 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -163,20 +163,31 @@ describe('dsh-subagent-spawn', () => { await run.dispose() }) - it('cancelling BEFORE the child turn starts settles aborted, not error', async () => { - // Regression: a cancel landing in the pre-turn window clears the queued - // prompt before any `turn/end` is logged. Deriving the stop reason from - // `turn/end` alone then mis-maps the no-turn case to `error`; the run must - // honor the cancel contract and settle `aborted`. The cancel is synchronous - // (same tick as start, before the loop's queued-wait continuation runs), so - // the turn is dropped and the empty script is never consumed. + it('same-tick cancellation rejects readiness and prevents child publication', async () => { + // Regression: cancellation before readiness used to set a flag but let the + // async factory publish a child anyway, so `started` fulfilled and lifecycle + // observers saw an agent for an attempt the caller had already cancelled. + // The empty script also proves no model turn can run. const { ctx, parent } = await setup([]) + const beforeAgents = ctx.agents.list().length + const beforeSessions = ctx.sessions.list().length + const published: string[] = [] + ctx.on('session/created', () => void published.push('session/created')) + ctx.on('agent/created', () => void published.push('agent/created')) + ctx.on('agent/session-start', () => void published.push('agent/session-start')) + ctx.on('subagent/start', () => void published.push('subagent/start')) + ctx.on('subagent/end', () => void published.push('subagent/end')) const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) run.cancel('early') - const result = await run.result - expect(result.stopReason).toBe('aborted') - expect(result.output).toEqual([]) + + await expect(run.started).rejects.toThrow() + await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' }) await run.dispose() + await Promise.resolve() + expect(ctx.agents.get(run.id)).toBeUndefined() + expect(ctx.agents.list()).toHaveLength(beforeAgents) + expect(ctx.sessions.list()).toHaveLength(beforeSessions) + expect(published).toEqual([]) }) it('a cancel from agent/queued maps a no-turn child log to aborted', async () => { diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index b068cfe79d..7e63f07376 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -18,23 +18,23 @@ Unlike the bash seam (one executor per context, second load throws), **multiple | Member | Semantics | |---|---| -| `registerProvider(provider)` | Register a frozen acceptance snapshot under `provider.name`; later caller mutation cannot change registry behavior or HMR cleanup, while `start` stays bound to the original provider receiver. Throws `SubagentError('DUPLICATE_PROVIDER')` on a name clash. Effect-scoped (HMR-safe); returns the disposer. | +| `registerProvider(provider)` | Read and validate the name, capability object and four boolean flags, `inheritsParentContext`, and `start` callback exactly once, then register a frozen acceptance snapshot under the accepted name. Malformed fixed fields fail loud before registration; later caller mutation cannot change registry behavior or HMR cleanup, while `start` stays bound to the original provider receiver. Throws `SubagentError('DUPLICATE_PROVIDER')` on a name clash. Effect-scoped (HMR-safe); returns the disposer. | | `getProvider(name)` | Look up the frozen registry snapshot (`undefined` if absent). | | `list()` | Registered provider names (insertion order). | -| `start(name, request)` | Resolve the provider (`NO_PROVIDER` if absent), read every caller field once into one acceptance snapshot, validate every requested START-TIME capability and scalar value before any child is created, and materialize prompt/schema/options/filter data through a single-pass lossless-JSON snapshot before delegating to `provider.start`. Return a frozen service-owned run wrapper whose provider fields are captured once, whose methods remain bound to the provider handle, and whose `result` is one detached, deeply frozen normalization shared by the caller and telemetry. Emit `subagent/start` only after `run.started` fulfills and the paired `subagent/end` after that started run settles; a pre-publication readiness rejection emits neither. | +| `start(name, request)` | Resolve the provider (`NO_PROVIDER` if absent), read every caller field once into one acceptance snapshot, validate every requested START-TIME capability and scalar value before any child is created, and materialize prompt/schema/options/filter data through a single-pass lossless-JSON snapshot before delegating to `provider.start`. Acquire and memoize the provider run's disposer before reading the rest of its handle, then return a frozen service-owned wrapper whose fields are captured once, whose methods remain bound to the provider handle, and whose `result` is one detached, deeply frozen normalization shared by the caller and telemetry. Malformed handle access/binding starts rollback before the synchronous fault escapes; malformed terminal data rejects only after rollback reaches quiescence. Emit `subagent/start` only after `run.started` fulfills and the paired `subagent/end` after that started run settles; a pre-publication readiness rejection emits neither. | ## Capabilities: two kinds, discovered two ways - **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter/persona`) are a static `provider.capabilities` descriptor, checked by the service BEFORE a run exists. A request that needs one the provider lacks is **rejected loud** (`UNSUPPORTED_CAPABILITY`), never accepted-then-ignored. - **Runtime features** (steering, resume) are **optional methods** on `SubagentRun` (`sendMessage?`, `resume?`). The method's presence IS the capability; TS narrowing is the discovery mechanism — a consumer cannot call an absent method without narrowing first, so there is no silent degradation path. -Beside `capabilities` sits one DESCRIPTIVE fact, not validated by the service: `provider.inheritsParentContext` — whether a child sees the parent conversation (`fork`: true — seeded with the completed-turn prefix; `spawn`/`acp`: false). The model-facing consumer (`dsh-tool-subagent`) derives truthful tool wording from it. +Beside `capabilities` sits one DESCRIPTIVE fact: `provider.inheritsParentContext` — whether a child sees the parent conversation (`fork`: true — seeded with the completed-turn prefix; `spawn`/`acp`: false). The service validates that the descriptor is a boolean but does not interpret or enforce its meaning; the model-facing consumer (`dsh-tool-subagent`) derives truthful tool wording from it. ## Run lifecycle `provider.start(request)` returns a provider-owned `SubagentRun`; `SubagentService.start` captures that handle once and returns a frozen service-owned wrapper with `started` (the publication/readiness promise), a normalized `result` (the terminal outcome), bound `cancel()` and `dispose()`, and bound optional runtime methods. `started` resolves only after the provider has established a real child and rejects if the attempt fails or is cancelled first. `result` resolves with one detached, deeply frozen `SubagentResult` (`output`, optional `structured`, `stopReason`) that the service and caller share — it does **not** reject on a child-level failure (a model/transport failure resolves with `stopReason: 'error'`), but malformed provider data rejects as an infrastructure contract fault. The consumer maps a non-`completed` reason to an `isError` tool result and MUST `dispose()` on every path (success, error, abort) to reach child quiescence and avoid leaking an idle child / session. -The service also announces provider lifecycle: `subagent/provider-added` (the frozen registry snapshot) fires after a registration and `subagent/provider-removed` (the accepted name) after an unregistration, so a consumer deriving state from a named provider (the model-facing tool wording) mirrors registry membership instead of assuming load order — the cordis Loader starts sibling plugins concurrently, so "listed earlier" does not mean "registered earlier". Run lifecycle is gated by provider readiness: the service captures the provider handle's public fields once, `subagent/start` (payload `SubagentRunInfo`) fires only after the accepted `started` promise fulfills, and `subagent/end` (payload `SubagentRunEndInfo`) uses the same accepted id and normalized result; readiness rejection emits neither. For spawn/fork, the start listener can resolve the published child via `ctx.agents.get(info.id)`; a remote provider need not have a local registry entry. Both events are **observe-only** plain emits whose service-owned payloads are deeply frozen before per-listener dispatch. The service observes the normalized `result` immediately even while readiness is pending and buffers that end payload until start has fired; a malformed provider result rejects the returned result promise and becomes contained `error` telemetry, a rejection cannot become an unhandled detached promise, start always precedes end, and one listener cannot corrupt either the caller or later listeners. `subagent/end` carries the same frozen output as `lastAssistantMessage` on a valid settle path and omits it on infrastructure or result-contract failure. Any run-affecting decision is out of scope for this observe-only surface. +The service also announces provider lifecycle: `subagent/provider-added` (the frozen registry snapshot) fires after a registration and `subagent/provider-removed` (the accepted name) after an unregistration, so a consumer deriving state from a named provider (the model-facing tool wording) mirrors registry membership instead of assuming load order — the cordis Loader starts sibling plugins concurrently, so "listed earlier" does not mean "registered earlier". Run lifecycle is gated by provider readiness: the service captures the provider handle's public fields once, `subagent/start` (payload `SubagentRunInfo`) fires only after the accepted `started` promise fulfills, and `subagent/end` (payload `SubagentRunEndInfo`) uses the same accepted id and normalized result; readiness rejection emits neither. For spawn/fork, the start listener can resolve the published child via `ctx.agents.get(info.id)`; a remote provider need not have a local registry entry. Both events are **observe-only** plain emits whose service-owned payloads are deeply frozen before per-listener dispatch. A synchronous listener throw or returned-promise rejection is logged per listener; later listeners still run synchronously, and async listeners remain concurrent fire-and-forget. The service observes the normalized `result` immediately even while readiness is pending and buffers that end payload until start has fired; a malformed provider result rejects the returned result promise and becomes contained `error` telemetry, a rejection cannot become an unhandled detached promise, start always precedes end, and one listener cannot corrupt either the caller or later listeners. `subagent/end` carries the same frozen output as `lastAssistantMessage` on a valid settle path and omits it on infrastructure or result-contract failure. Any run-affecting decision is out of scope for this observe-only surface. ## Scope (first cut) diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index de376686fd..b319a7f31d 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -169,9 +169,11 @@ export class SubagentService extends Service { * Register a provider under its `provider.name`. Throws {@link SubagentError} * (`DUPLICATE_PROVIDER`) if the name is already taken. The registry snapshots * the name, static descriptors, and `start` callback identity at acceptance; - * later caller mutation cannot change lookup, capability validation, consumer - * wording, dispatch, or HMR cleanup. The callback remains bound to the - * original provider object, so provider-owned mutable state stays live. + * every fixed field and capability flag is read once and validated before + * registration, so malformed provider objects fail loud without entering the + * registry. Later caller mutation cannot change lookup, capability validation, + * consumer wording, dispatch, or HMR cleanup. The callback remains bound to + * the original provider object, so provider-owned mutable state stays live. * Effect-scoped: disposed with the calling fiber (HMR-safe). Emits * `subagent/provider-added` after the registration and * `subagent/provider-removed` on unregistration, so consumers can mirror @@ -187,18 +189,49 @@ export class SubagentService extends Service { // mutate or reuse the provider object before its old fiber unloads. Binding // preserves the provider method's receiver while making replacement of the // public callback field after registration inert. - const inputCapabilities = provider.capabilities + const name: unknown = provider.name + const inputCapabilities: unknown = provider.capabilities + const inheritsParentContext: unknown = provider.inheritsParentContext + // eslint-disable-next-line @typescript-eslint/unbound-method + const inputStart: unknown = provider.start + if (typeof name !== 'string') { + throw new TypeError('subagent provider name must be a string') + } + if (inputCapabilities === null || typeof inputCapabilities !== 'object' || Array.isArray(inputCapabilities)) { + throw new TypeError(`subagent provider "${name}" capabilities must be an object`) + } + const inputCapabilityFields = inputCapabilities as Record + const outputSchema = inputCapabilityFields.outputSchema + const depthLimit = inputCapabilityFields.depthLimit + const toolFilter = inputCapabilityFields.toolFilter + const persona = inputCapabilityFields.persona + for (const [capability, value] of [ + ['outputSchema', outputSchema], + ['depthLimit', depthLimit], + ['toolFilter', toolFilter], + ['persona', persona], + ] as const) { + if (typeof value !== 'boolean') { + throw new TypeError(`subagent provider "${name}" capability "${capability}" must be a boolean`) + } + } + if (typeof inheritsParentContext !== 'boolean') { + throw new TypeError(`subagent provider "${name}" inheritsParentContext must be a boolean`) + } + if (typeof inputStart !== 'function') { + throw new TypeError(`subagent provider "${name}" start must be a function`) + } const capabilities: SubagentCapabilities = Object.freeze({ - outputSchema: inputCapabilities.outputSchema, - depthLimit: inputCapabilities.depthLimit, - toolFilter: inputCapabilities.toolFilter, - persona: inputCapabilities.persona, + outputSchema: outputSchema as boolean, + depthLimit: depthLimit as boolean, + toolFilter: toolFilter as boolean, + persona: persona as boolean, }) const snapshot: SubagentProvider = Object.freeze({ - name: provider.name, + name, capabilities, - inheritsParentContext: provider.inheritsParentContext, - start: provider.start.bind(provider), + inheritsParentContext, + start: Function.prototype.bind.call(inputStart, provider) as SubagentProvider['start'], }) const dispose = this.ctx.effect(function* (this: SubagentService) { if (this.providers.has(snapshot.name)) { @@ -255,7 +288,10 @@ export class SubagentService extends Service { * {@link SubagentProvider.start}. The returned handle is a service-owned, * frozen wrapper: provider fields are captured once, methods stay bound to the * provider handle, and `result` resolves to one detached, deeply frozen value - * shared by the caller and lifecycle telemetry. Emits `subagent/start` / + * shared by the caller and lifecycle telemetry. Once a provider returns a + * callable disposer, malformed handle access/binding starts rollback before + * the synchronous fault escapes; malformed terminal data rejects only after + * that same memoized disposal reaches quiescence. Emits `subagent/start` / * `subagent/end` only after the run's readiness boundary fulfills. A provider * that fails before establishing a child emits neither event. * @param name - the provider to run on. @@ -324,82 +360,176 @@ export class SubagentService extends Service { ...toolFilter !== undefined ? { toolFilter } : {}, ...input.persona !== undefined ? { persona: input.persona } : {}, } - const providerRun = provider.start(accepted) + const providerRun: unknown = provider.start(accepted) + if (providerRun === null || (typeof providerRun !== 'object' && typeof providerRun !== 'function')) { + throw new TypeError(`subagent provider "${name}" start must return a SubagentRun object`) + } + const acceptedRun = providerRun as SubagentRun + // Acquire the one rollback capability BEFORE touching any other provider-run + // field. Once start() returned a handle, the service owns an accepted live + // attempt; a hostile later accessor or bind must not make that attempt + // unreachable. The wrapper also memoizes provider disposal, so automatic + // rollback and a racing caller join one quiescence transaction even if a + // contract-violating provider forgot to make its own method idempotent. + // eslint-disable-next-line @typescript-eslint/unbound-method + const inputDispose = acceptedRun.dispose + if (typeof inputDispose !== 'function') { + throw new TypeError(`subagent provider "${name}" run dispose must be a function`) + } + let disposal: Promise | undefined + const dispose = (): Promise => { + if (disposal === undefined) { + try { + // Invoke through the captured callable without reading its public + // `bind`/`length`/`name` properties. Disposal is the recovery + // capability itself; hostile function metadata must not prevent the + // seam from exercising it when a later handle field is malformed. + disposal = Promise.resolve(Reflect.apply(inputDispose, acceptedRun, [])) + } catch (error: unknown) { + disposal = Promise.reject(error instanceof Error + ? error + : new Error('subagent provider run dispose threw a non-Error value', { cause: error })) + } + } + return disposal + } // Provider-owned run objects can be accessor-backed too. Capture every // public field exactly once, bind methods to the provider's original handle, // and expose only this service-owned wrapper. The normalized result promise // is also the one lifecycle telemetry observes, so the caller and observers // cannot receive different values from stateful accessors. - const id = providerRun.id - const started = providerRun.started - const providerResult = providerRun.result - const cancel = providerRun.cancel.bind(providerRun) - const sendMessage = providerRun.sendMessage?.bind(providerRun) - const dispose = providerRun.dispose.bind(providerRun) - const resume = providerRun.resume?.bind(providerRun) - const result = providerResult.then(value => this.snapshotRunResult(value)) - const run: SubagentRun = Object.freeze({ - id, - started, - result, - cancel, - dispose, - ...sendMessage === undefined - ? {} - : { sendMessage }, - ...resume === undefined - ? {} - : { resume }, - }) - - // Observe result settlement IMMEDIATELY, before waiting on readiness. A - // provider may fail both promises in the same turn; deferring the rejection - // handler until `started` fulfilled would leave `result` transiently - // unhandled. The settled event is buffered until start has been announced, - // preserving start → end order even for an already-settled scripted run. - let readiness: 'pending' | 'started' | 'failed' = 'pending' - let pendingEnd: SubagentRunEndInfo | undefined - const deliverEnd = (info: SubagentRunEndInfo): void => { - if (readiness === 'started') this.emitLifecycle('subagent/end', info, parent) - else if (readiness === 'pending') pendingEnd = info - // A pre-publication readiness failure has no lifecycle pair; result - // remains observable by the run's consumer, but telemetry must not claim - // that a child started. - } - void result.then( - (value) => { - deliverEnd({ - provider: name, - id, - stopReason: value.stopReason, - lastAssistantMessage: value.output, - }) - }, - () => { deliverEnd({ provider: name, id, stopReason: 'error' }) }, - ) - - // Readiness is the publication boundary owned by the provider. For - // in-process runs, fulfillment means the agent registry already contains - // `run.id`; for ACP it means the remote session exists. Emit start with - // per-listener containment, then flush an outcome that settled unusually - // early. A readiness rejection is handled here and deliberately emits no - // false start/end pair; the result path above remains independently handled. - void started.then( - () => { - readiness = 'started' - this.emitLifecycle('subagent/start', { provider: name, id }, parent) - if (pendingEnd !== undefined) { - const info = pendingEnd - pendingEnd = undefined - this.emitLifecycle('subagent/end', info, parent) + try { + const id = acceptedRun.id + if (typeof id !== 'string') { + throw new TypeError(`subagent provider "${name}" run id must be a string`) + } + const started = acceptedRun.started + if (!(started instanceof Promise)) { + throw new TypeError(`subagent provider "${name}" run started must be a Promise`) + } + // Observe each accepted provider promise before reading the next hostile + // field. A later accessor/validation failure prevents a wrapper from being + // returned, but must not leave an already-rejected provider promise + // unhandled while rollback proceeds. + void started.catch(() => undefined) + const providerResult = acceptedRun.result + if (!(providerResult instanceof Promise)) { + throw new TypeError(`subagent provider "${name}" run result must be a Promise`) + } + void providerResult.catch(() => undefined) + // eslint-disable-next-line @typescript-eslint/unbound-method + const inputCancel = acceptedRun.cancel + if (typeof inputCancel !== 'function') { + throw new TypeError(`subagent provider "${name}" run cancel must be a function`) + } + // eslint-disable-next-line @typescript-eslint/unbound-method + const inputSendMessage = acceptedRun.sendMessage + if (inputSendMessage !== undefined && typeof inputSendMessage !== 'function') { + throw new TypeError(`subagent provider "${name}" run sendMessage must be a function when provided`) + } + // eslint-disable-next-line @typescript-eslint/unbound-method + const inputResume = acceptedRun.resume + if (inputResume !== undefined && typeof inputResume !== 'function') { + throw new TypeError(`subagent provider "${name}" run resume must be a function when provided`) + } + const cancel = Function.prototype.bind.call(inputCancel, acceptedRun) as SubagentRun['cancel'] + const sendMessage = inputSendMessage === undefined + ? undefined + : Function.prototype.bind.call(inputSendMessage, acceptedRun) as NonNullable + const resume = inputResume === undefined + ? undefined + : Function.prototype.bind.call(inputResume, acceptedRun) as NonNullable + const result = providerResult.then(async (value) => { + try { + return this.snapshotRunResult(value) + } catch (error: unknown) { + // A malformed terminal value is an infrastructure contract fault. The + // result rejects only after the accepted provider attempt has reached + // quiescence, so a caller cannot lose the only cleanup handle by merely + // observing the normalization failure. + await this.rollbackProviderRun(name, dispose) + throw error } - }, - () => { - readiness = 'failed' - pendingEnd = undefined - }, - ) - return run + }) + const run: SubagentRun = Object.freeze({ + id, + started, + result, + cancel, + dispose, + ...sendMessage === undefined + ? {} + : { sendMessage }, + ...resume === undefined + ? {} + : { resume }, + }) + + // Observe result settlement IMMEDIATELY, before waiting on readiness. A + // provider may fail both promises in the same turn; deferring the rejection + // handler until `started` fulfilled would leave `result` transiently + // unhandled. The settled event is buffered until start has been announced, + // preserving start → end order even for an already-settled scripted run. + let readiness: 'pending' | 'started' | 'failed' = 'pending' + let pendingEnd: SubagentRunEndInfo | undefined + const deliverEnd = (info: SubagentRunEndInfo): void => { + if (readiness === 'started') this.emitLifecycle('subagent/end', info, parent) + else if (readiness === 'pending') pendingEnd = info + // A pre-publication readiness failure has no lifecycle pair; result + // remains observable by the run's consumer, but telemetry must not claim + // that a child started. + } + void result.then( + (value) => { + deliverEnd({ + provider: name, + id, + stopReason: value.stopReason, + lastAssistantMessage: value.output, + }) + }, + () => { deliverEnd({ provider: name, id, stopReason: 'error' }) }, + ) + + // Readiness is the publication boundary owned by the provider. For + // in-process runs, fulfillment means the agent registry already contains + // `run.id`; for ACP it means the remote session exists. Emit start with + // per-listener containment, then flush an outcome that settled unusually + // early. A readiness rejection is handled here and deliberately emits no + // false start/end pair; the result path above remains independently handled. + void started.then( + () => { + readiness = 'started' + this.emitLifecycle('subagent/start', { provider: name, id }, parent) + if (pendingEnd !== undefined) { + const info = pendingEnd + pendingEnd = undefined + this.emitLifecycle('subagent/end', info, parent) + } + }, + () => { + readiness = 'failed' + pendingEnd = undefined + }, + ) + return run + } catch (error: unknown) { + // start() has already transferred a live attempt to the seam. Begin + // rollback synchronously before surfacing the malformed-handle failure; + // the contained cleanup promise prevents either a resource leak or an + // unhandled rejection even though this API cannot synchronously await it. + void this.rollbackProviderRun(name, dispose) + throw error + } + } + + /** Dispose one malformed provider attempt without letting cleanup mask the contract fault. */ + private async rollbackProviderRun(providerName: string, dispose: () => Promise): Promise { + try { + await dispose() + } catch (error: unknown) { + this.ctx.logger.warn(`subagent provider "${providerName}" malformed run rollback failed: ${renderThrown(error)}`) + } } /** Normalize one provider result into the immutable seam value. */ @@ -452,10 +582,12 @@ export class SubagentService extends Service { /** * Emit a `subagent/*` lifecycle event with PER-LISTENER containment: dispatch - * each subscriber individually and log (never propagate) a thrown one, so one - * bad subscriber can neither strand the already-live run, surface as an - * unhandled rejection on the detached settle hook, NOR starve the listeners - * registered after it. A single try/catch around `ctx.emit` would not do the + * each subscriber individually and log (never propagate) either a synchronous + * throw or a returned-promise rejection, so one bad subscriber can neither + * strand the already-live run, surface as an unhandled rejection on the + * detached settle hook, NOR starve the listeners registered after it. Async + * listeners remain concurrent fire-and-forget; dispatch does not await or + * serialize them. A single try/catch around `ctx.emit` would not do the * last part — cordis `emit` runs listeners in a `.map(cb => cb())` that halts * on the first throw — so this resolves the listener callbacks via * `ctx.events.dispatch` and contains each call, the same guarantee @@ -488,7 +620,14 @@ export class SubagentService extends Service { : [scopeTarget(this, parent), name, acceptedInfo] for (const callback of this.ctx.events.dispatch('emit', dispatchArgs)) { try { - callback(acceptedInfo) + const returned: unknown = callback(acceptedInfo) + // Plain emits remain fire-and-forget and every callback is still invoked + // synchronously in this loop. Observe a returned promise independently so + // an async listener rejection is contained without serializing listeners + // or delaying provider/run lifecycle. + void Promise.resolve(returned).catch((error: unknown) => { + this.ctx.logger.warn(`subagent: ${name} listener rejected: ${renderThrown(error)}`) + }) } catch (error: unknown) { this.ctx.logger.warn(`subagent: ${name} listener threw: ${renderThrown(error)}`) } diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index ba4b58c2fc..d1a531ca09 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -150,6 +150,93 @@ describe('SubagentService', () => { } }) + it.each([ + { label: 'a non-string name', patch: { name: 42 }, message: 'name must be a string' }, + { label: 'null capabilities', patch: { capabilities: null }, message: 'capabilities must be an object' }, + { label: 'primitive capabilities', patch: { capabilities: 42 }, message: 'capabilities must be an object' }, + { label: 'array capabilities', patch: { capabilities: [] }, message: 'capabilities must be an object' }, + { + label: 'a non-boolean outputSchema capability', + patch: { capabilities: { ...NO_CAPS, outputSchema: 'yes' } }, + message: 'capability "outputSchema" must be a boolean', + }, + { + label: 'a non-boolean depthLimit capability', + patch: { capabilities: { ...NO_CAPS, depthLimit: 'yes' } }, + message: 'capability "depthLimit" must be a boolean', + }, + { + label: 'a non-boolean toolFilter capability', + patch: { capabilities: { ...NO_CAPS, toolFilter: 'yes' } }, + message: 'capability "toolFilter" must be a boolean', + }, + { + label: 'a non-boolean persona capability', + patch: { capabilities: { ...NO_CAPS, persona: 'yes' } }, + message: 'capability "persona" must be a boolean', + }, + { label: 'a non-boolean context descriptor', patch: { inheritsParentContext: 'yes' }, message: 'inheritsParentContext must be a boolean' }, + { label: 'a non-callable start field', patch: { start: 42 }, message: 'start must be a function' }, + ])('rejects a provider registration with $label before entering the registry', async ({ patch, message }) => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const provider = Object.assign(new StubProvider('invalid'), patch) + + expect(() => ctx.subagents.registerProvider(provider as unknown as SubagentProvider)).toThrow(message) + expect(ctx.subagents.list()).toEqual([]) + expect(Object.isFrozen(provider)).toBe(false) + }) + + it('reads every registration field once and binds the accepted start callback to the provider', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const reads = { + name: 0, + capabilities: 0, + outputSchema: 0, + depthLimit: 0, + toolFilter: 0, + persona: 0, + inheritsParentContext: 0, + start: 0, + } + const capabilities = Object.defineProperties({}, { + outputSchema: { enumerable: true, get: () => { reads.outputSchema += 1; return false } }, + depthLimit: { enumerable: true, get: () => { reads.depthLimit += 1; return false } }, + toolFilter: { enumerable: true, get: () => { reads.toolFilter += 1; return false } }, + persona: { enumerable: true, get: () => { reads.persona += 1; return false } }, + }) as SubagentCapabilities + const acceptedStart = function (this: SubagentProvider, request: SubagentStartRequest): SubagentRun { + expect(this).toBe(provider) + return { + id: AgentId(`one-read:${request.parent.id}`), + started: Promise.resolve(), + result: Promise.resolve({ output: [], stopReason: 'completed' }), + cancel() {}, + async dispose() {}, + } + } + const provider = Object.defineProperties({}, { + name: { enumerable: true, get: () => { reads.name += 1; return 'one-read' } }, + capabilities: { enumerable: true, get: () => { reads.capabilities += 1; return capabilities } }, + inheritsParentContext: { enumerable: true, get: () => { reads.inheritsParentContext += 1; return false } }, + start: { enumerable: true, get: () => { reads.start += 1; return acceptedStart } }, + }) as SubagentProvider + + ctx.subagents.registerProvider(provider) + await expect(ctx.subagents.start('one-read', baseRequest()).result).resolves.toMatchObject({ stopReason: 'completed' }) + expect(reads).toEqual({ + name: 1, + capabilities: 1, + outputSchema: 1, + depthLimit: 1, + toolFilter: 1, + persona: 1, + inheritsParentContext: 1, + start: 1, + }) + }) + it('unregisters a provider when its owning fiber is disposed (HMR safety)', async () => { const ctx = new Context() await ctx.plugin(SubagentService) @@ -608,6 +695,178 @@ describe('SubagentService', () => { }) }) + it.each([ + { label: 'a non-string id', field: 'id', value: 42, message: 'run id must be a string' }, + { label: 'a non-Promise started field', field: 'started', value: undefined, message: 'run started must be a Promise' }, + { label: 'a non-Promise result field', field: 'result', value: undefined, message: 'run result must be a Promise' }, + { label: 'a non-callable cancel field', field: 'cancel', value: undefined, message: 'run cancel must be a function' }, + { label: 'a non-callable sendMessage field', field: 'sendMessage', value: 42, message: 'run sendMessage must be a function' }, + { label: 'a non-callable resume field', field: 'resume', value: 42, message: 'run resume must be a function' }, + ])('rolls back a provider run with $label', async ({ field, value, message }) => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const providerDispose = vi.fn(async () => {}) + const providerRun = { + id: AgentId('invalid-handle-child'), + started: Promise.resolve(), + result: Promise.resolve({ output: [], stopReason: 'completed' } satisfies SubagentResult), + cancel() {}, + dispose: providerDispose, + [field]: value, + } as unknown as SubagentRun + ctx.subagents.registerProvider({ + name: 'invalid-handle', + capabilities: NO_CAPS, + inheritsParentContext: false, + start: () => providerRun, + }) + + expect(() => ctx.subagents.start('invalid-handle', baseRequest())).toThrow(message) + expect(providerDispose).toHaveBeenCalledOnce() + }) + + it.each([ + { label: 'null', value: null }, + { label: 'a primitive', value: 42 }, + ])('rejects $label returned by provider.start before reading a disposer', async ({ value }) => { + const ctx = new Context() + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'invalid-run-shell', + capabilities: NO_CAPS, + inheritsParentContext: false, + start: () => value as unknown as SubagentRun, + }) + + expect(() => ctx.subagents.start('invalid-run-shell', baseRequest())).toThrow('must return a SubagentRun object') + }) + + it('rejects a run without a callable disposer before accepting ownership', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'invalid-dispose', + capabilities: NO_CAPS, + inheritsParentContext: false, + start: () => ({ dispose: 42 }) as unknown as SubagentRun, + }) + + expect(() => ctx.subagents.start('invalid-dispose', baseRequest())).toThrow('run dispose must be a function') + }) + + it('observes accepted provider promises when a later handle field is malformed', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const providerDispose = vi.fn(async () => {}) + ctx.subagents.registerProvider({ + name: 'rejected-malformed-handle', + capabilities: NO_CAPS, + inheritsParentContext: false, + start: () => ({ + id: AgentId('rejected-malformed-child'), + started: Promise.reject(new Error('readiness already rejected')), + result: Promise.reject(new Error('result already rejected')), + cancel: 42, + dispose: providerDispose, + }) as unknown as SubagentRun, + }) + + expect(() => ctx.subagents.start('rejected-malformed-handle', baseRequest())).toThrow('run cancel must be a function') + expect(providerDispose).toHaveBeenCalledOnce() + // Let both provider rejections run: the seam's immediate observers keep + // them from surfacing as unhandled after no wrapper was returned. + await Promise.resolve() + }) + + it('starts rollback before surfacing a hostile run accessor failure', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const disposalGate = Promise.withResolvers() + const order: string[] = [] + const providerRun = Object.defineProperties({}, { + dispose: { + get: () => { + order.push('dispose:get') + return async function (this: SubagentRun): Promise { + expect(this).toBe(providerRun) + order.push('dispose:call') + await disposalGate.promise + order.push('dispose:quiescent') + } + }, + }, + id: { get: () => { order.push('id:get'); return AgentId('hostile-handle-child') } }, + started: { get: () => { order.push('started:get'); return Promise.resolve() } }, + result: { get: () => { order.push('result:get'); throw new Error('result accessor exploded') } }, + }) as SubagentRun + ctx.subagents.registerProvider({ + name: 'hostile-handle', + capabilities: NO_CAPS, + inheritsParentContext: false, + start: () => providerRun, + }) + + expect(() => ctx.subagents.start('hostile-handle', baseRequest())).toThrow('result accessor exploded') + expect(order).toEqual(['dispose:get', 'id:get', 'started:get', 'result:get', 'dispose:call']) + disposalGate.resolve(undefined) + await vi.waitFor(() => { expect(order).toContain('dispose:quiescent') }) + }) + + it('rolls back when binding a hostile optional run method fails', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const providerDispose = vi.fn(async () => {}) + const hostileCancel = new Proxy(() => {}, { + get(_target, property) { + if (property === 'length') throw new Error('cancel bind exploded') + return undefined + }, + }) + ctx.subagents.registerProvider({ + name: 'hostile-bind', + capabilities: NO_CAPS, + inheritsParentContext: false, + start: () => ({ + id: AgentId('hostile-bind-child'), + started: Promise.resolve(), + result: Promise.resolve({ output: [], stopReason: 'completed' }), + cancel: hostileCancel, + dispose: providerDispose, + }), + }) + + expect(() => ctx.subagents.start('hostile-bind', baseRequest())).toThrow('cancel bind exploded') + expect(providerDispose).toHaveBeenCalledOnce() + }) + + it.each([ + { label: 'an Error', thrown: new Error('cleanup exploded'), warning: 'cleanup exploded' }, + { label: 'a non-Error value', thrown: 'naked cleanup fault', warning: 'dispose threw a non-Error value' }, + ])('contains rollback failure from $label while preserving the malformed-handle fault', async ({ thrown, warning }) => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + ctx.subagents.registerProvider({ + name: 'rollback-failure', + capabilities: NO_CAPS, + inheritsParentContext: false, + start: () => ({ + id: 42, + started: Promise.resolve(), + result: Promise.resolve({ output: [], stopReason: 'completed' }), + cancel() {}, + dispose: () => { + // Deliberately violate the seam contract to exercise normalization. + throw thrown + }, + }) as unknown as SubagentRun, + }) + + expect(() => ctx.subagents.start('rollback-failure', baseRequest())).toThrow('run id must be a string') + await vi.waitFor(() => { expect(warnings.some(message => message.includes(warning))).toBe(true) }) + }) + it('waits for provider readiness and observes an early result rejection without reordering lifecycle', async () => { const ctx = new Context() await ctx.plugin(SubagentService) @@ -811,6 +1070,8 @@ describe('SubagentService', () => { const ctx = new Context() await ctx.plugin(SubagentService) const nonJsonOutput = [{ type: 'text', text: 'x', evil: () => 0 }] as unknown as SubagentResult['output'] + const disposalGate = Promise.withResolvers() + const providerDispose = vi.fn(async () => { await disposalGate.promise }) ctx.subagents.registerProvider({ name: 'unclone', capabilities: NO_CAPS, @@ -820,16 +1081,23 @@ describe('SubagentService', () => { started: Promise.resolve(), result: Promise.resolve({ output: nonJsonOutput, stopReason: 'completed' } as SubagentResult), cancel() {}, - dispose: async () => {}, + dispose: providerDispose, }), }) const ended = vi.fn() ctx.on('subagent/end', ended) const run = ctx.subagents.start('unclone', baseRequest()) + let resultSettled = false + void run.result.catch(() => { resultSettled = true }) + await vi.waitFor(() => { expect(providerDispose).toHaveBeenCalledOnce() }) + expect(resultSettled).toBe(false) + disposalGate.resolve(undefined) await expect(run.result).rejects.toThrow('subagent result must be losslessly JSON-serializable') + await run.dispose() await Promise.resolve() + expect(providerDispose).toHaveBeenCalledOnce() const endInfo = ended.mock.calls[0]![0] as Record expect(endInfo.stopReason).toBe('error') expect('lastAssistantMessage' in endInfo).toBe(false) @@ -922,6 +1190,42 @@ describe('SubagentService', () => { await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) }) + it('contains asynchronous lifecycle-listener rejections without serializing later listeners', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + const laterStart = vi.fn() + const laterEnd = vi.fn() + const laterRemoved = vi.fn() + const asyncStart = (async () => { await Promise.resolve(); throw new Error('async start listener') }) as unknown as () => void + const asyncEnd = (async () => { await Promise.resolve(); throw new Error('async end listener') }) as unknown as () => void + const asyncRemoved = (async () => { await Promise.resolve(); throw new Error('async removed listener') }) as unknown as () => void + ctx.on('subagent/start', asyncStart) + ctx.on('subagent/start', laterStart) + ctx.on('subagent/end', asyncEnd) + ctx.on('subagent/end', laterEnd) + ctx.on('subagent/provider-removed', asyncRemoved) + ctx.on('subagent/provider-removed', laterRemoved) + const unregister = ctx.subagents.registerProvider(new StubProvider('async-listeners')) + + const run = ctx.subagents.start('async-listeners', baseRequest()) + await run.started + expect(laterStart).toHaveBeenCalledOnce() + await run.result + await vi.waitFor(() => { + expect(laterEnd).toHaveBeenCalledOnce() + expect(warnings.some(message => message.includes('async start listener'))).toBe(true) + expect(warnings.some(message => message.includes('async end listener'))).toBe(true) + }) + + await unregister() + expect(laterRemoved).toHaveBeenCalledWith('async-listeners') + await vi.waitFor(() => { + expect(warnings.some(message => message.includes('async removed listener'))).toBe(true) + }) + }) + it('contains a listener whose thrown value cannot be stringified', async () => { const ctx = new Context() await ctx.plugin(SubagentService) diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 153d97a45c..8d8f52f3d7 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -367,6 +367,7 @@ export function apply(ctx: Context): void { 'tools/result': args => (args[0] as ToolExecution).agent, 'system-prompt/assemble': args => (args[1] as AssembleContext).scope, 'session/created': null, + 'session/disposed': null, 'session/event': null, 'session/flush': null, 'subagent/start': null, diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index a3ce9b5117..35ff870080 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -1010,7 +1010,7 @@ export function apply(ctx: Context, config: AcpConfig): void { * quiescence"): for each session settle any pending prompt `cancelled`, then * run that session's {@link AgentHandle} `dispose()` — which stops the loop * (sets `disposed`, aborts the in-flight step), AWAITS the loop's exit (the - * final `turn/end` + `session/flush` are captured while `onAppend` is still + * final `turn/end` + `session/flush` are captured while the store-owned append observer is still * attached), unregisters the agent, and removes its session from the store. * The per-session disposes run in parallel. Idempotent — clears the `sessions` * map first and memoizes, so a second call (close racing dispose) is a no-op. diff --git a/packages/ui/acp/tests/dispose.spec.ts b/packages/ui/acp/tests/dispose.spec.ts index 037d00f17e..9dbd72aa35 100644 --- a/packages/ui/acp/tests/dispose.spec.ts +++ b/packages/ui/acp/tests/dispose.spec.ts @@ -159,8 +159,8 @@ describe('acp bridge — disposal & HMR safety', () => { it('the final turn closing events are persisted across an AgentHandle dispose (durability)', async () => { // The teardown-ORDER guarantee: a per-agent dispose must stop the loop, // AWAIT its exit (so the loop's final `turn/end` + `session/flush` fire - // through the still-attached `session.onAppend` → `session/event`), and only - // THEN detach onAppend + remove the session. If the order were inverted + // through the still-attached store observer → `session/event`), and only + // THEN detach that observer + remove the session. If the order were inverted // (detach first), the closing events would never reach persistence. Drive a // CLEAN turn to completion, dispose JUST the bridge, then re-load the // persisted log from disk and assert the closing turn/end is on disk — the @@ -190,7 +190,7 @@ describe('acp bridge — disposal & HMR safety', () => { // produced BY the dispose itself. Here the model stream HANGS, so the turn is // still open when teardown runs: the composite agent effect stops the loop, // the loop unwinds and appends `turn/end {disposed}` + runs its final - // `session/flush` — all while `onAppend` is still attached (the session + // `session/flush` — all while the store-owned append observer is still attached (the session // detach is the LAST disposer in the same effect's LIFO chain) — and only // THEN is the session detached. If the order were inverted (or the session // were a racing SIBLING effect), the abort-produced `turn/end` would never @@ -255,7 +255,7 @@ describe('acp bridge — disposal & HMR safety', () => { // into ONE composite effect whose disposers run as a `.then()` chain. The // register disposer emits `agent/disposed`; if a listener throws and the // emit is UNCONTAINED, the rejected chain skips the LATER session-detach - // disposer — stranding the session in the store with `onAppend` attached (a + // disposer — stranding the session in the store with its append observer attached (a // leak AND a durability hole, since the new design relies on detach // running). The emit must be contained. Register a throwing listener, drive // a clean turn, dispose, and assert the session was STILL removed. diff --git a/packages/ui/user-approval/README.md b/packages/ui/user-approval/README.md index 821e98638f..1c573cdf3f 100644 --- a/packages/ui/user-approval/README.md +++ b/packages/ui/user-approval/README.md @@ -2,11 +2,11 @@ User-approval seam. Owns the `ctx.approval` service ([`ApprovalService`](src/index.ts)) and the one-shot permission vocabulary the harness shares: `ApprovalRequest` (agent + tool identity + reason + abort signal), the closed `ApprovalOutcome` union (`allowed-once` / `rejected` / `cancelled` / `unavailable`), the `ApprovalRequestId` brand pairing the two log-only audit events (`approval/asked` / `approval/decided`), and the `approval/request` waterfall the answerers listen on. It lives in the UI group because its purpose is human permission, while remaining channel-neutral: it depends only on Cordis and core vocabulary packages, never on a concrete UI. -The contract in one line: `ctx.approval.request(req)` puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and always resolves to an outcome, never rejects: an aborted signal yields `cancelled`, a throwing or missing answerer yields `unavailable`, and `allowed-once` is a grant for the single asked-about action, never a class of future ones. Acceptance is synchronous: the service shallow-freezes a detached request record before dispatch, preserving the exact `agent` and `AbortSignal` identities while making later caller mutation unable to redirect scope, payload, cancellation, or either audit event. Session observers run after an event enters the append-only log; if one throws, the service recognizes that the audit is already authoritative, contains the observer failure, and completes the pair. The one precondition: ask from inside an open turn — the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask throws before appending anything. +The contract in one line: `ctx.approval.request(req)` puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and its decision phase always resolves to an outcome: an aborted signal yields `cancelled`, a throwing or missing answerer yields `unavailable`, and `allowed-once` is a grant for the single asked-about action, never a class of future ones. Acceptance is synchronous: the service reads the request fields and `agent.session` binding once, requires object agent/session identities, a string `toolName`, optional string `callId`/`reason`, and an AbortSignal-shaped live capability, then shallow-freezes a detached request record while preserving the exact `agent` and signal identities. A malformed request rejects before any audit append; later caller mutation cannot redirect scope, payload, cancellation, policy lookup, or either audit event. The other precondition is an open turn on the captured session — the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask also rejects before appending. Session observers run after an event enters the append-only log; if one throws, the service recognizes that the audit is already authoritative, contains the observer failure, and completes the pair. The service is the mechanism, answerers are the policy. Answerers are `approval/request` waterfall listeners occupying a single decision slot: answer for an agent you own by returning an outcome without calling `next()`, or delegate an agent you don't recognize by calling `next()` — the chain's built-in default is `unavailable`, so a deployment with no answerer (headless, CI) fails closed with zero configuration. Dispatch is keyed by `req.agent`: a listener registered through `agent.ctx` receives only that agent's questions, while a plain-context listener receives every agent's. Registration order across sibling plugins is not load-order deterministic; compose one terminal answerer per deployment and use `prepend` listeners only for decide-or-delegate gates. -The seam also owns the per-session POLICY tier ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): `ApprovalPolicy` is `'ask'` (delegate to the answerers) or `'never'` (deterministically reject without prompting anyone; the strict CI/unattended stance), with `effective = fold(the session's 'approval/policy' events, last one wins) ?? Config.policy` — the session log is the store, written only through `setApprovalPolicy(session, policy)`. The service decides `'never'` inside `request()` itself, before dispatching the waterfall (`'never'` → `'rejected'` with the audit pair still landing; no listener registration, including a later `prepend`, can precede it), states `'never'` — and only `'never'` in prose — in a per-agent prompt section, records either value with a source-owned header marker, and narrates a policy switch to the model in at most one coalesced `agent/pre-step` notice. The restart fallback reads the marker rather than deployment-controlled persona prose; attribution is positional (an override event after the last `request/header*` reads `changed by the user`, otherwise `changed by the operator/config`). +The seam also owns the per-session POLICY tier ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): `ApprovalPolicy` is `'ask'` (delegate to the answerers) or `'never'` (deterministically reject without prompting anyone; the strict CI/unattended stance), with `effective = fold(the session's 'approval/policy' events, last one wins) ?? Config.policy` — the session log is the store, written only through `setApprovalPolicy(session, policy)`, which rejects any value outside that closed vocabulary before appending. The service decides `'never'` inside `request()` itself, before dispatching the waterfall (`'never'` → `'rejected'` with the audit pair still landing; no listener registration, including a later `prepend`, can precede it), states `'never'` — and only `'never'` in prose — in a per-agent prompt section, records either value with a source-owned header marker, and narrates a policy switch to the model in at most one coalesced `agent/pre-step` notice. The restart fallback reads the marker rather than deployment-controlled persona prose; attribution is positional (an override event after the last `request/header*` reads `changed by the user`, otherwise `changed by the operator/config`). One seam serves both ask paths of [the sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md): the `tools/pre-execute` `ask` decision (routed by [`@deepseek-ai/dsh-tools`](../../core/tools/) when this service is mounted; degrading to deny when it is not), and the sandbox post-denial escalated retry (the bash tool's `sandbox_permissions` gate in [`@deepseek-ai/dsh-tool-bash`](../../bash/tool-bash/) — [the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). The full design: [the approval-seam RFC](../../../docs/rfc/implemented/feature/2026-07-06-approval-seam.md). diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 4fb50ba23b..93ccd973c7 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -223,12 +223,16 @@ function hasOpenTurn(events: readonly SessionEvent[]): boolean { * THE write path for a session's approval-policy override: appends exactly * one `approval/policy` event — the switch IS its event; nothing mutates * policy state out of band. Takes effect on the session's next ask and next - * prompt assembly (the consumers fold on every read). + * prompt assembly (the consumers fold on every read). Rejects a value outside + * {@link APPROVAL_POLICIES} before appending anything. * @param session - the session the override belongs to. * @param policy - the policy every subsequent ask for this session resolves * under (until the next switch). */ export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): void { + if (!APPROVAL_POLICIES.includes(policy)) { + throw new TypeError('approval policy must be one of "ask" or "never"') + } session.append('approval/policy', { policy }) } @@ -238,8 +242,10 @@ export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): voi * was asked — 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. `request()` synchronously copies and shallow-freezes - * this record before crossing an asynchronous boundary. Scalar fields are - * detached; the `agent` and `signal` identity capabilities are preserved. + * this record before crossing an asynchronous boundary. It reads each field + * and the agent's session binding once, validates the public fixed-field + * contract before audit, and detaches the scalar values; the `agent` and live + * `signal` identity capabilities are preserved rather than cloned or frozen. */ export interface ApprovalRequest { /** @@ -264,6 +270,13 @@ export interface ApprovalRequest { signal?: AbortSignal } +/** Live signal capability accepted at the synchronous request boundary. */ +interface AcceptedSignal { + signal: AbortSignal + addEventListener: AbortSignal['addEventListener'] + removeEventListener: AbortSignal['removeEventListener'] +} + /** Plugin config. All optional — `static Config` supplies the defaults. */ export interface Config { /** @@ -297,7 +310,7 @@ export class ApprovalService extends Service { constructor(ctx: Context, public config: Config) { super(ctx, 'approval') - const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent) + const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent.session) // Visibility layer 1, scoped on the prompt registry so headless // compositions mount the seam without it: state the one deterministic @@ -345,7 +358,7 @@ export class ApprovalService extends Service { } // Same fold effectivePolicy performs — override is scanned here anyway // for POSITIONAL attribution; the default lives once, in the method. - const current = this.effectivePolicy(agent) + const current = this.effectivePolicy(session) const header = session.requestHeader() const told = narrated.get(session) ?? toldApprovalPolicy(header?.system) narrated.set(session, current) @@ -361,17 +374,21 @@ export class ApprovalService extends Service { } /** - * Ask the composed answerers to decide one request. Requires an open turn - * on the requesting agent's session — the audit pair below is turn-enclosed - * by contract (the turn is the log's commit/replay boundary; an idle append - * would be dropped as crash tail on reload) — and throws before appending - * anything when called idle; asking outside a turn is a deferred design. - * Within that precondition it always resolves to an outcome, never rejects: - * an aborted signal yields `'cancelled'`, a missing or throwing answerer - * yields `'unavailable'` (fail closed), and a rogue non-vocabulary return - * value is normalized to `'unavailable'`. The caller-owned request is - * synchronously snapshotted, so later mutation cannot split routing, - * dispatch payload, cancellation, or the audit pair across agents/sessions. + * Ask the composed answerers to decide one request. Synchronously reads each + * request field and the agent's session binding once, validates the fixed + * agent/session, string, and live-signal contracts, and rejects before any + * audit append when malformed. The signal remains the caller's exact live + * identity capability; it is neither cloned nor frozen. Requires an open + * turn on the accepted session — the audit pair below is turn-enclosed by + * contract (the turn is the log's commit/replay boundary; an idle append + * would be dropped as crash tail on reload) — and likewise throws before + * appending anything when called idle; asking outside a turn is a deferred + * design. Once accepted it always resolves to an outcome, never rejects: an + * aborted signal yields `'cancelled'`, a missing or throwing answerer yields + * `'unavailable'` (fail closed), and a rogue non-vocabulary return value is + * normalized to `'unavailable'`. The caller-owned request is synchronously + * snapshotted, so later mutation cannot split routing, dispatch payload, + * cancellation, policy lookup, or the audit pair across agents/sessions. * Appends the * `approval/asked`/`approval/decided` audit pair (log-only) around the * decision regardless of outcome. A synchronous session observer failure @@ -385,20 +402,73 @@ export class ApprovalService extends Service { // Accept one immutable request shape before the first async boundary. The // caller retains its record and may mutate it as soon as this async method // returns; identity capabilities stay live, but the record is never reread. - const agent = req.agent - const toolName = req.toolName - const callId = req.callId - const reason = req.reason - const signal = req.signal + const input: unknown = req + if (typeof input !== 'object' || input === null) { + throw new TypeError('approval.request() requires a request object') + } + const source = input as Record + const agentInput = source['agent'] + const toolName = source['toolName'] + const callId = source['callId'] + const reason = source['reason'] + const signalInput = source['signal'] + if (typeof agentInput !== 'object' || agentInput === null) { + throw new TypeError('approval request agent must be an object') + } + if (typeof toolName !== 'string') { + throw new TypeError('approval request toolName must be a string') + } + if (callId !== undefined && typeof callId !== 'string') { + throw new TypeError('approval request callId must be a string when provided') + } + if (reason !== undefined && typeof reason !== 'string') { + throw new TypeError('approval request reason must be a string when provided') + } + let acceptedSignal: AcceptedSignal | undefined + if (signalInput !== undefined) { + if (typeof signalInput !== 'object' || signalInput === null) { + throw new TypeError('approval request signal must be an AbortSignal when provided') + } + const signalRecord = signalInput as unknown as Record + const aborted = signalRecord['aborted'] + const addEventListener = signalRecord['addEventListener'] + const removeEventListener = signalRecord['removeEventListener'] + if (typeof aborted !== 'boolean' + || typeof addEventListener !== 'function' + || typeof removeEventListener !== 'function') { + throw new TypeError('approval request signal must be an AbortSignal when provided') + } + acceptedSignal = { + signal: signalInput as AbortSignal, + addEventListener: addEventListener as AbortSignal['addEventListener'], + removeEventListener: removeEventListener as AbortSignal['removeEventListener'], + } + } + const sessionInput = (agentInput as unknown as Record)['session'] + if (typeof sessionInput !== 'object' || sessionInput === null) { + throw new TypeError('approval request agent session must be an object') + } + const sessionRecord = sessionInput as unknown as Record + const events = sessionRecord['events'] + const append = sessionRecord['append'] + if (!Array.isArray(events)) { + throw new TypeError('approval request session events must be an array') + } + if (typeof append !== 'function') { + throw new TypeError('approval request session append must be a function') + } + const agent = agentInput as Agent + const session = sessionInput as Session + const acceptedCallId = callId as CallId | undefined + const signal = signalInput as AbortSignal | undefined const accepted: Readonly = Object.freeze({ agent, toolName, - ...callId !== undefined ? { callId } : {}, + ...acceptedCallId !== undefined ? { callId: acceptedCallId } : {}, ...reason !== undefined ? { reason } : {}, ...signal !== undefined ? { signal } : {}, }) - const session = accepted.agent.session - if (!hasOpenTurn(session.events)) { + if (!hasOpenTurn(events)) { throw new Error( 'approval.request() outside an open turn: the approval/asked + approval/decided audit pair ' + 'must be turn-enclosed (a bare event between turns is crash-tail garbage on reload). ' @@ -407,16 +477,16 @@ export class ApprovalService extends Service { } const id = ApprovalRequestId(randomUUID()) this.appendAudit(session, 'approval/asked', id, () => { - session.append('approval/asked', { + Reflect.apply(append, session, ['approval/asked', { id, toolName: accepted.toolName, ...accepted.callId !== undefined ? { callId: accepted.callId } : {}, ...accepted.reason !== undefined ? { reason: accepted.reason } : {}, - }) + }]) }) - const outcome = await this.decide(accepted) + const outcome = await this.decide(accepted, session, acceptedSignal) this.appendAudit(session, 'approval/decided', id, () => { - session.append('approval/decided', { id, outcome }) + Reflect.apply(append, session, ['approval/decided', { id, outcome }]) }) return outcome } @@ -451,22 +521,30 @@ export class ApprovalService extends Service { * The session's effective policy: its own `approval/policy` fold, else the * configured default (the schema already defaulted an omitted policy to * `'ask'`; the `??` only narrows the optional-input TYPE). - * @param agent - the agent whose session's policy applies. - * @returns the policy every ask for this agent resolves under right now. + * @param session - the exact accepted session whose policy applies. + * @returns the policy every ask for this session resolves under right now. */ - private effectivePolicy(agent: Agent): ApprovalPolicy { - return effectiveApprovalPolicy(agent.session.events) ?? this.config.policy ?? 'ask' + private effectivePolicy(session: Session): ApprovalPolicy { + return effectiveApprovalPolicy(session.events) ?? this.config.policy ?? 'ask' } - /** Dispatch the waterfall, contained and raced against the accepted signal. */ - private async decide(req: Readonly): Promise { - if (req.signal?.aborted) return 'cancelled' + /** + * Dispatch the waterfall, contained and raced against the accepted signal. + * @param req - the detached public request snapshot. + * @param session - the captured session used for policy lookup. + * @param acceptedSignal - the validated live signal capability, if supplied. + * @returns the normalized closed outcome. + */ + private async decide( + req: Readonly, session: Session, acceptedSignal: AcceptedSignal | undefined, + ): Promise { + if (acceptedSignal?.signal.aborted) return 'cancelled' // The 'never' policy is decided HERE, before any dispatch: a listener // registered with `prepend: true` after this service mounts would sit // ahead of any gate LISTENER, so a listener-shaped gate cannot keep the // documented promise that 'never' rejects deterministically regardless // of registration order — only the service's own request path can. - if (this.effectivePolicy(req.agent) === 'never') return 'rejected' + if (this.effectivePolicy(session) === 'never') return 'rejected' // Enter the promise chain BEFORE dispatching: a listener that throws // SYNCHRONOUSLY (before its first await) must land in the same rejection // path as an async one — `Promise.resolve(call())` would let it escape @@ -484,13 +562,13 @@ export class ApprovalService extends Service { // tool call open — the seam contains its callbacks. () => 'unavailable', ) - const signal = req.signal - if (signal === undefined) return answer + if (acceptedSignal === undefined) return answer + const { signal, addEventListener, removeEventListener } = acceptedSignal return await new Promise((resolve) => { const onAbort = () => { resolve('cancelled') } - signal.addEventListener('abort', onAbort, { once: true }) + addEventListener.call(signal, 'abort', onAbort, { once: true }) void answer.then((outcome) => { - signal.removeEventListener('abort', onAbort) + removeEventListener.call(signal, 'abort', onAbort) // After an abort won the race this resolve is a settled-promise no-op: // the late answer is discarded by construction. resolve(outcome) diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index 0e69b8dfd7..831b50111b 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -39,6 +39,158 @@ function requestOf(agent: Agent, overrides: Partial = {}): Appr } describe('ApprovalService.request', () => { + it('rejects malformed fixed fields and identities before appending or dispatching', async () => { + const ctx = await mounted() + const consulted = vi.fn() + ctx.on('approval/request', () => { + consulted() + return Promise.resolve('allowed-once') + }) + const { agent, appended } = fakeAgent() + const badSessionAppends: Array> = [] + const badSession = (events: unknown, append: unknown): Agent => ({ + session: { events, append }, + }) as unknown as Agent + const appendSpy = (): ReturnType => { + const append = vi.fn() + badSessionAppends.push(append) + return append + } + const validSignalShape = { + aborted: false, + addEventListener: () => {}, + removeEventListener: () => {}, + } + const cases: Array<{ request: unknown; message: string }> = [ + { request: null, message: 'requires a request object' }, + { request: 1, message: 'requires a request object' }, + { request: { agent: null, toolName: 'echo' }, message: 'agent must be an object' }, + { request: { agent: 1, toolName: 'echo' }, message: 'agent must be an object' }, + { request: { agent, toolName: 1 }, message: 'toolName must be a string' }, + { request: { agent, toolName: 'echo', callId: 1 }, message: 'callId must be a string' }, + { request: { agent, toolName: 'echo', reason: 1 }, message: 'reason must be a string' }, + { request: { agent, toolName: 'echo', signal: null }, message: 'signal must be an AbortSignal' }, + { request: { agent, toolName: 'echo', signal: 1 }, message: 'signal must be an AbortSignal' }, + { + request: { agent, toolName: 'echo', signal: { ...validSignalShape, aborted: 'no' } }, + message: 'signal must be an AbortSignal', + }, + { + request: { agent, toolName: 'echo', signal: { ...validSignalShape, addEventListener: 1 } }, + message: 'signal must be an AbortSignal', + }, + { + request: { agent, toolName: 'echo', signal: { ...validSignalShape, removeEventListener: 1 } }, + message: 'signal must be an AbortSignal', + }, + { + request: { agent: { session: null }, toolName: 'echo' }, + message: 'agent session must be an object', + }, + { + request: { agent: { session: 1 }, toolName: 'echo' }, + message: 'agent session must be an object', + }, + { + request: { agent: badSession(null, appendSpy()), toolName: 'echo' }, + message: 'session events must be an array', + }, + { + request: { agent: badSession([{ type: 'turn/start' }], 1), toolName: 'echo' }, + message: 'session append must be a function', + }, + ] + + for (const { request, message } of cases) { + await expect(ctx.approval.request(request as ApprovalRequest)).rejects.toThrow(message) + } + + expect(appended).toEqual([]) + for (const append of badSessionAppends) expect(append).not.toHaveBeenCalled() + expect(consulted).not.toHaveBeenCalled() + }) + + it('reads request fields, the agent session, and the session append method once', async () => { + const ctx = await mounted() + const { agent: acceptedSessionOwner, appended: acceptedAudit } = fakeAgent() + const { agent: replacementAgent, appended: replacementAudit } = fakeAgent() + const acceptedSession = acceptedSessionOwner.session + const acceptedAppend = acceptedSession.append.bind(acceptedSession) + const signal = new AbortController().signal + const reads = { + agent: 0, + toolName: 0, + callId: 0, + reason: 0, + signal: 0, + session: 0, + append: 0, + } + const session = { + events: acceptedSession.events, + get append(): Session['append'] { + reads.append += 1 + return reads.append === 1 ? acceptedAppend : undefined as unknown as Session['append'] + }, + } as Session + const agent = Object.defineProperty({}, 'session', { + enumerable: true, + get: () => { + reads.session += 1 + return reads.session === 1 ? session : replacementAgent.session + }, + }) as Agent + const request = Object.defineProperties({}, { + agent: { + enumerable: true, + get: () => (++reads.agent === 1 ? agent : null), + }, + toolName: { + enumerable: true, + get: () => (++reads.toolName === 1 ? 'stable-tool' : 1), + }, + callId: { + enumerable: true, + get: () => (++reads.callId === 1 ? CallId('stable-call') : {}), + }, + reason: { + enumerable: true, + get: () => (++reads.reason === 1 ? 'stable reason' : {}), + }, + signal: { + enumerable: true, + get: () => (++reads.signal === 1 ? signal : {}), + }, + }) as ApprovalRequest + let received: ApprovalRequest | undefined + ctx.on('approval/request', (accepted) => { + received = accepted + return Promise.resolve('allowed-once') + }) + + await expect(ctx.approval.request(request)).resolves.toBe('allowed-once') + + expect(reads).toEqual({ + agent: 1, + toolName: 1, + callId: 1, + reason: 1, + signal: 1, + session: 1, + append: 1, + }) + expect(received).toMatchObject({ + agent, + toolName: 'stable-tool', + callId: 'stable-call', + reason: 'stable reason', + signal, + }) + expect(Object.isFrozen(received)).toBe(true) + expect(acceptedAudit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided']) + expect(replacementAudit).toEqual([]) + }) + it('throws before appending anything when no turn has ever opened (idle ask)', async () => { const ctx = await mounted() const { agent, appended } = fakeAgent([]) @@ -420,6 +572,15 @@ describe('approval policy (the approval/policy fold)', () => { expect(session.events.at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' } }) }) + it('rejects a policy outside the closed vocabulary before appending', () => { + const append = vi.fn() + const session = { append } as unknown as Session + + expect(() => { setApprovalPolicy(session, 'sometimes' as Parameters[1]) }) + .toThrow('approval policy must be one of "ask" or "never"') + expect(append).not.toHaveBeenCalled() + }) + it('defaults a schema-less construction to ask (the ?? narrows the optional TYPE)', async () => { // Direct construction bypasses the plugin schema (the SystemPrompt-test // precedent for covering a defaulted Config field's type-narrowing ??). diff --git a/packages/workflow/workflow-workerthread/README.md b/packages/workflow/workflow-workerthread/README.md index d7cd9ed64c..2ea1a78c39 100644 --- a/packages/workflow/workflow-workerthread/README.md +++ b/packages/workflow/workflow-workerthread/README.md @@ -35,7 +35,7 @@ Values LEAVING the script (hook options/schemas, the script's return) are materi ## Cancellation, death, disposal -Per-run limits: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` posts the cancel to the worker (its hooks start throwing `CANCELLED`; the script dies at its next await) and cancels every host-side child NOW on **both seam channels** — the shared request signal aborts AND each registered child's explicit `cancel()` is called host-side, because the seam leaves a provider free to honor either channel and a worker wedged in a synchronous spin could not relay its own per-child cancel RPCs (those later land as idempotent no-ops). Each provider-owned cancel callback is exception-contained independently, so a broken child cannot prevent peer cancellation or workflow settlement. The grace then arms: a run still unsettled `disposeGraceMs` later force-settles `cancelled` and the worker is **terminated**. A cancellation that lands before the body runs (the ready→go handshake) reports `cancelled` without executing anything; a worker `result` racing an in-flight host cancellation reports `cancelled` too (first-wins settlement — the seam-visible result had not settled when cancellation was requested); post-cancel `phase`/`log` narration is suppressed host-side, while cancelled children still deliver their paired `agent-end`. +Per-run limits: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` posts the cancel to the worker (its hooks start throwing `CANCELLED`; the script dies at its next await) and cancels every host-side child NOW on **both seam channels** — the shared request signal aborts AND each registered child's explicit `cancel()` is called host-side, because the seam leaves a provider free to honor either channel and a worker wedged in a synchronous spin could not relay its own per-child cancel RPCs (those later land as idempotent no-ops). Each provider-owned cancel callback is exception-contained independently, so a broken child cannot prevent peer cancellation or workflow settlement. The caller's optional start-signal callback is retained by exact identity only while the run is live and removed at the first settlement or teardown, so a long-lived signal cannot retain completed `WorkerRun` instances. The grace then arms: a run still unsettled `disposeGraceMs` later force-settles `cancelled` and the worker is **terminated**. A cancellation that lands before the body runs (the ready→go handshake) reports `cancelled` without executing anything; a worker `result` racing an in-flight host cancellation reports `cancelled` too (first-wins settlement — the seam-visible result had not settled when cancellation was requested); post-cancel `phase`/`log` narration is suppressed host-side, while cancelled children still deliver their paired `agent-end`. A worker that dies unexpectedly (an OOM, a script reaching `process.exit` through the documented vm escape) settles the run `stopReason: 'error'` with the exit diagnostics — or `'cancelled'` when a cancel was in flight — and the host-side child registry is what winds every surviving child down. `dispose()` = cancel + immediate host-driven disposal of every registered child (a wedged worker can relay no dispose RPC, so child teardown overlaps the grace instead of starting after it; the worker's own dispose RPCs join the same per-child disposal) + bounded wait (result, then child-registry quiescence, capped by the grace) + unconditional `worker.terminate()`: the thread never outlives its run. Before an ordinary run settlement becomes observable, the host cancels every stray child on both channels too—even a fire-and-forget run still waiting on `started`, for which the worker has no handle yet—and `dispose()` then waits for their disposal (bounded by the grace) before returning. `agent-start`/`agent-end` pairing is host-guaranteed the same way: forwarded starts live in a ledger, worker-reported ends pair them on the graceful paths, and the termination paths (grace force-settle, worker death) synthesize the missing ends (outcome `cancelled`) before the run settles — a start still in flight across the force-settle can surface after `workflow/end`, immediately paired the same way. diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index b1d92ac1d0..e1c1a6542f 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -130,6 +130,9 @@ export class WorkerRun implements WorkflowRun { private readonly quiescenceWaiters: (() => void)[] = [] /** The per-run abort fanout every child start request carries. */ private readonly controller = new AbortController() + /** External start signal and the exact callback installed on it, retained only until first settle/teardown. */ + private inputSignal: AbortSignal | undefined + private inputSignalAbort: (() => void) | undefined private disposed: Promise | undefined constructor( @@ -159,8 +162,14 @@ export class WorkerRun implements WorkflowRun { }) if (signal?.aborted) { this.cancel('workflow start signal already aborted') - } else { - signal?.addEventListener('abort', () => { this.cancel('workflow signal aborted') }, { once: true }) + } else if (signal !== undefined) { + const onAbort = (): void => { + this.detachInputSignal() + this.cancel('workflow signal aborted') + } + this.inputSignal = signal + this.inputSignalAbort = onAbort + signal.addEventListener('abort', onAbort, { once: true }) } } @@ -217,6 +226,7 @@ export class WorkerRun implements WorkflowRun { */ dispose(): Promise { this.disposed ??= (async () => { + this.detachInputSignal() this.cancel('workflow disposed') for (const [callId, run] of [...this.children]) void this.disposeChild(callId, run) await Promise.race([ @@ -522,10 +532,21 @@ export class WorkerRun implements WorkflowRun { return { value: null, stopReason: 'cancelled', error: `workflow run cancelled: ${reason}`, agentsStarted } } - /** First settle wins; disarms the grace timer. */ + /** Remove the exact abort callback installed on the caller's start signal. */ + private detachInputSignal(): void { + const signal = this.inputSignal + const onAbort = this.inputSignalAbort + if (signal === undefined || onAbort === undefined) return + this.inputSignal = undefined + this.inputSignalAbort = undefined + signal.removeEventListener('abort', onAbort) + } + + /** First settle wins; disarms the grace timer and releases the caller signal. */ private settleResult(result: WorkflowResult): void { if (this.settled) return this.settled = true + this.detachInputSignal() clearTimeout(this.graceTimer) this.settleResolve(result) } diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 46a42e4036..d5b16a8e2f 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -585,6 +585,41 @@ describe('dsh-workflow-workerthread', () => { await second.dispose() }) + it('removes the exact external abort callback on first settlement or teardown', async () => { + const { ctx, parent } = await setup() + const settledController = new AbortController() + const settledAdd = vi.spyOn(settledController.signal, 'addEventListener') + const settledRemove = vi.spyOn(settledController.signal, 'removeEventListener') + const completed = ctx.workflows.start({ ...scripted('return 123'), parent, signal: settledController.signal }) + const settledAbort = settledAdd.mock.calls.find(([type]) => type === 'abort')?.[1] + expect(typeof settledAbort).toBe('function') + + await expect(completed.result).resolves.toMatchObject({ value: 123, stopReason: 'completed' }) + expect(settledRemove).toHaveBeenCalledWith('abort', settledAbort) + const cancelAfterSettle = vi.spyOn(completed, 'cancel') + settledController.abort() + expect(cancelAfterSettle).not.toHaveBeenCalled() + cancelAfterSettle.mockRestore() + await completed.dispose() + + const manual = await setup({ manual: true }) + const teardownController = new AbortController() + const teardownAdd = vi.spyOn(teardownController.signal, 'addEventListener') + const teardownRemove = vi.spyOn(teardownController.signal, 'removeEventListener') + const tornDown = manual.ctx.workflows.start({ + ...scripted("return await agent('job')"), + parent: manual.parent, + signal: teardownController.signal, + }) + await waitFor(() => { expect(manual.provider.runs).toHaveLength(1) }) + const teardownAbort = teardownAdd.mock.calls.find(([type]) => type === 'abort')?.[1] + expect(typeof teardownAbort).toBe('function') + + const disposing = tornDown.dispose() + expect(teardownRemove).toHaveBeenCalledWith('abort', teardownAbort) + await disposing + }) + it('a child-start racing the host cancel is refused: no child starts after cancellation', async () => { const { ctx, parent, provider } = await setup({ manual: true }) // Cancel from INSIDE the log listener: the worker has already posted diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 074cfd83d2..5459755be5 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -246,6 +246,13 @@ const SERVICE_ROLES: ServiceRole[] = [ ] const DYNAMIC_EVENT_DISPATCHERS: Array<{ event: string; pkg: string; method: string }> = [ + // Creation notifications preserve synchronous veto/rollback but observe + // returned promises explicitly so async listener rejection is not unhandled. + { event: 'agent/created', pkg: 'agent', method: 'events.dispatch' }, + { event: 'session/created', pkg: 'session', method: 'events.dispatch' }, + // Session disposal uses direct callback resolution so teardown contains each + // synchronous throw and returned-promise rejection independently. + { event: 'session/disposed', pkg: 'session', method: 'events.dispatch' }, // tools/result uses ctx.events.dispatch directly so the registry can await // every observer while containing each callback independently. { event: 'tools/result', pkg: 'tools', method: 'events.dispatch' }, diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 4be54d89f4..e238583e69 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -17,6 +17,14 @@ { "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationStop", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "SessionStartSource", "source": "packages/core/agent/src/types.ts" }, + { "doc": "docs/core-data-structures/scope.md", "symbol": "ScopeKey", "source": "packages/core/scope/src/index.ts" }, + { "doc": "docs/core-data-structures/scope.md", "symbol": "Scoped", "source": "packages/core/scope/src/index.ts" }, + { "doc": "docs/core-data-structures/scope.md", "symbol": "Scope", "source": "packages/core/scope/src/index.ts" }, + + { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "AssembleContext", "source": "packages/core/system-prompt/src/index.ts" }, + { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "ToolProviderResult", "source": "packages/core/system-prompt/src/index.ts" }, + { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "PromptProtection", "source": "packages/core/system-prompt/src/index.ts" }, + { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "TokenUsage", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, From 197f7237d2eb8889866759e5f48f801ba5d7197f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 12 Jul 2026 06:19:53 +0800 Subject: [PATCH 41/64] fix(workflow): bootstrap source worker transforms --- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- .../process/2026-07-06-node-engine-floor.md | 4 +- .../2026-07-06-parallel-github-ci-gates.md | 6 +-- .../workflow/workflow-workerthread/README.md | 2 +- .../workflow-workerthread/src/host.ts | 48 ++++++++++++------- .../tests/source-worker.compat.spec.ts | 38 +++++++++++++++ .../tests/workflow-workerthread.spec.ts | 6 +-- scripts/run-gates.ts | 5 ++ 10 files changed, 85 insertions(+), 32 deletions(-) create mode 100644 packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index c1e557170d..e2dd772eb0 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -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 diff --git a/docs/development.md b/docs/development.md index bd6f6b5614..ea5f2e5d08 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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`. diff --git a/docs/development.zh.md b/docs/development.zh.md index 54bf19765d..50877796f2 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -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`。 diff --git a/docs/rfc/implemented/process/2026-07-06-node-engine-floor.md b/docs/rfc/implemented/process/2026-07-06-node-engine-floor.md index 72c9f09eda..51328a5a1a 100644 --- a/docs/rfc/implemented/process/2026-07-06-node-engine-floor.md +++ b/docs/rfc/implemented/process/2026-07-06-node-engine-floor.md @@ -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. diff --git a/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md b/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md index d63a8ad713..061d534b9b 100644 --- a/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ b/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -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. diff --git a/packages/workflow/workflow-workerthread/README.md b/packages/workflow/workflow-workerthread/README.md index 2ea1a78c39..0aba0eb680 100644 --- a/packages/workflow/workflow-workerthread/README.md +++ b/packages/workflow/workflow-workerthread/README.md @@ -21,7 +21,7 @@ What the seam guarantees regardless, because benign scripts hit these constantly ## How a run executes -`start()` shape-validates the meta DATA host-side and parse-checks the body with the identical wrapper the worker compiles (`new vm.Script`, discarded), preserving the seam's synchronous `META_INVALID`/`SCRIPT_PARSE` throws; one redundant parse per run is the deliberate price. It then spawns the worker (`src/worker.ts` unbuilt via an explicit tsx `execArgv`; the sibling `lib/worker.js` bundle when built) with the meta, body, `args`, and worker-side limits as `workerData`. +`start()` shape-validates the meta DATA host-side and parse-checks the body with the identical wrapper the worker compiles (`new vm.Script`, discarded), preserving the seam's synchronous `META_INVALID`/`SCRIPT_PARSE` throws; one redundant parse per run is the deliberate price. It then spawns the worker (unbuilt: a JavaScript data-URL bootstrap registers tsx's ESM and CommonJS transforms inside the worker before importing `src/worker.ts`, giving the whole mixed-module source graph full TypeScript and tsconfig-path transformation on every supported Node line; built: the sibling `lib/worker.js` bundle) with the meta, body, `args`, and worker-side limits as `workerData`. Inside the worker, `runWorkerSession` builds the execution core (hooks, combinators, concurrency semaphore, caps, fatal-error discipline) over a **child port**. `agent()` sends `child-start`, and the host starts the child on `ctx.subagents` with parent attribution, the shared per-run abort signal, and `outputSchema`/`model` pass-through. diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index e1c1a6542f..56c0e455f8 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -41,7 +41,6 @@ * @module @deepseek-ai/dsh-workflow-workerthread/host */ -import { fileURLToPath } from 'node:url' import { Worker } from 'node:worker_threads' import type { WorkerOptions } from 'node:worker_threads' import type { Context } from 'cordis' @@ -59,13 +58,16 @@ import type { ChildResult, ChildStartRequest, WorkerInit } from './types.ts' /** * Resolve the worker entry and spawn options for the current runtime shape. * Unbuilt (tsx demos, vitest — `import.meta.url` points into `src/`), the - * entry is the TypeScript sibling and the worker needs the tsx loader - * registered explicitly: a worker thread inherits no transform pipeline from - * vitest (vite transforms in-process, not via a node loader), and passing - * execArgv explicitly also shields the worker from any loader flags the - * parent was started with. Built (`lib/index.js`), the entry is the sibling - * bundle the package tsdown config emits and no loader is needed (execArgv - * pinned empty — hermetic, like the environment). + * entry is a JavaScript data-URL bootstrap. That bootstrap runs INSIDE the + * user worker, registers tsx's ESM AND CommonJS transforms there, and only + * then imports the TypeScript sibling. The whole mixed-module source graph + * therefore receives TypeScript transformation and the tsconfig paths map in + * the worker's own module-loader realm. A worker inherits no + * transform pipeline from vitest (vite transforms in-process), and a parent + * `--import tsx` registration is not a contract that user workers share on + * every supported Node line. Built (`lib/index.js`), the entry is the sibling + * bundle the package tsdown config emits and no loader is needed (`execArgv` + * pinned empty in both shapes — hermetic, like the environment). * * Both shapes spawn with an EMPTY environment (`env: {}`): the documented vm * escape reaches `process`, and the harness's ambient credentials @@ -85,19 +87,31 @@ function resolveWorkerSpawn(init: WorkerInit): { entry: URL; options: WorkerOpti if (!import.meta.url.endsWith('.ts')) { return { entry: new URL('./worker.js', import.meta.url), options: { workerData: init, env: {}, execArgv: [] } } } - // Lazy tsx resolution: only the unbuilt shape needs it, so the built - // bundle never requires tsx to be installed. TSX_TSCONFIG_PATH is the one - // variable forwarded through the scrub: tsx finds a tsconfig by searching - // UP from the worker's cwd, and a parent running with its cwd outside the - // repo (the ACP snapshot harness pins the tsconfig through this exact - // variable) would otherwise lose the dsh-* paths map and resolve workspace - // imports to unbuilt lib/ bundles. Loader plumbing, not a secret. + // Resolve tsx lazily: only the unbuilt shape executes this arm, so a built + // consumer never needs the dev-only loader installed. A JavaScript entry is + // essential — it can install tsx's ESM and CommonJS hooks from INSIDE the + // user worker before any TypeScript enters Node's native strip-only parser. + // Both hooks are load-bearing because the source graph crosses both module + // shapes on supported Node lines. TSX_TSCONFIG_PATH is + // the one variable forwarded through the scrub: a parent running outside + // the repo cwd (the ACP snapshot harness is the real case) pins the paths + // map through it. Loader plumbing, not a secret. + const workerEntry = new URL('./worker.ts', import.meta.url) + const tsxEsmApiEntry = import.meta.resolve('tsx/esm/api') + const tsxCjsApiEntry = import.meta.resolve('tsx/cjs/api') + const bootstrap = [ + `import { register as registerEsm } from ${JSON.stringify(tsxEsmApiEntry)}`, + `import { register as registerCjs } from ${JSON.stringify(tsxCjsApiEntry)}`, + 'registerCjs()', + 'registerEsm()', + `await import(${JSON.stringify(workerEntry.href)})`, + ].join('\n') return { - entry: new URL('./worker.ts', import.meta.url), + entry: new URL(`data:text/javascript,${encodeURIComponent(bootstrap)}`), options: { workerData: init, env: process.env.TSX_TSCONFIG_PATH === undefined ? {} : { TSX_TSCONFIG_PATH: process.env.TSX_TSCONFIG_PATH }, - execArgv: ['--import', fileURLToPath(import.meta.resolve('tsx'))], + execArgv: [], }, } } diff --git a/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts b/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts new file mode 100644 index 0000000000..7f34396d52 --- /dev/null +++ b/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts @@ -0,0 +1,38 @@ +/** + * Keyless runtime smoke for the source-mode workflow worker. The Node + * compatibility matrix runs this WHOLE file, so renaming or removing its test + * cannot turn the runtime proof into a successful zero-match filter. + */ + +import { expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { AgentId } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SubagentService from '@deepseek-ai/dsh-subagent' +import WorkerWorkflowEngine from '../src/index.ts' + +// A fresh thread compiles the source runtime. Leave contention headroom on +// shared CI runners without weakening any engine-level timeout assertion. +vi.setConfig({ testTimeout: 30_000 }) + +it('runs the default config through the source worker', async () => { + const ctx = new Context() + const subagents = await ctx.plugin(SubagentService) + const engine = await ctx.plugin(WorkerWorkflowEngine, {}) + const parent = { id: AgentId('workflow-compat-parent'), options: {} } as unknown as Agent + try { + const run = ctx.workflows.start({ + script: 'return 6 * 7', + meta: { name: 'source-worker-compat', description: 'exercise the unbuilt worker entry' }, + parent, + }) + try { + await expect(run.result).resolves.toMatchObject({ value: 42, stopReason: 'completed', agentsStarted: 0 }) + } finally { + await run.dispose() + } + } finally { + await engine.dispose() + await subagents.dispose() + } +}) diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index d5b16a8e2f..9ccbb17a4e 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -1226,15 +1226,11 @@ describe('dsh-workflow-workerthread', () => { await second.dispose() }) - it('unregisters ctx.workflows when the engine fiber is disposed (HMR safety), and default config runs (auto concurrency)', async () => { + it('unregisters ctx.workflows when the engine fiber is disposed (HMR safety)', async () => { const ctx = new Context() await ctx.plugin(SubagentService) const fiber = await ctx.plugin(WorkerWorkflowEngine, {}) expect(ctx.get('workflows')).toBeDefined() - // A zero-agent run through the DEFAULT config exercises the auto - // concurrency resolution (cores - 2, capped) in start(). - const result = await run(ctx, fakeParent(), scripted('return 6 * 7')) - expect(result.value).toBe(42) await fiber.dispose() expect(ctx.get('workflows')).toBeUndefined() }) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 2f98c74eea..b26e13a56c 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -143,6 +143,11 @@ function gatesForMode(selected: Mode): Gate[] { case 'node-compat': return [ pnpmScript('typecheck', 'typecheck'), + pnpmExec('source-worker-smoke', [ + 'vitest', + 'run', + 'packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts', + ], { label: 'source worker smoke' }), ] case 'pre-push': return [ From c5b1a7941fa5b78c2992d8c7aac02909f1176116 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:57:05 +0800 Subject: [PATCH 42/64] fix(scope): harden lifecycle ownership foundation Make Cordis construction and teardown ownership reentrancy-safe, then carry caller and provider ownership through reservation, setup, publication, quiescence, and sentinel retirement. Stabilize registry carriers and factory/workflow boundaries, add adversarial lifecycle regressions, and align the rewritten RFC plus generated contracts with the enforced behavior. --- docs/architecture.md | 2 +- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 44 +- docs/cordis-catalog/services.md | 14 +- docs/cordis-primer.md | 2 +- docs/core-data-structures/core.md | 13 + docs/core-data-structures/session.md | 14 + docs/event-producer-consumer.md | 34 +- ...-18-agent-lifecycle-and-ownership-seams.md | 2 +- .../2026-07-08-agent-scope-contexts.md | 152 ++-- .../cordis/tool-cordis/src/api-catalog.ts | 8 +- .../tests/cordis-lifecycle.spec.ts | 287 ++++++++ packages/core/agent-loop/README.md | 6 +- packages/core/agent-loop/src/agent.ts | 106 +-- packages/core/agent-loop/src/index.ts | 657 ++++++++++++++---- packages/core/agent-loop/tests/agent.spec.ts | 33 + packages/core/agent-loop/tests/resume.spec.ts | 108 +++ .../agent-loop/tests/scope-lifecycle.spec.ts | 608 +++++++++++++++- packages/core/agent/README.md | 14 +- packages/core/agent/src/dispatch.ts | 17 +- packages/core/agent/src/index.ts | 287 ++++++-- packages/core/agent/src/types.ts | 28 +- packages/core/agent/tests/agent.spec.ts | 357 +++++++++- packages/core/scope/README.md | 2 +- packages/core/scope/src/index.ts | 34 +- packages/core/scope/tests/scope.spec.ts | 56 +- packages/core/session/README.md | 6 +- packages/core/session/src/index.ts | 135 ++-- packages/core/session/tests/session.spec.ts | 116 ++++ .../subagent/subagent-inprocess/README.md | 2 +- .../workflow/workflow-workerthread/README.md | 2 +- .../workflow-workerthread/src/host.ts | 17 +- .../workflow-workerthread/src/index.ts | 11 +- .../tests/workflow-workerthread.spec.ts | 32 +- scripts/gen-cordis-catalog.ts | 3 + scripts/gen-doc-graphs.ts | 3 + scripts/type-equiv.manifest.json | 2 + vendor/README.md | 1 + vendor/cordis/src/fiber.ts | 189 ++++- 39 files changed, 2945 insertions(+), 461 deletions(-) create mode 100644 packages/cordis/tool-cordis/tests/cordis-lifecycle.spec.ts diff --git a/docs/architecture.md b/docs/architecture.md index 249765eb45..15a1142ac3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -105,7 +105,7 @@ 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 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0a1c0289ae..dc766ae25f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -144,7 +144,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:44`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:119`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-bash-local` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index cc450a782e..3e228405ee 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -15,7 +15,7 @@ Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `n ### `agent/created` — emit -An agent's fully composed scoped world was published in the AgentRegistry. Its session is already live in the session store, but concrete factories may keep driving verbs locked until the subsequent `agent/session-start` boundary; that event is the first supported place to inject or queue work during startup. A synchronous listener throw vetoes publication and rollback emits the matching disposal edges; returned-promise rejection is observed and logged but cannot retroactively veto this synchronous boundary. +An agent's fully composed scoped world was published in the AgentRegistry. Its session is already live in the session store, but concrete factories may keep driving verbs locked until the subsequent `agent/session-start` boundary; that event is the first supported place to inject or queue work during startup. A synchronous listener throw vetoes publication and rollback emits the matching disposal edges; returned-promise rejection is observed and logged but cannot retroactively veto this synchronous boundary. A synchronous listener that requests the advanced registry detach does not remove the entry immediately: removal and the paired `agent/disposed` edge wait until the creation dispatch unwinds, so no later creation listener observes a disposal that preceded its own creation callback. ```ts cordis-catalog 'agent/created'(this: Scoped, agent: Agent): void @@ -23,11 +23,11 @@ An agent's fully composed scoped world was published in the AgentRegistry. Its s Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:303`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:307`](../../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. Ordered teardown may still be detaching the session and unwinding the agent's scoped registrations when this notification runs. +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): void @@ -35,7 +35,7 @@ An agent was removed from the registry after its driver and any in-flight turn r Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:317`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:322`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -47,7 +47,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:590`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:596`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -61,7 +61,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:422`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:428`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -73,7 +73,7 @@ Waterfall: decide what happens to ONE drained queued message before it becomes a Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:440`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:446`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -85,7 +85,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:345`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:350`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -97,7 +97,7 @@ Waterfall: shape the step's call configuration — model switching, sampling ove Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:469`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:475`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -113,19 +113,19 @@ The seed is a frozen empty list; a contributing listener returns a NEW array — Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:521`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:527`](../../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, …). A pure NOTIFICATION (emit, not waterfall): it carries no veto — a session-start listener that wants to seed context does so via `agent.inject()` (a `context/message` the first request sees), not by returning a decision. Cannot block the session from starting; that gap is deliberate (a bridge logs/injects, it does not gate startup). +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, 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:365`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:371`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -137,7 +137,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:331`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:336`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -149,7 +149,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:536`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:542`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -161,7 +161,7 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:554`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:560`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -173,7 +173,7 @@ Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` wat Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:573`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:579`](../../packages/core/agent/src/types.ts) ## `approval/*` @@ -245,13 +245,13 @@ Source: [`packages/llm/llm/src/index.ts:39`](../../packages/llm/llm/src/index.ts ### `session/created` — emit -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. 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. +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): void ``` -Source: [`packages/core/session/src/index.ts:50`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:52`](../../packages/core/session/src/index.ts) ### `session/disposed` — emit @@ -261,7 +261,7 @@ A previously announced session left the store. Emitted exactly once on normal de 'session/disposed'(this: Scoped, session: Session): void ``` -Source: [`packages/core/session/src/index.ts:62`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:64`](../../packages/core/session/src/index.ts) ### `session/event` — emit @@ -273,7 +273,7 @@ An event was appended to a session log (sync, fire-and-forget). This is the per- Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:76`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:78`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -283,7 +283,7 @@ Awaited durability checkpoint. The agent loop awaits `ctx.sessions.flush(session 'session/flush'(this: Scoped, session: Session): Promise | void ``` -Source: [`packages/core/session/src/index.ts:94`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:96`](../../packages/core/session/src/index.ts) ## `skill/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index b7caa6f842..1122137e1a 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -17,11 +17,11 @@ The loop itself is deliberately thin — every behavior beyond "call the model, ```ts cordis-catalog create(id: AgentId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent -async createAgent(options: CreateAgentOptions): Promise -async resume(options: ResumeAgentOptions): Promise +async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise +async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:78`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:153`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` @@ -39,9 +39,9 @@ get(id: AgentId): Agent | undefined list(): Agent[] ``` -Types: [Agent](../core-data-structures/core.md) +Types: [Agent](../core-data-structures/core.md) · [AgentRegistrationReservation](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:202`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:250`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` @@ -222,7 +222,9 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:663`](../../packages/core/session/src/index.ts) +Types: [SessionRegistrationReservation](../core-data-structures/session.md) + +Source: [`packages/core/session/src/index.ts:667`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/cordis-primer.md b/docs/cordis-primer.md index b59363eea7..5b14da901c 100644 --- a/docs/cordis-primer.md +++ b/docs/cordis-primer.md @@ -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. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 76ec27e744..7937018b9f 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -346,6 +346,19 @@ interface Agent { `AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`) is merge-extensible — plugins add creation options by declaration merging; the persona is NOT an agent option but the `dsh-system-prompt` plugin's `persona` config, shared context-wide. The `agent/*` event taxonomy (lifecycle emits incl. `agent/session-start`, serial `agent/pre-step`/`agent/turn-stop` checkpoints, and the `agent/prompt-submit`/`agent/request`/`agent/session-prefix`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy); turn/step boundaries are durable `session/event` records, not `agent/*` emits. +### `AgentRegistrationReservation` — unpublished identity ownership + +An agent factory reserves its public `AgentId` before awaiting setup, so setup code cannot publish either the intended agent or a replacement under that id ahead of the transaction. The opaque capability authorizes exactly the later `enter()` call. Its `release` function is the exact Cordis owner effect disposer, letting the lifecycle adopt it by identity and place release after scope quiescence while owner disposal remains the abandoned-transaction backstop. Ordinary plugins use `register()` and never hold this type. + +Source: [`packages/core/agent/src/index.ts`](../../packages/core/agent/src/index.ts) + +```ts type-equiv +interface AgentRegistrationReservation { + readonly id: AgentId + release(): void +} +``` + ## Interception decisions Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. They share one envelope for model-facing context, `HookContext`, which is `inject()`ed as a `context/message` and so carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 1b12c3fc48..3ed02b41a9 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -196,6 +196,20 @@ export interface SurfaceNode { } ``` +## `SessionRegistrationReservation` — unpublished identity and construction ownership + +A session factory reserves its public `SessionId` before awaiting persistence load or scoped setup, so concurrent code cannot create, prepare, or enter a session under that id ahead of the transaction. The opaque capability may construct exactly one unpublished `Session` and authorizes exactly that object at `enter()`. Its `release` function is the exact Cordis owner effect disposer, letting the lifecycle adopt it by identity and place release after scope quiescence while owner disposal remains the abandoned-transaction backstop. Ordinary session consumers use `create()` and never hold this type. + +Source: [`packages/core/session/src/index.ts`](../../packages/core/session/src/index.ts) + +```ts type-equiv +interface SessionRegistrationReservation { + readonly id: SessionId + prepare(options?: CreateSessionOptions): Session + release(): void +} +``` + ## Derived history: `deriveMessages()` and `deriveEventMessage()` `Session.deriveMessages()` projects the event log into the `Message[]` the model sees — cached (each surface node projected once, when first seen; a surface rewrite rebuilds) and frozen (a fresh array per call over shared, deep-frozen messages, so mutating logged history through a projection is unrepresentable). `deriveEventMessage(event)` is the per-node pure function the fold applies — public so external reconstructors and the dev invariant project a log prefix with exactly the same rules and cannot disagree with the cache. The projection rules: diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 7b26f43b12..ec867aa709 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,28 +7,28 @@ 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:303`](../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:317`](../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:590`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:422`](../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:440`](../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:345`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:469`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:521`](../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:365`](../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:331`](../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:536`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:554`](../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:573`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`strictSerial (serial)`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:307`](../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:322`](../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:596`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:428`](../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:446`](../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:350`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:475`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:527`](../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:371`](../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:336`](../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:542`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:560`](../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:579`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`strictSerial (serial)`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:72`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `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:50`](../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:62`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../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:94`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `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:78`](../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:96`](../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:132`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | | `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:138`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:115`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | diff --git a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md index 8e1acd1638..b727e357ee 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md +++ b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md @@ -16,7 +16,7 @@ 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 }`. 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 }`. 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 detaching the session store's private append observer 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. diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md index 436a208be8..c29fb4f83b 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -27,18 +27,19 @@ The subagent API makes these requirements concrete. Two concurrent children can Each live agent owns a registration context named `agent.ctx`, and services expose narrow owner-final policy boundaries where ordinary middleware ordering is not strong enough. Together these choices make one agent's world composable with normal plugin APIs while keeping authority, observation, and cleanup aligned. -The design has four parts: +The design has five parts: | Part | Rule | Purpose | |---|---|---| | Registration scope | A registration through a plain plugin context is global; the same registration through `agent.ctx` belongs to that agent | Reuse existing APIs for per-agent tools, prompt state, and listeners | -| Lifecycle transaction | Create and resume await scoped setup while the agent and session are unpublished, then publish them in an ordered rollback-covered sequence | No observer sees a partially composed agent, and every failure path owns cleanup | +| Lifecycle transaction | Caller and factory ownership cover create and resume from reservation or load through scoped setup, ordered publication, and teardown | No observer sees a partially composed agent, and caller or provider loss cannot orphan work | +| Lifecycle foundation | Effects become owner-visible before setup, child fibers become parent-owned before publication, and unloading fibers reject late effects | Reentrant HMR cannot strand a half-built or cleanup-time registration outside the unload snapshot | | Owner-final policy | Prompt protection, tool guards, final tool-result observation, and terminal turn stopping run at service-owned boundaries | Invariants do not depend on listener registration order | | Boundary ownership | Services capture fixed fields once, materialize lossless-JSON data once, and publish owner-controlled views | Validation, execution, persistence, and telemetry cannot observe different values from one call | Three domain terms recur below. A **Session** is one agent run's append-only event log, from which model history and durable replay are derived. **Lossless JSON** means JSON primitives plus dense arrays and plain objects that can be copied without changing meaning; the boundary rejects sparse arrays, cycles, exotic prototypes, non-finite numbers, negative zero, `undefined`, `bigint`, functions, and symbols instead of coercing or erasing them. **Code Mode** presents the model with a generated software-development-kit interface and a reserved `run_code` transport, rather than advertising every end-capability as a native tool. -Ownership stays with the component that can enforce each fact. The scope package owns scope tags and carrier construction; each registry owns acceptance snapshots and resolution; the agent factory owns identity reservation, setup, and publication; the session owns accepted history; the tool and subagent services own their pipeline records; and the workflow host owns cancellation of the runs it started. A caller never validates a value that another component later rereads from the caller's mutable object. +Ownership stays with the component that can enforce each fact. The scope package owns scope tags and carrier construction; each registry owns acceptance snapshots and resolution; the caller owns the programmatic agent lifetime it requested; the concrete agent factory owns identity reservation, setup, publication, and structural invalidation of agents that still depend on it; the session owns accepted history; the tool and subagent services own their pipeline records; and each workflow run captures its holder-bound dependencies and owns its cancellation after the engine returns it. A caller never validates a value that another component later rereads from the caller's mutable object. The scope is flat. An agent resolves the deployment-global layer plus its own layer; a child does not inherit registrations from its parent's scope. Parent/child lineage remains explicit session data, and parent-owned disposal links lifetimes without silently inheriting authority. @@ -54,10 +55,14 @@ A Cordis `Context` is the object through which a plugin reaches services such as A context also carries a capability view. A derived context reaches the services injected into the plugin that created it. Handing out `agent.ctx` therefore hands out the agent loop's injected service surface; it is not an ambient root context. +Factory delegation uses two contexts whose jobs must remain separate. The registry derives a caller-bound context carrying the fiber and scope from which `ctx.agents.create()` or `resume()` was called and passes it explicitly as `ownerCtx`; those facts identify the fiber and optional parent agent that own the requested lifetime. When the registered factory is itself a Cordis service, the registry also invokes it through a traced receiver, which preserves the factory's own injected dependency origin. A plain object that merely implements the factory methods receives the same explicit `ownerCtx` without depending on Cordis tracing. Conflating these roles would either attach the agent to the factory registrant instead of the caller or make the concrete loop resolve dependencies from the wrong service view. + ### Effects give registrations an owner A Cordis effect is work whose cleanup belongs to a runtime unit called a fiber. Tool registration, prompt contribution, and event subscription are effects, so disposing their fiber unwinds them on normal teardown, failure, or hot reload. +Ownership must exist before effect setup can call arbitrary code. The vendored Fiber implementation therefore places an effect's cleanup wrapper in the owner list before running its setup body; a reentrant unload sees that in-construction effect and waits for setup plus every cleanup it collected. A child fiber likewise receives its parent-owned disposer before `internal/plugin` announces the child. Teardown delivers that notification with per-observer failure containment so one callback cannot starve peers or interrupt cleanup. Effects remain legal while a fiber is pending or loading, because setup needs them, but a fiber already unloading rejects new effects: its cleanup snapshot has been taken, so accepting another registration would strand it in the old epoch. + `dsh-scope` mounts a no-op plugin fiber for each scope. The plugin contributes no behavior; its fiber is the ownership bucket for everything registered through the scoped context. ### A waterfall is ordered around-middleware @@ -125,7 +130,7 @@ The property is deliberately not treated as the authoritative scope tag. A neste | `Scope.dispose()` | Give ordinary callers an idempotent promise shared by repeat and racing calls until quiescence | | `Scope.rawDispose` | Expose the exact Cordis disposer so a larger generator lifecycle can nest it at a precise teardown position | -The two disposal forms solve different framework constraints. Cordis identifies nested effects by disposer-function identity, so an ordered composite lifecycle must yield `rawDispose` exactly. Cordis disposers are also single-shot, so a second raw call may not await the first asynchronous teardown; `Scope.dispose()` follows the backing fiber's in-flight lifecycle and gives all ordinary callers the same quiescence boundary, including a race in which `rawDispose` started first. The test/tooling `ScopeHost.dispose()` extends that shared boundary across its host fiber and every minted child scope. +The two disposal forms solve different framework constraints. Cordis identifies nested effects by disposer-function identity, so an ordered composite lifecycle must yield `rawDispose` exactly. Cordis disposers are also single-shot, so a second raw call may not await the first asynchronous teardown; `Scope.dispose()` follows the backing fiber's in-flight lifecycle and gives all ordinary callers the same quiescence boundary, including a race in which `rawDispose` started first. The test/tooling `ScopeHost.dispose()` extends that shared boundary across its host fiber and every minted child scope. Pre-registration of an effect wrapper solves a different race: it makes the first owner unload see construction in progress without changing this single-shot raw-disposer contract. The primitive itself is small. Its essential implementation shape is: @@ -245,72 +250,92 @@ The real helpers fuse values that must agree. `agentEvents(context, agent)` uses Function-style listeners receive the carrier as `this`, and agent event APIs allow them to call subject methods. The carrier is therefore a JavaScript proxy that reads and writes through to the real subject and binds methods to it. -Binding matters for classes with JavaScript private fields: a method called with the proxy itself as receiver would fail the runtime private-field identity check. The carrier therefore uses a dedicated surrogate proxy target with its own immutable composed-filter slot, while ordinary property access, writes, own-key visibility, methods, invocation, and construction delegate to the real subject; callable carriers also preserve whether the subject is constructable. For non-overlay properties owned by the subject, descriptor queries preserve values and flags except that `configurable` is reported as `true`, which is the Proxy-safe way for an extensible surrogate to expose a property it does not itself own. A filter property pinned on the subject before, during, or after construction cannot trigger the proxy invariant that would otherwise force delivery to use the subject's raw filter and silently drop scope isolation. The carrier is intentionally not identity-equal to the subject; event arguments carry the real object whenever identity matters. +Binding matters for classes with JavaScript private fields: a method called with the proxy itself as receiver would fail the runtime private-field identity check. The carrier therefore uses a dedicated surrogate proxy target with its own immutable composed-filter slot, while ordinary property access, writes, own-key visibility, methods, invocation, and construction delegate to the real subject; callable carriers also preserve whether the subject is constructable. + +The composed filter is an authorization boundary, not an ordinary exposed callback. It invokes a subject's pre-existing filter with stable references to the built-in `Reflect.apply` and `Function.prototype.call` operations, pins its own `.call` to that captured built-in, and freezes the callable. Code holding the subject or carrier therefore cannot replace either `.call` property to turn a scoped predicate into an always-allow predicate. Keeping the filter on the surrogate also means a filter property pinned on the subject before, during, or after carrier construction cannot trigger a Proxy invariant that silently replaces scope isolation with the subject's raw filter. + +The surrogate must remain extensible so its reported own-key view can follow the subject. For non-overlay properties owned by the subject, descriptor queries preserve values and flags except that `configurable` is reported as `true`, which is the only Proxy-safe description of a property the extensible surrogate does not itself own. For the same reason, defining a property through the carrier is supported only when the descriptor explicitly says `configurable: true`; an omitted or false flag is rejected before the subject is touched. The carrier is intentionally not identity-equal to the subject; event arguments carry the real object whenever identity matters. `Scoped` is a TypeScript-only marker that requires this carrier at declared scoped dispatch sites. It improves authoring but adds no runtime security, so runtime marks and development invariants check the same contract for JavaScript, casts, and hand-written dispatches. ## Agent creation and teardown -An agent's scope, session, registry entry, and driver form one owned transaction. Setup finishes before publication, publication is synchronous and rollback-covered rather than magically atomic, and teardown reaches one ordered quiescent boundary. +An agent's scope, session, registry entry, and driver form one transaction with two ownership edges. The caller context owns the work it requested and receives the only consumer-facing teardown capability; the concrete `AgentLoop` provider is a structural co-owner because the live agent continues to use the provider's injected services. Either edge deactivates the transaction and converges on the same ordered, memoized quiescence boundary. Setup finishes before publication, and publication is synchronous and rollback-covered rather than magically atomic. ### Create and resume reserve identities before asynchronous work Programmatic create and resume reserve both the agent ID and session ID before work that can await. Create prepares a fresh or seeded session; resume first loads and reconstructs the persisted session. Both paths then construct the agent, mint `agent.ctx`, and install the complete teardown skeleton before awaiting setup. +The registry treats the factory seam as an untrusted runtime boundary. A TypeScript interface checks source code but does not constrain the JavaScript object received at runtime, which may expose stateful getters. `setFactory()` therefore claims the single factory slot before reading method accessors, canonicalizes an already traced Cordis service to its concrete target, then captures that target plus the `createAgent` and `resume` callback identities once. A getter cannot reenter `setFactory()` and replace the outer factory while it is being accepted, later method replacement cannot redirect calls, and a service proxy cannot accumulate a second trace layer that breaks raw-identity state. On each call, the registry passes a caller-bound context carrying the accessing fiber and scope as `ownerCtx`, retraces the concrete service target exactly once through that context, and invokes the captured callback with both pieces. The explicit argument binds ownership; the traced receiver preserves the factory's dependency origin. + The factory first captures the requested IDs, setup callback, and caller-owned agent options. Seed events and session metadata take a stricter route than a preliminary clone: cloning can erase an exotic prototype before validation sees it, so the factory reads each reference once and hands it synchronously to the session store's reservation-bound prepare operation. That boundary rejects exotic shells, reads accepted metadata fields once, and recursively materializes each seed record in one pass. Resume applies the same rule to persistence output by capturing the loaded header fields once before reconstruction. The transaction therefore cannot move to different identities, storage routing, or lineage after an asynchronous boundary. Before setup can observe the new objects, their ownership-bearing public properties become stable runtime data slots rather than TypeScript-only `readonly` promises. The concrete agent pins its ID, accepted options, and session; the factory binds its scope context exactly once. The session pins its ID and detached, deep-frozen header. Registry detach closures likewise close over their accepted map keys instead of rereading public properties during teardown. A JavaScript assignment or stateful accessor therefore cannot split registry lookup, dispatch, persistence, and the driver into different identities. The session owns the accepted log as described in [the session-immutability RFC](2026-06-11-dev-invariants-over-deep-readonly.md). Seed and append paths materialize lossless JSON once, validate both the event envelope and the metadata that places message-producing events into derived model history, and deep-freeze the exact accepted event. `session.events` returns a frozen snapshot that never grows later. The store keeps append notification and scope-carrier state in store-owned private tables instead of caller-writable `Session` fields, so outside JavaScript cannot suppress or redirect `session/event` dispatch. -Reservations prevent two concurrent factory transactions from composing different unpublished objects under the same public identities. Each reservation belongs both to the factory transaction and to the Cordis fiber that requested it: explicit release covers every success or failure path, while owner-fiber disposal is the backstop for an abandoned handle during plugin unload or HMR. The agent registry and session store recognize their own reserved keys: setup code that calls public reserve, prepare, create, register, or bare enter APIs with the same IDs fails. The session capability can prepare exactly one object, and publication succeeds only when both stores receive the factory-held exact capabilities; the session store additionally checks that the capability owns that exact prepared session. This closes the otherwise possible path in which setup publishes a substitute object under an ID that the factory merely tracked in a separate pending set, without letting a vanished owner wedge the ID forever. +Reservations prevent two concurrent factory transactions from composing different unpublished objects under the same public identities. Each capability's `release` is its exact Cordis effect disposer. Before asynchronous work, the owning sentinel adopts those functions by identity, removing them from the caller fiber's concurrent sibling list; teardown reaches them only after the transaction's driver, registry entries, session, and scope have quiesced. Explicit release covers pre-lifecycle failure and the ordered final step, while the owning fiber remains the backstop for an abandoned transaction. The concrete factory also tracks the whole create transaction before reservation and session preparation begin, and keeps that structural edge through reservation release. Provider unload first stops the factory from accepting work, then aborts or drains every tracked transaction before its dependency surface disappears. -Resume installs an owner-liveness sentinel before reserving IDs or starting persistence I/O, then races loading against owner disposal. If disposal wins, resume rejects and releases both reservations immediately; a backend promise that settles later cannot publish. After a successful load, the factory synchronously installs the full agent lifecycle before removing the sentinel, so ownership passes from load to setup without an unobserved disposal gap. +The agent registry and session store recognize their own reserved keys: setup code that calls public reserve, prepare, create, register, or bare enter APIs with the same IDs fails. The session capability can prepare exactly one object, and publication succeeds only when both stores receive the factory-held exact capabilities; the session store additionally checks that the capability owns that exact prepared session. This closes the otherwise possible path in which setup publishes a substitute object under an ID that the factory merely tracked in a separate pending set, without letting a vanished owner wedge the ID forever. -The sentinel exists only for the interval in which no agent lifecycle can exist yet: +Resume needs an ownership edge before an agent object exists. It reserves the identities, then installs a caller-liveness sentinel that adopts both exact reservation disposers before persistence I/O; a factory-tracked load transaction supplies the provider edge. If either owner wins, resume rejects, waits for the load transaction to settle, and only then releases both reservations; a backend promise that settles later cannot publish. After a successful load, `startOwned` synchronously returns both the complete lifecycle disposer and the asynchronous setup/publication result. Even a preparation failure is represented by a disposer-backed result, so the load sentinel can hand off to a real quiescence boundary instead of mistaking an async function's rejected promise for successful installation. The load tracker remains until the surrounding transaction settles, while the load and caller sentinels remain lifecycle-long followers, so no ownership or ID-release gap opens. Once the shared lifecycle quiesces, each sentinel first disarms its follower and then removes its owner-fiber effect; long-lived callers therefore do not retain completed agents, scopes, or reservation closures. + +The load sentinel changes what it follows at handoff but remains an owner-visible boundary: ```text -resume(request): +resume(ownerCtx, request): snapshot request ids, options, and setup callback - sentinel = owner.effect(onDispose => signal ownerDisposed) reservations = reserve agentId in AgentRegistry and sessionId in SessionStore + sentinel = ownerCtx.effect( + onDispose => abort and await load settlement before reservation release, + adopt exact reservation disposers) + loadTransaction = factory.track(onDispose => signal deactivated and await settlement) try: - persisted = await firstOf(persistence.load(sessionId), ownerDisposed) + persisted = await firstOf(persistence.load(sessionId), deactivated) session = reservations.session.prepare(reconstruct persisted data) - # This call installs the full lifecycle before its first await. - starting = startOwned(agentId, session, options, reservations, setup) - disarm and dispose sentinel - return await starting + # This synchronous call returns a lifecycle boundary even when preparation fails. + starting = startOwned(ownerCtx, agentId, session, options, reservations, setup) + sentinel.follow(starting.dispose) + return await starting.result finally: - release both reservation capabilities - settle the sentinel transaction + release directly only if no lifecycle boundary was established + settle and untrack the load transaction ``` -If `ownerDisposed` wins, the load promise may continue inside the backend, but it has no path back to publication. +If deactivation wins, the load promise may continue inside the backend, but it has no path back to publication. ### Setup composes an unpublished world The optional `setup(agentCtx)` callback receives the new agent context and may synchronously register contributions or await child-plugin activation. During setup, neither the session nor agent is visible through its global registry, but `agentCtx.agent` exposes the unpublished agent to the code composing it. -Setup may register scoped tools, prompt sections, variables, restrictions, listeners, protections, or child plugins. If it throws or rejects, the scope unwinds without publishing either object, and the reserved IDs become reusable. If the owner unloads during an await, the preinstalled teardown skeleton marks the transaction inactive; late setup completion cannot publish. +Setup may register scoped tools, prompt sections, variables, restrictions, listeners, protections, or child plugins. If it throws or rejects, the scope unwinds without publishing either object, and the reserved IDs become reusable. If either the caller owner or concrete factory unloads during an await, the preinstalled teardown skeleton marks the transaction inactive; late setup completion cannot publish. -After setup settles, the factory yields one microtask checkpoint and rechecks the lifecycle flag, owner-fiber state, and owning agent's disposed state. Cordis begins owner unload synchronously but may run nested effect disposers in the next microtask; the explicit owner checks and checkpoint let a same-turn unload win instead of allowing an immediately fulfilled setup to publish an already-doomed agent. +Both structural edges exist before driver preparation or scope minting. The provider uses a tracked placeholder, while the caller gets a lifecycle-long sentinel that adopts the reservation effects and resolves to the same memoized lifecycle disposer. If `internal/plugin` reentrantly unloads either owner while the scope fiber is being constructed, Cordis has already attached the child disposer to its parent and the sentinel waits until preparation publishes either the complete lifecycle or a rollback disposer. A failure halfway through preparation therefore leaves both owners with a quiescence boundary for the prepared driver, minted scope, and reservations. + +The factory checks liveness before invoking arbitrary setup. After setup settles, it yields one microtask checkpoint and checks the lifecycle flag, factory state, caller-fiber state, and the owner context's associated agent state again. Cordis begins owner unload synchronously but may run nested effect disposers in the next microtask; the explicit checks and checkpoint let a same-turn unload win instead of allowing an immediately fulfilled setup to publish an already-doomed agent. Setup composes but does not drive. The concrete agent rejects `send`, `steer`, `inject`, and `cancel` until publication reaches the session-start boundary, keeps its inbox in a JavaScript native-private field, and allows only one concrete driver to claim a session. Driver startup is absent from the package surface: the package exports neither its loop/inbox internals nor source subpaths, and only instance-bound controls held by the factory can enable and start the driver. JavaScript or a type cast therefore cannot bypass the lock by calling a public `start()` or writing directly into the queue. These boundaries prevent a turn from opening before lifecycle listeners know the session exists. The common create/resume tail makes the unpublished boundary explicit: ```text -startOwned(snapshot, preparedSession): - world = prepareLifecycle(snapshot, preparedSession) - # world now owns agent.ctx and the complete rollback/teardown skeleton - +startOwned(ownerCtx, snapshot, preparedSession): try: + world = prepareLifecycle(ownerCtx, snapshot, preparedSession) + # Factory placeholder, lifecycle-long caller sentinel, reservation adoption, + # and complete rollback/teardown skeleton all exist before the first await. + catch preparationError with rollbackBoundary: + return { dispose: rollbackBoundary, + result: await rollbackBoundary then reject original error } + + result = async: + require world.lifecycleActive await firstOf(snapshot.setup(world.agent.ctx), world.deactivated) await oneMicrotask() require world.lifecycleActive + require world.factoryActive require world.ownerFiberActive require world.ownerAgentNotDisposed @@ -319,60 +344,82 @@ startOwned(snapshot, preparedSession): catch error: await world.dispose() throw error + + return { dispose: world.dispose, result } ``` `setup` can await arbitrary plugin activation, but every exit still passes through the already-installed disposer. ### Publication is ordered and rollback-covered -After setup succeeds, the factory publishes in one synchronous sequence with no `await` between steps: +After setup succeeds, the factory publishes in one synchronous sequence with no `await` between steps. Each registry has already claimed its ID across every caller-code boundary needed to construct a stable entry: the agent registry pins the accepted ID and captures one lifecycle carrier while its claim is held, and the session store holds the same kind of claim while evaluating its filter and carrier. A Proxy trap or filter getter can therefore neither overwrite a reentrant same-ID entry nor create a stale detach capability that later deletes another object. Liveness checkpoints then divide publication into three notification phases, and an outer publication barrier keeps teardown from revoking either registry entry or the scope while one of those phases is on the stack: 1. Enter the session store and capture its scope carrier. 2. Enter the agent registry without announcing it. -3. Emit `session/created`. -4. Emit `agent/created`. -5. Enable driving. -6. Emit `agent/session-start`. -7. Start the driver loop. +3. Recheck caller and factory liveness; entering either registry may have evaluated a caller-owned getter that began teardown. +4. Emit `session/created`. +5. Recheck liveness; if teardown began, skip the agent announcement and roll back. +6. Emit `agent/created`. +7. Recheck liveness; if teardown began, keep driving locked and roll back. +8. Enable driving. +9. Emit `agent/session-start`. +10. Recheck liveness; if teardown began, roll back without starting the driver. +11. Start the driver loop. The implementation keeps publication synchronous and leaves rollback to the surrounding owned transaction: ```text publish(world): - world.detachSession = world.agent.ctx.sessions.enter(world.session, world.sessionReservation) - world.detachAgent = app.agents.enter(world.agent, world.agentReservation) - app.sessions.announce(world.session) - app.agents.announce(world.agent) - world.driver.enableDrivingVerbs() - emitNonVetoing(agent/session-start) - world.stopDriver = world.driver.start() + world.beginSynchronousPublication() + try: + world.detachSession = world.agent.ctx.sessions.enter(world.session, world.sessionReservation) + world.detachAgent = app.agents.enter(world.agent, world.agentReservation) + require world.callerAndFactoryActive + app.sessions.announce(world.session) + require world.callerAndFactoryActive + app.agents.announce(world.agent) + require world.callerAndFactoryActive + world.driver.enableDrivingVerbs() + emitNonVetoing(agent/session-start) + require world.callerAndFactoryActive + world.driver.start() + finally: + world.endSynchronousPublication() ``` -Both registry entries exist before the first creation listener runs, and setup-installed listeners receive both announcements. Driving opens immediately before `agent/session-start`, so that event remains the first supported place for a listener to inject or queue startup work. +Both registry entries exist before the first creation listener runs, and setup-installed listeners receive every announcement that publication reaches. Driving opens immediately before `agent/session-start`, so that event remains the first supported place for a listener to inject or queue startup work. A synchronous teardown request from any notification marks the lifecycle inactive immediately, which makes the next checkpoint abort, but actual loop, registry, session, and scope cleanup waits until the current synchronous notification phase and publication call stack unwind. Teardown itself therefore cannot make a later listener that still runs observe a different world; teardown from `session/created` prevents `agent/created`, teardown from `agent/created` prevents session start, and teardown from `agent/session-start` prevents the driver from starting. The sequence is not described as atomic because observers run between its steps. If a `session/created` or `agent/created` listener throws synchronously, the transaction rolls the registry entries and scope back, but effects already performed by an earlier listener cannot be retracted. Each store therefore marks its announcement as begun before invoking creation listeners and rejects a repeat or reentrant announcement before dispatch. Rollback emits `session/disposed` or `agent/disposed` exactly once for every corresponding creation announcement that began, including a partial emit in which an early listener observed creation before a later listener threw. An object entered but never announced has no disposal notification because no observer was told it existed. +Each registry also protects ordering inside its own creation phase. If a listener uses an advanced detach capability while `session/created` or `agent/created` is dispatching, removal and the paired disposal edge are deferred until that dispatch unwinds. The agent's creation and disposal edges reuse the carrier captured before commit instead of rebuilding it from a mutable filter getter. A detach request therefore cannot make a later listener observe `created` after `disposed`, find the just-created entry missing, or trigger disposal while creation is still constructing its receiver. Exact-object guards on both detach paths are the final defense against a stale capability deleting a later same-ID entry. The factory's outer publication barrier is the cross-registry complement: caller or provider teardown cannot remove the other entry or unwind `agent.ctx` while the current phase is still running. + Creation notification preserves that synchronous veto while also defending against JavaScript's asynchronous callback shape. A listener may return a promise even though the event type returns `void`; the dispatcher does not await it because publication has no asynchronous gap, but it observes and logs a later rejection. Such a rejection is too late to roll back, does not become unhandled, and does not starve the listeners invoked after that callback. -The disposal notifications and `agent/session-start` are deliberately non-vetoing. Their dispatchers invoke every listener synchronously and independently; they log and contain both a synchronous throw and a rejection from a returned promise. Returned promises are observed for failure but not awaited, so an asynchronous notification listener cannot delay rollback or teardown, veto driver startup, or starve a later listener. +The disposal notifications and `agent/session-start` do not treat return values or listener failures as vetoes. Their dispatchers invoke every listener synchronously and independently; they log and contain both a synchronous throw and a rejection from a returned promise. Completion or rejection of a returned promise is observed but not awaited, so it cannot delay rollback or teardown, veto driver startup, or starve a later listener. The callback's synchronous prefix remains ordinary code: if it holds and disposes a structural ownership edge, the next publication liveness check deliberately aborts startup. ### Teardown stops work before revoking its world -Every owner path uses the same reverse order: stop the loop and await its actual exit plus every agent-started durability checkpoint, remove the agent from the registry, detach the session, then unwind the scope. Final turn events, the turn-ending flush, and any outstanding idle-injection flush therefore settle while the session and scoped listeners are still live. +Every owner path reaches the same memoized reverse order: the consumer handle, caller-fiber disposal, and structural factory-provider unload first deactivate the lifecycle; wait for an in-progress synchronous publication phase; stop the loop and await its actual exit plus every agent-started durability checkpoint; remove the agent from the registry; detach the session; unwind the scope; and only then release both IDs. Final turn events, the turn-ending flush, and any outstanding idle-injection flush therefore settle while the session and scoped listeners are still live, and a replacement cannot reuse either identity while old scoped cleanup remains in flight. ```text disposeOwnedAgent(world): + mark world inactive + await world.synchronousPublicationIfRunning() await world.stopDriver() # waits for loop exit and all agent-started flushes world.detachAgent() # leaves registry; emits agent/disposed if announced world.detachSession() # stops event feed, leaves store; emits session/disposed if announced await world.scope.dispose() + world.releaseSessionReservation() + world.releaseAgentReservation() ``` The actual Cordis generator yields these disposers in reverse so its last-in-first-out teardown executes in the order shown. -`agent/disposed` means the driver is quiescent and the agent has left the registry; the session is still live during that notification. `session/disposed` follows after append notification has been detached and the session has left its store. The scope is still live when each disposal listener is selected and invoked, although returned asynchronous work is observed rather than awaited. Both notifications use the same scope key and delivery rule as their creation partners and occur exactly once only when those creation announcements began. +For the concrete AgentLoop transaction, `agent/disposed` runs after the driver is quiescent and the agent has left the registry; the session is still live during that notification. The public AgentRegistry alone promises only exact removal, because a custom registered `Agent` owns any stronger driver contract itself. `session/disposed` follows after append notification has been detached and the session has left its store. The scope is still live when each disposal listener is selected and invoked, although returned asynchronous work is observed rather than awaited. Both notifications use the stable scope key and delivery rule captured for their creation partners and occur exactly once only when those creation announcements began. -`AgentHandle.dispose()` is memoized so concurrent owners await the same full transaction, and `Scope.dispose()` provides the corresponding shared boundary for direct scope disposal and raw-disposer races. +`AgentHandle.dispose()` is memoized so repeated consumer calls await the same full transaction. The lifecycle-long caller sentinel independently follows that memoized promise, so handle-first teardown cannot make a racing caller-fiber unload observe Cordis's inert second raw-disposer call and return early. Once the transaction reaches its final quiescent stage, retirement disarms and removes the sentinel before settling that shared promise. `Scope.dispose()` provides the corresponding shared boundary for direct scope disposal and raw-disposer races. The provider's ownership ledger is internal rather than another public handle: it stops accepting new transactions, invokes every tracked disposer independently, and waits for all of them before the AgentLoop service surface disappears. + +Provider co-ownership is specific to resources that remain structurally dependent on their provider. An AgentLoop-created agent continues to resolve the loop's injected services, so loop unload must stop it. A worker workflow run instead captures its holder-bound `SubagentService` handle synchronously at `start()` and stores that independent dependency on the run; unloading `WorkerWorkflowEngine` removes the ability to start new runs but does not revoke an already returned run or prevent its later worker message from starting a child. The two lifetimes differ by dependency shape, not by a blanket rule that every service must own every value it creates. Parent-owned subagents use explicit ownership rather than capability inheritance. The driver creates one run-owner fiber under `parent.ctx` and invokes the child factory through that fiber, so lifecycle ownership exists before setup or publication begins; disposing a parent reaches its descendants even if a delegating tool never reaches its own `finally`. The child still receives a newly minted scope and resolves only global plus child-scoped capabilities. @@ -564,7 +611,7 @@ Provider registration first freezes an acceptance snapshot of the provider name, Starting a run reads every top-level request field once before capability validation, then snapshots every accepted field before asynchronous owner setup. This order makes checked and delegated capabilities identical even for a JavaScript caller with stateful accessors. Fixed scalars are checked at the same boundary: `maxDepth` must be a non-negative safe integer and `persona` must be a string. The parent and abort signal are retained as identity capabilities but never reread from the mutable request record; tool filters, seed events, agent options, output schema, and prompt are detached through the one-pass lossless-JSON materializer. The exported in-process driver repeats this boundary for direct callers before it awaits run-owner activation, including taking one seed snapshot from which it derives both the child prefix and `seedLength`. Later caller mutation therefore cannot change lifecycle scope, configuration, the schema enforced by the capture tool, or the prompt eventually logged and sent. -The driver first installs provider ownership. Only after that succeeds does it attach the request's abort listener and create one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves neither a child nor an orphaned listener. The child factory runs through the owner fiber. Parent teardown, provider teardown, and manual run disposal all dispose this same node; moving it out of the active state synchronously prevents an unpublished setup from publishing afterward, while all three paths follow one quiescence promise. This structured ownership does not change the child's flat capability view. +The driver first installs provider ownership. Only after that succeeds does it attach the request's abort listener and create one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves neither a child nor an orphaned listener. Calling `runOwner.ctx.agents.create()` gives the child factory an explicit `ownerCtx` carrying the run-owner fiber and scope, while the registry's traced factory receiver preserves AgentLoop's injected dependency origin. Parent teardown, provider teardown, and manual run disposal all dispose this same run-owner node; moving it out of the active state synchronously prevents an unpublished setup from publishing afterward, while all three paths follow one quiescence promise. This structured ownership does not change the child's flat capability view. The provider's run separates acceptance from publication with `started: Promise`, but the service does not expose that caller-owned handle directly. It captures `id`, `started`, `result`, and each method once, binds methods to the provider-owned run handle, and returns a frozen service-owned wrapper. Capturing `dispose` first also preserves a rollback capability if a later accessor or method check reveals a malformed handle. @@ -624,7 +671,9 @@ Before publishing the workflow's own result: Every downstream protocol that announces a subagent must honor the same boundary. The workflow worker bridge therefore registers the returned run before waiting, observes and snapshots `result` immediately, sends `ChildStarted` only after `started` fulfills, and sends `ChildStartError` plus host-driven disposal when readiness rejects. -Cancellation before readiness is a publication decision, not merely a flag for later result mapping. The in-process run synchronously deactivates its owner fiber, so the agent factory's liveness check fails, `started` rejects, and neither the child session nor agent can publish. The run's result still settles as `aborted`. Before the workflow's own result becomes observable, its host likewise drives both permitted cancellation channels: it aborts the shared request signal and calls each registered run's `cancel()`, including runs still waiting on readiness. Provider cancel callbacks are contained independently so one broken implementation cannot prevent peers from receiving cancellation or wedge the workflow result. +Cancellation before readiness is a publication decision, not merely a flag for later result mapping. The in-process run synchronously deactivates its owner fiber. If cancellation lands before publication, the factory's liveness check prevents either creation edge. If it begins synchronously inside `session/created`, `agent/created`, or `agent/session-start`, the publication barrier lets the current notification phase unwind without revoking its world, the next liveness check prevents every later phase and driver start, and rollback pairs every creation edge that already began. In either case `started` rejects, no `subagent/start` or `subagent/end` is emitted, and the run result settles as `aborted`. + +Before the workflow's own result becomes observable, its host likewise drives both permitted cancellation channels: it aborts the shared request signal and calls each registered run's `cancel()`, including runs still waiting on readiness. Provider cancel callbacks are contained independently so one broken implementation cannot prevent peers from receiving cancellation or wedge the workflow result. Together these rules prevent an early result rejection from going unhandled, ensure `workflow/agent-start` never names an unpublished child, and prevent a child from publishing after its workflow has ended. @@ -758,9 +807,9 @@ The owner-final APIs express the actual strength required by each rule: restore Listener filtering prevents a hook from intercepting the wrong agent but does not scope tool schemas, executable lookup, prompt sections, variables, or Code Mode bindings. Persona, tool filtering, and concurrent structured schemas would still require global mutation. -### Add scope semantics to vendored Cordis +### Put agent-scope policy inside vendored Cordis -Cordis already provides derived contexts, effect-owning fibers, and receiver-based listener filtering. The harness-level primitive combines those mechanisms without adding a framework fork whose synchronization cost would outlive this feature. +Cordis already provides derived contexts, effect-owning fibers, and receiver-based listener filtering, so the harness-level primitive composes those mechanisms instead of teaching the framework about agents, tools, prompts, or global-plus-scope resolution. The implementation does harden Cordis's domain-neutral lifecycle substrate: effects are owner-visible before setup callbacks, child fibers are parent-owned before publication, and an unloading fiber rejects registrations that missed its cleanup snapshot. Those rules are required by every plugin under reentrant HMR, not scope-specific policy pushed into the framework. ## Consequences @@ -772,8 +821,8 @@ The main benefit is one composition model across data, behavior, and lifetime: r - Plugin authors use the same registration APIs globally and per agent; only the context changes. - Registry-owned prompt schemas, executable lookup, Code Mode bindings, policy listeners, and UI presentation resolve from the same agent view. -- Create and resume expose no partially configured registry entry during awaited setup. -- Agent disposal revokes scoped contributions after the driver and all final or idle-injection session flushes have settled. +- Create and resume expose no partially configured registry entry during awaited setup, and overlapping caller/factory ownership leaves no gap between resume load, preparation failure, and the live lifecycle. +- Agent disposal revokes scoped contributions after the driver and all final or idle-injection session flushes have settled, and retains both public IDs until scope cleanup is quiescent. - Structured output composes per child without global mutation or listener-order assumptions. - Existing unscoped plugins remain deployment-wide contributors and observers. @@ -784,13 +833,14 @@ The costs are concentrated in dispatch discipline, per-scope registry state, and - Every scoped event dispatcher must carry the correct receiver; fused helpers, type markers, invariants, and gates exist because omission would otherwise deliver only to global listeners. - `agent.ctx` is capability-bearing. Its available services come from the agent loop's injected context, so holders receive that deliberate service surface. - Registries maintain per-scope maps and perform a global-plus-one-layer merge for the agent lifetime. -- The dispatch carrier is proxy-shaped and not identity-equal to its subject, even though method calls and property access behave like the subject. +- The dispatch carrier is proxy-shaped and not identity-equal to its subject, even though method calls and property access behave like the subject. Its composed filter is frozen, and defining a property through the carrier requires an explicitly configurable descriptor because the extensible surrogate cannot truthfully expose a new non-configurable subject property. - Flat scopes do not inherit parent capabilities; a desired child capability must be global or explicitly registered for the child. - `run_code` is protected transport infrastructure rather than a filterable end capability, so a policy that must forbid programs denies execution at the tool-policy layer instead of removing the transport from a Code Mode prompt. - Prompt protection restores named canonical contributions and their anchor placement, not the entire assembly; unprotected output remains extensible, while a globally protected section name is deliberately unavailable for scoped shadowing. - Terminal turn stopping has authority to discard pending steering. That power is appropriate for owner-enforced terminal protocols and too strong for ordinary cooperative continuation policy. - Programmatic `ctx.agents.create()` and `ctx.agents.resume()` are asynchronous because they await setup. The direct no-setup `ctx.agentLoop.create()` path, used by configuration and programmatic callers that already have complete options, remains synchronous. -- Ordered composition requires both an exact raw scope disposer and a shared public quiescence promise; the dual surface reflects two distinct Cordis lifecycle requirements. +- A programmatic agent is caller-owned but also structurally owned by its concrete AgentLoop provider. Reloading that provider tears the agent down even if a consumer still holds its handle, because the handle cannot keep the provider's dependency surface valid. +- Ordered composition requires exact raw effect identities plus shared public quiescence promises; the dual surfaces and lifecycle-long owner sentinels reflect distinct Cordis nesting and repeated-caller requirements. ### Deliberate boundaries diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index af8e9905e4..942ec4efb4 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -57,8 +57,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'The agent-loop plugin (`ctx.agentLoop`): creates ReactLoopAgents, runs their loops, and registers them in `ctx.agents`.', methods: [ 'create(id: AgentId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent', - 'async createAgent(options: CreateAgentOptions): Promise', - 'async resume(options: ResumeAgentOptions): Promise', + 'async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise', + 'async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise', ], }, { @@ -251,7 +251,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/disposed', mode: 'emit', signature: '\'agent/disposed\'(this: Scoped, agent: Agent): void', - summary: 'An agent was removed from the registry after its driver and any in-flight turn reached quiescence.', + summary: 'An agent was removed from the registry.', }, { name: 'agent/error', @@ -497,7 +497,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AgentFactory', - declaration: 'export interface AgentFactory {\n createAgent(options: CreateAgentOptions): Promise;\n resume(options: ResumeAgentOptions): Promise;\n}', + declaration: 'export interface AgentFactory {\n createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise;\n resume(ownerCtx: Context, options: ResumeAgentOptions): Promise;\n}', }, { name: 'AgentHandle', diff --git a/packages/cordis/tool-cordis/tests/cordis-lifecycle.spec.ts b/packages/cordis/tool-cordis/tests/cordis-lifecycle.spec.ts new file mode 100644 index 0000000000..b290ae998e --- /dev/null +++ b/packages/cordis/tool-cordis/tests/cordis-lifecycle.spec.ts @@ -0,0 +1,287 @@ +import { Context, CordisError, FiberState, type Fiber } from 'cordis' +import { describe, expect, it } from 'vitest' + +/** + * Direct regressions for the vendored Cordis ownership substrate used by + * tool-cordis's dynamic plugin tree and every other harness plugin. + */ + +describe('Cordis effect ownership', () => { + it('makes an effect visible to a reentrant owner restart and awaits setup plus cleanup', async () => { + const ctx = new Context() + const setupGate = Promise.withResolvers() + const cleanupGate = Promise.withResolvers() + const cleanupStarted = Promise.withResolvers() + let restarted!: Promise + let setupFinished = false + let cleanupFinished = false + + ctx.effect(async () => { + restarted = ctx.fiber.restart() + await setupGate.promise + setupFinished = true + return async () => { + cleanupStarted.resolve(undefined) + await cleanupGate.promise + cleanupFinished = true + } + }, 'reentrant-restart') + + let settled = false + void restarted.then(() => { settled = true }) + await Promise.resolve() + expect(settled).toBe(false) + + setupGate.resolve(undefined) + await cleanupStarted.promise + expect(setupFinished).toBe(true) + await Promise.resolve() + expect(settled).toBe(false) + + cleanupGate.resolve(undefined) + await restarted + expect(cleanupFinished).toBe(true) + expect(ctx.fiber.getEffects()).toEqual([]) + }) + + it('rolls back collected cleanup and its owner-list entry when setup throws synchronously', () => { + const ctx = new Context() + let cleanups = 0 + + expect(() => ctx.effect(function* () { + yield () => { cleanups += 1 } + throw new Error('setup failed') + }, 'throwing-setup')).toThrow('setup failed') + + expect(cleanups).toBe(1) + expect(ctx.fiber.getEffects()).toEqual([]) + }) + + it('makes a reentrant owner restart await asynchronous rollback after synchronous setup failure', async () => { + const ctx = new Context() + const cleanupGate = Promise.withResolvers() + const cleanupStarted = Promise.withResolvers() + let restarted!: Promise + + expect(() => ctx.effect(function* () { + yield async () => { + cleanupStarted.resolve(undefined) + await cleanupGate.promise + } + restarted = ctx.fiber.restart() + throw new Error('setup failed after restart') + }, 'reentrant-throw')).toThrow('setup failed after restart') + + await cleanupStarted.promise + let settled = false + void restarted.then(() => { settled = true }) + await Promise.resolve() + expect(settled).toBe(false) + + cleanupGate.resolve(undefined) + await restarted + expect(ctx.fiber.getEffects()).toEqual([]) + }) + + it('keeps ordinary teardown synchronous and the public disposer single-shot', () => { + const ctx = new Context() + let cleanups = 0 + const dispose = ctx.effect(() => () => { cleanups += 1 }, 'sync-effect') + + expect(dispose()).toBeUndefined() + expect(cleanups).toBe(1) + expect(dispose()).toBeUndefined() + expect(cleanups).toBe(1) + expect(ctx.fiber.getEffects()).toEqual([]) + }) + + it('rejects cleanup-time registration while a restart is unloading', async () => { + const ctx = new Context() + let registrationError: unknown + + ctx.effect(() => () => { + try { + ctx.effect(() => () => {}, 'too-late') + } catch (error) { + registrationError = error + } + }, 'restart-cleanup') + + await ctx.fiber.restart() + expect(registrationError).toBeInstanceOf(CordisError) + expect((registrationError as CordisError).code).toBe('INACTIVE_EFFECT') + expect(ctx.fiber.state).toBe(FiberState.ACTIVE) + expect(ctx.fiber.getEffects()).toEqual([]) + }) + + it('keeps effect registration legal while child fibers are PENDING and LOADING', async () => { + const ctx = new Context() + let pendingCleanup = false + let loadingCleanup = false + + ctx.on('internal/plugin', (fiber) => { + if (fiber.name !== 'state-probe' || fiber.uid === null) return + expect(fiber.state).toBe(FiberState.PENDING) + fiber.ctx.effect(() => () => { pendingCleanup = true }, 'pending-effect') + }) + + const fiber = await ctx.plugin({ + name: 'state-probe', + apply(inner) { + expect(inner.fiber.state).toBe(FiberState.LOADING) + inner.effect(() => () => { loadingCleanup = true }, 'loading-effect') + }, + }) + await fiber.dispose() + + expect(pendingCleanup).toBe(true) + expect(loadingCleanup).toBe(true) + }) + + it('resolves dependencies that internal/plugin adds before child activation', async () => { + const ctx = new Context() + ctx.provide('late-inject', {}) + let applyCalls = 0 + + ctx.on('internal/plugin', (fiber) => { + if (fiber.name !== 'loader-shaped' || fiber.uid === null) return + fiber.inject['late-inject'] = {} + }) + + const fiber = await ctx.plugin({ + name: 'loader-shaped', + apply() { + applyCalls += 1 + }, + }) + + expect(applyCalls).toBe(1) + expect(fiber.state).toBe(FiberState.ACTIVE) + }) +}) + +describe('Cordis child publication ownership', () => { + it('rolls back parent and runtime ownership when internal/plugin publication throws', () => { + const ctx = new Context() + const plugin = { name: 'publication-failure', apply() {} } + ctx.on('internal/plugin', (fiber) => { + if (fiber.name === plugin.name) throw new Error('publication failed') + }) + + expect(() => ctx.plugin(plugin)).toThrow('publication failed') + expect(ctx.registry.has(plugin)).toBe(false) + }) + + it('contains teardown notification failures so ownership cleanup and peers complete', async () => { + const ctx = new Context() + const errors: unknown[] = [] + ctx.logger.error = ((error: unknown) => { errors.push(error) }) as typeof ctx.logger.error + const observed: string[] = [] + ctx.on('internal/plugin', (fiber) => { + if (fiber.name === 'contained-teardown' && fiber.uid === null) { + throw new Error('broken teardown observer') + } + }) + ctx.on('internal/plugin', (fiber) => { + if (fiber.name === 'contained-teardown' && fiber.uid === null) observed.push('disposed') + }) + const child = await ctx.plugin({ name: 'contained-teardown', apply() {} }) + + await expect(child.dispose()).resolves.toBeUndefined() + expect(observed).toEqual(['disposed']) + expect(errors).toHaveLength(1) + expect(errors[0]).toEqual(expect.objectContaining({ message: 'broken teardown observer' })) + expect(child.uid).toBeNull() + }) + + it('makes a LOADING parent join child cleanup started before its unload snapshot', async () => { + const ctx = new Context() + const cleanupGate = Promise.withResolvers() + const cleanupStarted = Promise.withResolvers() + let ownerFiber!: Fiber + let ownerDisposal!: Promise + let childDisposal!: Promise + let childFiber!: Fiber + + ctx.on('internal/plugin', (fiber) => { + if (fiber.name !== 'loading-child' || fiber.uid === null) return + childFiber = fiber + fiber.ctx.effect(() => async () => { + cleanupStarted.resolve(undefined) + await cleanupGate.promise + }, 'loading-child-cleanup') + ownerDisposal = ownerFiber.dispose() + childDisposal = Promise.resolve(fiber.dispose()) + }) + + const ownerMount = ctx.plugin({ + name: 'loading-owner', + apply(inner) { + ownerFiber = inner.fiber + inner.plugin({ name: 'loading-child', apply() {} }) + }, + }) + + await cleanupStarted.promise + let ownerSettled = false + void ownerDisposal.then(() => { ownerSettled = true }) + await Promise.resolve() + expect(ownerSettled).toBe(false) + + cleanupGate.resolve(undefined) + await Promise.all([ownerDisposal, childDisposal, ownerMount]) + expect(childFiber.uid).toBeNull() + expect(ownerFiber.uid).toBeNull() + }) + + it('lets parent disposal during internal/plugin await the unpublished child to quiescence', async () => { + const ctx = new Context() + let ownerCtx!: Context + const owner = await ctx.plugin({ + name: 'owner', + apply(inner) { + ownerCtx = inner + }, + }) + + const cleanupGate = Promise.withResolvers() + const cleanupStarted = Promise.withResolvers() + let cleanupFinished = false + let childApplyCalls = 0 + let parentDisposal!: Promise + + ctx.on('internal/plugin', (fiber) => { + if (fiber.name !== 'child' || fiber.uid === null) return + expect(fiber.state).toBe(FiberState.PENDING) + fiber.ctx.effect(() => async () => { + cleanupStarted.resolve(undefined) + await cleanupGate.promise + cleanupFinished = true + }, 'pending-child-cleanup') + }) + ctx.on('internal/plugin', (fiber) => { + if (fiber.name !== 'child' || fiber.uid === null) return + parentDisposal = owner.dispose() + }) + + const child = ownerCtx.plugin({ + name: 'child', + apply() { + childApplyCalls += 1 + }, + }) + + await cleanupStarted.promise + let settled = false + void parentDisposal.then(() => { settled = true }) + await Promise.resolve() + expect(settled).toBe(false) + + cleanupGate.resolve(undefined) + await parentDisposal + expect(cleanupFinished).toBe(true) + expect(childApplyCalls).toBe(0) + expect(child.uid).toBeNull() + expect(child.state).toBe(FiberState.DISPOSED) + }) +}) diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index a8da612305..daf7e10d76 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -8,7 +8,9 @@ This is the only package in the harness that contains concrete loop logic. Every ### Public API -Lifecycle (scoped): programmatic creation and resume snapshot caller-owned identity/configuration data, obtain registry/store-owned capabilities for both unpublished IDs, mint `agent.ctx`, and install the ordered teardown skeleton before awaiting optional `setup`. The capabilities reject competing `register`/`enter`/`prepare`/`create` calls, so setup cannot publish the factory objects or same-id replacements. A create hands one-read raw seed and metadata references synchronously to the session boundary, which rejects exotic shells and materializes accepted values in a single recursive pass; pre-cloning either value could incorrectly sanitize prototypes. Resume installs an owner-liveness sentinel before persistence load, captures each loaded metadata field once, then hands ownership directly to the full lifecycle. After setup resolves, the factory checks its lifecycle flag, owner-fiber state, and owning agent status around one microtask checkpoint so a same-turn Cordis unload wins before publication. Successful setup inserts both session and agent before announcing either, enables driving immediately before `agent/session-start`, then starts the loop. The concrete agent owns runtime-pinned `id`, frozen detached `options`, `session`, and `ctx` bindings. Load/setup rejection or owner unload publishes nothing; partial creation announcements are paired during rollback. Teardown runs stop/drain (including outstanding idle-injection flushes) → unregister → detach session → unwind scope. All non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, which contains sync/async listener failures per observer; per-step assembly goes through `assembleContextFor(agent)`; the turn-end durability checkpoint goes through `ctx.sessions.flush(session)`. +Lifecycle (scoped and dual-owned): programmatic creation and resume snapshot caller-owned identity/configuration data, obtain registry/store-owned capabilities for both unpublished IDs, mint `agent.ctx`, and install the ordered teardown skeleton before awaiting optional `setup`. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly; the trace-bound `AgentLoop` receiver still supplies the dependency origin, so a caller that injects only `agents` can create an agent whose scope reaches the loop's `sessions`/`llm`/`tools`/`systemPrompt` surface. The caller owns cancellation and the returned handle, while AgentLoop remains a structural second owner because the live driver depends on that service surface: unloading the provider aborts pending load/setup, tears down live programmatic agents, and awaits the same quiescence and ID-release boundary before its dependencies disappear. + +The complete create transaction is factory-tracked before ID reservation/session validation, and both a factory placeholder and lifecycle-long caller sentinel exist before scope minting can reenter plugin lifecycle notifications. The caller sentinel adopts the exact reservation effects and always follows the memoized lifecycle boundary, including handle-first teardown followed by caller unload. Resume adds a load sentinel before persistence I/O; it waits for load settlement until `startOwned` synchronously returns a lifecycle/rollback disposer, then follows that disposer without a handoff gap. The ID capabilities reject competing `register`/`enter`/`prepare`/`create` calls and remain held through scope quiescence. A create hands one-read raw seed and metadata references synchronously to the session boundary, which rejects exotic shells and materializes accepted values in a single recursive pass; pre-cloning either value could incorrectly sanitize prototypes. Resume captures each loaded metadata field once. After setup resolves, the factory checks caller and provider liveness after constructing both registry entries but before the first announcement, after `session/created`, after `agent/created`, and again after `agent/session-start` before starting the driver, so synchronous getter- or listener-triggered teardown wins. A publication-wide barrier flips lifecycle liveness immediately but keeps both entries and `agent.ctx` intact until the current synchronous notification phase unwinds; only then does rollback revoke them. Registry/store entries claim IDs across caller-code commit windows, detach exact objects only, and reuse stable carriers for paired edges. Load/setup rejection or owner unload before announcement emits no creation edge; if teardown begins inside a creation or session-start listener, the already-started notifications are paired during rollback and no live or drivable publication survives. Teardown runs stop/drain (including outstanding idle-injection flushes) → unregister → detach session → unwind scope → release reservations. After quiescence, the caller sentinel and any resume-load sentinel disarm and remove their owner-fiber effects so a long-lived caller does not retain the completed agent and scope. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`; the registry's paired disposal edge applies the same failure containment through its captured carrier. Per-step assembly goes through `assembleContextFor(agent)`, and the turn-end durability checkpoint goes through `ctx.sessions.flush(session)`. - `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create, used directly by programs and by `cordis.yml`-configured agents. It creates a fresh per-run session id `${id}-session-` with optional metadata; the uuid avoids colliding with a prior durable log. Each call is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber. @@ -17,7 +19,7 @@ Lifecycle (scoped): programmatic creation and resume snapshot caller-owned ident - `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup? }): Promise` — programmatic create on a caller-supplied `sessionId`, NOT `${id}-session`. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and detaches each raw value in one pass. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown. - `ctx.agents.resume({ agentId, resumeSessionId, agentOptions?, setup? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`. -The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle) — only the programmatic factory callers (the ACP bridge and in-process subagent backends) hold a handle and own per-agent teardown. +The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code. ### Injected services diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 3234a47939..6ea60c4958 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -25,18 +25,23 @@ const claimedDriverSessions = new WeakSet() /** Module-private driver entry: its symbol is absent from the package surface. */ const startDriver = Symbol('dsh.agent-loop.start-driver') +/** Module-private quiescent stop, valid both before and after driver start. */ +const stopDriver = Symbol('dsh.agent-loop.stop-driver') + /** Factory-owned controls that can operate only on the agent created with them. */ export interface PreparedReactLoopAgent { /** The unpublished concrete agent. */ agent: ReactLoopAgent /** Open its driving verbs at the rollback-covered publication boundary. */ enableDrive(): void + /** Stop the prepared instance even when publication has not started its loop. */ + dispose(): Promise | void /** * Start its driver after publication and session-start notification. * The returned disposer reaches quiescence for both the loop and every * fire-and-forget idle-injection flush the agent started. */ - startDriver(): () => Promise + startDriver(): () => Promise | void } /** @@ -56,12 +61,20 @@ export function prepareReactLoopAgent( if (claimedDriverSessions.has(session)) { throw new Error(`session "${session.id}" already has a concrete agent driver`) } - claimedDriverSessions.add(session) const agent = new ReactLoopAgent(ctx, id, options, session) + // Construction snapshots caller options and can throw. Claim only the fully + // initialized driver so the same prepared session remains retryable after a + // rejected caller value. + claimedDriverSessions.add(session) + const dispose = () => agent[stopDriver]() return { agent, enableDrive: () => { driveEnabledAgents.add(agent) }, - startDriver: () => agent[startDriver](), + dispose, + startDriver: () => { + agent[startDriver]() + return dispose + }, } } @@ -108,6 +121,8 @@ export class ReactLoopAgent implements Agent { private _status: AgentStatus = 'idle' private currentAbort: AbortController | undefined + /** Whether runLoop has been installed into {@link done}. */ + private driverStarted = false /** * Turn-scoped cancel marker, set by {@link cancel} and read/cleared by the * driver loop (via the LoopHandle) at every point a turn could start or @@ -361,17 +376,13 @@ export class ReactLoopAgent implements Agent { } /** - * Start the driver loop. Returns a disposer: calling it sets status to - * `disposed`, emits `agent/status('disposed')`, resolves the disposed - * promise (unblocking the idle wait), releases any `whenIdle` waiters, and - * aborts the current request if any. Its returned promise resolves only after - * the loop exits and every idle-injection flush started by this agent settles. - * @returns the disposer — idempotent, synchronously marks the agent disposed, - * and asynchronously reaches loop + flush quiescence without rejecting (it - * runs inside the fiber's LIFO disposal chain, where a rejection would skip - * later disposers). + * Start the driver loop. The prepared controller already owns its stable + * disposer, so teardown can mark the agent disposed even in the narrow + * publication window before this method runs. */ - [startDriver](): () => Promise { + [startDriver](): void { + if (this._status === 'disposed') return + this.driverStarted = true this.done = runLoop(this.loopCtx, this, { inbox: this.#inbox, setStatus: (status) => { this.setStatus(status) }, @@ -389,35 +400,50 @@ export class ReactLoopAgent implements Agent { // that would resolve a freshly-queued prompt as cancelled. settleIdle: () => { this.settleIdleWaiters() }, }) - // The disposer must be infallible: it runs inside the fiber's LIFO - // disposal chain, where a throw would skip later disposers (e.g. the - // registry unregistration) and leave `done` pending forever. - return async () => { - if (this._status !== 'disposed') { - this._status = 'disposed' - this.resolveDisposed() - // Release whenIdle waiters BEFORE the (guarded) event emit — they are - // internal state that must settle even if a listener throws below. Each - // waiter chains `done`, so it resolves only once the loop actually exits. - this.settleIdleWaiters() - this.currentAbort?.abort('disposed') - // setStatus refuses transitions out of 'disposed', so emit directly — - // 'disposed' is part of the agent/status contract. Guarded: a throwing - // listener must not break the disposal chain. + } + + /** + * Quiescent stop shared by pre-start rollback and live teardown. It marks the + * agent disposed synchronously, contains an unexpected loop rejection, and + * drains every idle-injection flush before resolving. + */ + private [stopDriver](): Promise | void { + if (this._status !== 'disposed') { + this._status = 'disposed' + this.resolveDisposed() + // Release whenIdle waiters BEFORE the (guarded) event emit — they are + // internal state that must settle even if a listener throws below. Each + // waiter chains `done`, so it resolves only once the loop actually exits. + this.settleIdleWaiters() + this.currentAbort?.abort('disposed') + // An unpublished rollback has no public status lifecycle to announce. + // Once driving is enabled, disposed is part of the agent/status contract. + if (driveEnabledAgents.has(this)) { agentEvents(this.loopCtx, this).emit('agent/status', 'disposed') } - // An unexpected driver rejection must not skip registry/session/scope - // cleanup. The normal loop contains turn failures itself; allSettled is the - // final lifecycle backstop for anything outside those boundaries. - await Promise.allSettled([this.done]) - // No new inject() can start after the synchronous disposed transition. - // Loop because settled tasks retire themselves in promise reactions that - // may run beside this continuation; either the set is empty or this waits - // the exact remaining quiescence boundary. allSettled keeps a failure in - // error reporting from skipping the registry/session/scope disposers. - while (this.pendingIdleFlushes.size > 0) { - await Promise.allSettled([...this.pendingIdleFlushes]) - } + } + // Before runLoop starts there is normally nothing asynchronous to drain; + // keep publication rollback synchronous so create() cannot throw while its + // session/agent entries are still briefly live. A session-start listener + // may have used the newly enabled inject() surface, however, so preserve + // its durability checkpoint as a real quiescence boundary. + if (!this.driverStarted && this.pendingIdleFlushes.size === 0) return + return this.drainDriver() + } + + /** Await the loop (when started) and every outstanding idle flush. */ + private async drainDriver(): Promise { + // An unexpected driver rejection must not skip registry/session/scope + // cleanup. The normal loop contains turn failures itself; allSettled is the + // final lifecycle backstop for anything outside those boundaries. + await Promise.allSettled([this.done]) + // No new inject() can start after the synchronous disposed transition. + // Loop because settled tasks retire themselves in promise reactions that + // may run beside this continuation; either the set is empty or this waits + // the exact remaining quiescence boundary. allSettled keeps a failure in + // error reporting from skipping registry/session/scope disposers. + while (this.pendingIdleFlushes.size > 0) { + await Promise.allSettled([...this.pendingIdleFlushes]) } } } diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 902e1e2185..8722751962 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -7,7 +7,7 @@ * @module @deepseek-ai/dsh-agent-loop */ -import { Context, FiberState, Service } from 'cordis' +import { Context, CordisError, FiberState, Service, symbols } from 'cordis' import { randomUUID } from 'node:crypto' import z from 'schemastery' import { createScope } from '@deepseek-ai/dsh-scope' @@ -31,6 +31,81 @@ interface RegistrationReservations { release(): void } +/** A synchronously established ownership handoff plus its async publication result. */ +interface OwnedAgentStart { + result: Promise + dispose: () => Promise +} + +/** Internal carrier for a preparation error whose rollback still has to quiesce. */ +class LifecyclePreparationFailure extends Error { + constructor( + readonly reason: unknown, + readonly dispose: () => Promise, + ) { + super('agent lifecycle preparation failed', { cause: reason }) + this.name = 'LifecyclePreparationFailure' + } +} + +/** Stable construction-time state shared by every traceable AgentLoop receiver. */ +interface FactoryOwnership { + isActive(): boolean + track(dispose: () => Promise): () => void + dispose(): Promise +} + +/** Fiber states in which a concrete factory cannot safely serve dependencies. */ +const INACTIVE_FACTORY_STATES: ReadonlySet = new Set([ + FiberState.UNLOADING, + FiberState.DISPOSED, + FiberState.FAILED, +]) + +/** Build a tamper-resistant controller around one factory's private ledger. */ +function createFactoryOwnership(fiber: Context['fiber']): FactoryOwnership { + let accepting = true + const transactions = new Set<() => Promise>() + const isActive = (): boolean => accepting && !INACTIVE_FACTORY_STATES.has(fiber.state) + return Object.freeze({ + isActive, + track(dispose: () => Promise): () => void { + /* v8 ignore next -- every call site checks the same controller immediately + * before this synchronous, non-reentrant insertion; retain the guard as an invariant */ + if (!isActive()) throw new Error('agent loop is not active') + transactions.add(dispose) + return () => { transactions.delete(dispose) } + }, + async dispose(): Promise { + accepting = false + const disposers = [...transactions] + transactions.clear() + const results = await Promise.allSettled(disposers.map(dispose => Promise.resolve().then(dispose))) + /* v8 ignore next -- tracked lifecycle/load boundaries are deliberately + * infallible; keep reasons if that lower-level contract ever breaks */ + const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason as unknown] : []) + /* v8 ignore next -- every tracked boundary is deliberately infallible; + * preserve an exact unexpected single failure as a defensive backstop */ + if (errors.length === 1) throw errors[0] + /* v8 ignore next -- multiple failures require multiple contract-breaking + * lifecycle disposers, but teardown must still retain every cause */ + if (errors.length > 1) throw new AggregateError(errors, 'agent loop transaction disposal failed') + }, + }) +} + +/** Private ownership controllers keyed by the concrete, unproxied service. */ +const factoryOwnerships = new WeakMap() + +/** Recover the stable controller when a Cordis trace proxy is the receiver. */ +function factoryOwnershipFor(loop: AgentLoop): FactoryOwnership { + const original = (loop as AgentLoop & { [symbols.original]?: AgentLoop })[symbols.original] ?? loop + // Installed immediately after Service construction, before AgentLoop starts + // any effect or config-driven transaction. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return factoryOwnerships.get(original)! +} + declare module 'cordis' { interface Context { agentLoop: AgentLoop @@ -94,6 +169,13 @@ export class AgentLoop extends Service implements AgentFactory { constructor(ctx: Context, public config: Config) { super(ctx, 'agentLoop') + const factoryOwnership = createFactoryOwnership(ctx.fiber) + factoryOwnerships.set(this, factoryOwnership) + // Programmatic agents are caller-owned, but this implementation is their + // dependency provider too. Retain a second ownership edge so unloading the + // loop aborts unpublished work and drains every live lifecycle before its + // service surface disappears. + ctx.effect(() => () => factoryOwnership.dispose(), 'agentLoop.factoryTransactions()') // Provide the agent-creation factory to the registry (effect-scoped: the // slot is cleared on dispose). ctx.effect(() => this.ctx.agents.setFactory(this), 'agentLoop.setFactory()') @@ -118,7 +200,11 @@ export class AgentLoop extends Service implements AgentFactory { // failed resume is contained + logged: startup must not crash. ctx.effect(() => { const fiber = this.ctx.inject(['sessionPersistence'], (childCtx: Context) => { - void this.resumeWith(childCtx.sessionPersistence, { agentId: id, resumeSessionId, agentOptions: options }) + void this.resumeWith(ctx, childCtx.sessionPersistence, { + agentId: id, + resumeSessionId, + agentOptions: options, + }) .catch((error: unknown) => { this.ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`) }) @@ -135,6 +221,22 @@ export class AgentLoop extends Service implements AgentFactory { } } + /** Whether this concrete factory may begin or publish more work. */ + private factoryIsActive(): boolean { + return factoryOwnershipFor(this).isActive() + } + + /** Reject a call that raced the concrete loop's unload boundary. */ + private assertFactoryActive(): void { + if (this.factoryIsActive()) return + throw new Error('agent loop is not active') + } + + /** Add one memoized quiescence boundary to the factory's ownership set. */ + private trackFactoryTransaction(dispose: () => Promise): () => void { + return factoryOwnershipFor(this).track(dispose) + } + /** * Config-driven create: an agent on a FRESH, non-colliding session id per run * (`${id}-session-`). Used for `cordis.yml`-configured agents and as @@ -162,13 +264,17 @@ export class AgentLoop extends Service implements AgentFactory { // lifecycle into the agent's composite effect (so a fiber unload tears the // session + agent down as one ordered chain, capturing the loop's closing // flush). The whole effect is owned by THIS fiber; no AgentHandle is needed. + let session: Session try { - const session = reservations.session.prepare({ meta }) - const { agent } = this.start(id, options, session, 'startup', reservations) - return agent - } finally { + session = reservations.session.prepare({ meta }) + } catch (error: unknown) { reservations.release() + throw error } + // start() accepts ownership of both reservation capabilities even when + // synchronous preparation fails; its rollback releases them at quiescence. + const { agent } = this.start(id, options, session, 'startup', reservations) + return agent } /** @@ -180,11 +286,13 @@ export class AgentLoop extends Service implements AgentFactory { * `seed` (a balanced completed-turn prefix of the parent's log) so the child * starts with the parent's context. Returns an {@link AgentHandle} the owner * disposes to tear down exactly this agent. + * @param ownerCtx - the caller context that owns setup and the live lifecycle. * @param options - agent id, caller-supplied session id, optional seed/meta, * and agent options. * @returns the handle whose dispose tears down exactly this agent. */ - async createAgent(options: CreateAgentOptions): Promise { + async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise { + this.assertFactoryActive() // Snapshot every caller-owned field before the first async setup boundary. // The callback itself is an identity capability. Agent options detach here; // seed and metadata stay raw only until sessions.prepare() synchronously @@ -196,16 +304,32 @@ export class AgentLoop extends Service implements AgentFactory { const agentOptions = structuredClone(options.agentOptions ?? {}) const seed = options.seed const meta = options.meta - const reservations = this.reserve(agentId, sessionId) + // Snapshot accessors can reenter plugin teardown; do not reserve identities + // after the dependency provider has begun unloading. + this.assertFactoryActive() + const { promise: transactionSettled, resolve: markTransactionSettled } = Promise.withResolvers() + const disposeCreateForFactory = (): Promise => transactionSettled + const untrackFactoryCreate = this.trackFactoryTransaction(disposeCreateForFactory) try { - const session = reservations.session.prepare({ - ...seed !== undefined ? { seed } : {}, - ...meta !== undefined ? { meta } : {}, - }) - // A seeded (forked) create is still a fresh start, NOT a resume. - return await this.startOwned(agentId, agentOptions, session, 'startup', reservations, setup) + const reservations = this.reserve(agentId, sessionId) + let lifecycleStarted = false + try { + const session = reservations.session.prepare({ + ...seed !== undefined ? { seed } : {}, + ...meta !== undefined ? { meta } : {}, + }) + // A seeded (forked) create is still a fresh start, NOT a resume. + lifecycleStarted = true + return await this.startOwned(ownerCtx, agentId, agentOptions, session, 'startup', reservations, setup).result + } finally { + // Once startOwned is invoked, even a preparation failure carries its + // own quiescent rollback boundary. Only failures before that handoff + // release directly here. + if (!lifecycleStarted) reservations.release() + } } finally { - reservations.release() + markTransactionSettled() + untrackFactoryCreate() } } @@ -220,10 +344,12 @@ export class AgentLoop extends Service implements AgentFactory { * configured. NOT hard-injected (that would make non-persistent demos pend * forever) — callers that need resume (ACP) inject `sessionPersistence`, so * by the time this runs the service exists. + * @param ownerCtx - the caller context that owns load, setup, and the live lifecycle. * @param options - the persisted session id to reload, plus agent id/options. * @returns the handle for the agent resumed on the reconstructed session. */ - async resume(options: ResumeAgentOptions): Promise { + async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise { + this.assertFactoryActive() // Read the service through `ctx.get('sessionPersistence')` — a direct // global-store lookup keyed by the isolate symbol — NOT // `this.ctx.sessionPersistence`. AgentLoop deliberately does NOT inject @@ -243,7 +369,7 @@ export class AgentLoop extends Service implements AgentFactory { if (persistence === undefined) { throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)') } - return this.resumeWith(persistence, options) + return this.resumeWith(ownerCtx, persistence, options) } /** @@ -255,7 +381,7 @@ export class AgentLoop extends Service implements AgentFactory { * sessions store + registry are still read through `this.ctx` (both are in * AgentLoop's static inject, so they resolve fine). */ - private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise { + private async resumeWith(ownerCtx: Context, persistence: SessionPersistence, options: ResumeAgentOptions): Promise { // Persistence is an async trust boundary. Reserve, load, reconstruct, and // publish only the identities/options accepted at entry—never fields // reread from a caller-owned object after the await. @@ -263,25 +389,65 @@ export class AgentLoop extends Service implements AgentFactory { const sessionId = options.resumeSessionId const agentOptions = structuredClone(options.agentOptions ?? {}) const setup = options.setup + // Caller-owned accessors above are a synchronous reentrancy boundary: one + // can begin factory unload while options are snapshotted. Re-check before + // installing either ownership sentinel, so a rejected transaction leaves + // no orphan effect or unresolved settlement promise. + this.assertFactoryActive() const { promise: ownerDisposed, resolve: markOwnerDisposed } = Promise.withResolvers() const { promise: transactionSettled, resolve: markTransactionSettled } = Promise.withResolvers() let observingOwner = true // Resume must observe its caller from BEFORE persistence I/O begins. The // full agent lifecycle does not exist until load returns, so without this // sentinel a never-settling backend outlives owner disposal and holds both - // public identities forever. `this.ctx.effect` retains the traceable caller - // ownership used by startOwned's lifecycle effect. Install it before even - // reserving the ids: an inactive owner cannot leak a reservation if effect - // registration fails. - const disposeLoadSentinel = this.ctx.effect(() => () => { - if (!observingOwner) return + // public identities forever. The caller-bound effect retains the same owner + // later used by startOwned's lifecycle effect and adopts both reservation + // disposers before persistence I/O begins. + let lifecycleBoundary: (() => Promise) | undefined + let disposingForFactory: Promise | undefined + const disposeLoadForFactory = (): Promise => (disposingForFactory ??= (async () => { markOwnerDisposed() - // Owner-triggered teardown does not reach quiescence until the resume - // transaction has observed disposal and released both reservations. - return transactionSettled - }, `agentLoop.resumeLoad(${agentId})`) + await transactionSettled + })()) + let untrackFactoryLoad: (() => void) | undefined + let disposeLoadSentinel: (() => Promise | void) | undefined + let loadSentinelRetired = false + const retireLoadSentinel = (): void => { + /* v8 ignore next -- every lifecycle/rollback boundary is memoized and + * invokes its after-quiescence hook once; retain idempotence defensively */ + if (loadSentinelRetired) return + // Disarm the follower before invoking its wrapper: retirement can happen + // from inside the lifecycle it used to follow, so recursing into that + // same boundary here would deadlock final teardown. + loadSentinelRetired = true + observingOwner = false + void disposeLoadSentinel?.() + } + let reservations: RegistrationReservations | undefined + let lifecycleStarted = false try { - const reservations = this.reserve(agentId, sessionId) + reservations = this.reserve(agentId, sessionId) + const ownedReservations = reservations + // Move both reservation effects under a sentinel BEFORE persistence I/O. + // Its first teardown stage either aborts/waits for the load transaction + // or follows the full lifecycle after handoff; only then do the exact + // reservation disposers run. They therefore cannot race ahead as owner + // siblings and reopen ids while load/setup/scope cleanup is still live. + disposeLoadSentinel = ownerCtx.effect(function* () { + // eslint-disable-next-line @typescript-eslint/unbound-method -- exact effect-disposer identity is the ownership contract + yield ownedReservations.agent.release + // eslint-disable-next-line @typescript-eslint/unbound-method -- exact effect-disposer identity is the ownership contract + yield ownedReservations.session.release + yield () => { + if (loadSentinelRetired) return + if (observingOwner) { + markOwnerDisposed() + return transactionSettled + } + return lifecycleBoundary?.() + } + }, `agentLoop.resumeLoad(${agentId})`) + untrackFactoryLoad = this.trackFactoryTransaction(disposeLoadForFactory) try { const loadTask = persistence.load(sessionId) const { meta, events } = await Promise.race([ @@ -309,16 +475,26 @@ export class AgentLoop extends Service implements AgentFactory { ...seedLength !== undefined ? { seedLength } : {}, }, }) - // Calling startOwned synchronously installs the complete lifecycle - // effect before it reaches its first setup await. Only then disarm the - // load sentinel: ownership passes directly from one effect to the other - // with no disposal gap. - const starting = this.startOwned(agentId, agentOptions, session, 'resume', reservations, setup) + // startOwned synchronously returns either the complete lifecycle or a + // preparation-rollback boundary before its result reaches the first + // setup await. Retarget the lifecycle-long load sentinel to that disposer; + // ownership overlaps instead of creating a gap. + lifecycleStarted = true + const starting = this.startOwned( + ownerCtx, + agentId, + agentOptions, + session, + 'resume', + reservations, + setup, + retireLoadSentinel, + ) + lifecycleBoundary = starting.dispose observingOwner = false - await disposeLoadSentinel() - return await starting + return await starting.result } finally { - reservations.release() + if (!lifecycleStarted) reservations.release() } } finally { try { @@ -327,10 +503,18 @@ export class AgentLoop extends Service implements AgentFactory { // owner already triggered cleanup, this idempotent second disposal is a // no-op and the owner's first cleanup remains parked on the shared // settlement promise. - observingOwner = false - await disposeLoadSentinel() + if (!lifecycleStarted) { + // Covers reserve succeeding but sentinel/factory tracking failing + // before the inner load transaction begins. + reservations?.release() + // A failed pre-lifecycle transaction has already released directly; + // retire the sentinel so it cannot remain as a stale owner effect. + retireLoadSentinel() + await disposeLoadSentinel?.() + } } finally { markTransactionSettled() + untrackFactoryLoad?.() } } } @@ -358,17 +542,20 @@ export class AgentLoop extends Service implements AgentFactory { /** * Construct an unpublished agent and synchronously install its complete - * teardown skeleton before any setup await. The closures are assigned their - * session/registry/loop disposers only at publication, while the exact scope - * disposer is nested immediately. Therefore owner unload during setup flips - * `active`, unwinds the scope, and wins the race without any late Cordis - * effect collection. + * teardown skeleton before any setup await. A lifecycle-long caller sentinel and + * factory placeholder exist before driver/scope construction; the closures + * receive their session/registry/loop disposers only at publication, while + * the exact scope disposer is nested as soon as construction returns. Owner + * unload during preparation or setup therefore follows a real rollback + * boundary, flips liveness, and wins without late Cordis effect collection. */ private prepareLifecycle( + ownerCtx: Context, id: AgentId, options: AgentOptions, session: Session, reservations: RegistrationReservations, + afterQuiescence?: () => void, ): { agent: ReactLoopAgent active: () => boolean @@ -381,77 +568,266 @@ export class AgentLoop extends Service implements AgentFactory { // than Cordis reaches nested scope effects. Include that signal in the // pre-publication liveness check so a same-turn parent dispose cannot race // an already-fulfilled setup promise into briefly publishing a child. - const ownerAgent = this.ctx.agent - const ownerFiber = this.ctx.fiber - const driver = prepareReactLoopAgent(this.ctx, id, options, session) - const { agent } = driver - const scope: Scope = createScope(this.ctx, agent) - bindReactLoopAgentContext(agent, scope.ctx.extend({ agent })) - - let active = true - let detachSession: (() => void) | undefined - let detachAgent: (() => void) | undefined - let stop: (() => Promise) | undefined - const { promise: deactivated, resolve: markDeactivated } = Promise.withResolvers() - const { promise: torndown, resolve: markTorndown } = Promise.withResolvers() - - const dispose = this.ctx.effect(function* () { - // First yielded, disposed last: every preceding teardown stage settled. - yield () => { markTorndown() } - // Exact identity moves the scope fiber out of the owner's concurrent - // sibling list and into this ordered transaction. - yield scope.rawDispose - yield () => { - detachSession?.() - detachSession = undefined - } - yield () => { - detachAgent?.() - detachAgent = undefined - } - // Last yielded, disposed first. Keep the pre-publication path - // synchronous: returning a Promise only after the loop actually began - // lets a failed announcement roll back registry/store before create's - // rejection is observed. - yield () => { - active = false - markDeactivated() - if (stop === undefined) return - return stop() - } - }, 'agentLoop.lifecycle()') - - let disposing: Promise | undefined - const disposeAgent = (): Promise => (disposing ??= (async () => { - await dispose() - await torndown - })()) - - const publish = (source: SessionStartSource): void => { - // Publication is one synchronous, rollback-covered sequence. Setup has - // already completed, so its scoped listeners observe both announcements. - detachSession = agent.ctx.sessions.enter(session, reservations.session) - detachAgent = this.ctx.agents.enter(agent, reservations.agent) - this.ctx.sessions.announce(session) - this.ctx.agents.announce(agent) - // Setup is over and both entries are live. Open the driving surface just - // before session-start so its listeners retain their supported ability to - // inject/queue, while setup itself can never drive an unpublished agent. - driver.enableDrive() - agentEvents(this.ctx, agent).emit('agent/session-start', source) - stop = driver.startDriver() + let ownerAgent: Context['agent'] + let ownerFiber: Context['fiber'] + try { + this.assertFactoryActive() + ownerCtx.fiber.assertActive() + ownerAgent = ownerCtx.agent + ownerFiber = ownerCtx.fiber + } catch (error: unknown) { + reservations.release() + afterQuiescence?.() + const dispose = (): Promise => Promise.resolve() + throw new LifecyclePreparationFailure(error, dispose) } - return { - agent, - active: () => active + // Establish BOTH ownership edges before driver preparation or scope + // minting can publish an internal lifecycle notification. The lifecycle-long + // caller sentinel also adopts the exact reservation effects: owner unload + // first waits for the memoized lifecycle boundary, then reaches those + // capabilities, so IDs cannot reopen while scope cleanup is still live. + const { promise: lifecycleReady, resolve: markLifecycleReady } + = Promise.withResolvers<() => Promise>() + const { promise: deactivated, resolve: markDeactivated } = Promise.withResolvers() + let ownerDisposed = false + const ownerIsDisposed = (): boolean => ownerDisposed + let ownerSentinelRetired = false + let disposeOwnerSentinel: () => Promise | void + try { + disposeOwnerSentinel = ownerCtx.effect(function* () { + // eslint-disable-next-line @typescript-eslint/unbound-method -- exact effect-disposer identity is the ownership contract + yield reservations.agent.release + // eslint-disable-next-line @typescript-eslint/unbound-method -- exact effect-disposer identity is the ownership contract + yield reservations.session.release + yield () => { + if (ownerSentinelRetired) return + ownerDisposed = true + markDeactivated() + return lifecycleReady.then(disposeLifecycle => disposeLifecycle()) + } + }, `agentLoop.ownerLifecycle(${id})`) + } catch (error: unknown) { + reservations.release() + afterQuiescence?.() + const dispose = (): Promise => Promise.resolve() + markLifecycleReady(dispose) + // The only callback-free effect-install failure is Cordis's inactive + // owner boundary; preserve the original value as cause for diagnostics. + const reportedError = new Error(`agent "${id}" setup aborted: owner disposed during setup`, { cause: error }) + throw new LifecyclePreparationFailure(reportedError, dispose) + } + let disposingForFactory: Promise | undefined + const disposeForFactory = (): Promise => (disposingForFactory ??= (async () => { + const disposeLifecycle = await lifecycleReady + await disposeLifecycle() + })()) + let untrackFactory: () => void + try { + untrackFactory = this.trackFactoryTransaction(disposeForFactory) + } catch (error: unknown) { + /* v8 ignore start -- no callback boundary exists between the active + * factory check, sentinel installation, and this synchronous ledger insert */ + let cleanupTask: Promise | undefined + const cleanup = (): Promise => (cleanupTask ??= Promise.resolve().then(() => { + reservations.release() + afterQuiescence?.() + })) + markLifecycleReady(cleanup) + void disposeOwnerSentinel() + throw new LifecyclePreparationFailure(error, cleanup) + /* v8 ignore stop */ + } + + let scope: Scope | undefined + let stopPrepared: (() => Promise | void) | undefined + let disposeAgent: (() => Promise) | undefined + try { + const driver = prepareReactLoopAgent(this.ctx, id, options, session) + stopPrepared = () => driver.dispose() + const { agent } = driver + scope = createScope(this.ctx, agent) + const lifecycleScope = scope + if (ownerIsDisposed() || !this.factoryIsActive() + || ownerFiber.state === FiberState.UNLOADING + || ownerFiber.state === FiberState.DISPOSED + || ownerFiber.state === FiberState.FAILED + || ownerAgent?.status === 'disposed') { + throw new Error(`agent "${id}" setup aborted: owner disposed during setup`) + } + bindReactLoopAgentContext(agent, lifecycleScope.ctx.extend({ agent })) + + let active = true + let detachSession: (() => void) | undefined + let detachAgent: (() => void) | undefined + const stop = stopPrepared + const { promise: torndown, resolve: markTorndown } = Promise.withResolvers() + const { promise: publicationSettled, resolve: markPublicationSettled } = Promise.withResolvers() + let publishing = false + + const dispose = ownerCtx.effect(function* () { + // First yielded, disposed last: every preceding teardown stage settled. + yield () => { + // Reservation ownership is part of lifecycle settlement: a factory + // unload that awaited this disposer may reuse both ids immediately. + reservations.release() + // Retire both follower effects only after quiescence reached this final + // stage. Their retired branches skip recursively disposing this same + // lifecycle while their exact reservation children are already inert. + ownerSentinelRetired = true + void disposeOwnerSentinel() + afterQuiescence?.() + untrackFactory() + markTorndown() + } + // Exact identity moves the scope fiber out of the owner's concurrent + // sibling list and into this ordered transaction. + yield lifecycleScope.rawDispose + yield () => { + detachSession?.() + detachSession = undefined + } + yield () => { + detachAgent?.() + detachAgent = undefined + } + // Last yielded, disposed first. Keep the pre-publication path + // synchronous: returning a Promise only after the loop actually began + // lets a failed announcement roll back registry/store before create's + // rejection is observed. + yield () => { + active = false + markDeactivated() + // A listener can begin owner teardown reentrantly. Flip liveness now + // so publish's next checkpoint aborts, but keep both registry entries + // and the scope intact until the current synchronous publication + // phase has unwound. + if (publishing) return publicationSettled.then(stop) + return stop() + } + }, 'agentLoop.lifecycle()') + + let disposing: Promise | undefined + disposeAgent = (): Promise => (disposing ??= (async () => { + await dispose() + await torndown + })()) + markLifecycleReady(disposeAgent) + + const isActive = (): boolean => active + && !ownerIsDisposed() + && this.factoryIsActive() && ownerFiber.state !== FiberState.UNLOADING && ownerFiber.state !== FiberState.DISPOSED && ownerFiber.state !== FiberState.FAILED - && ownerAgent?.status !== 'disposed', - deactivated, - publish, - disposeAgent, + && ownerAgent?.status !== 'disposed' + + const publish = (source: SessionStartSource): void => { + publishing = true + try { + /* v8 ignore next 3 -- both callers check active immediately before + * this callback-free synchronous publish entry */ + if (!isActive()) { + throw new Error(`agent "${id}" setup aborted: owner disposed during setup`) + } + // Publication is one synchronous, rollback-covered sequence. Setup has + // already completed, so its scoped listeners observe both announcements. + detachSession = agent.ctx.sessions.enter(session, reservations.session) + detachAgent = this.ctx.agents.enter(agent, reservations.agent) + // Both enter() calls capture stable dispatch carriers and therefore + // evaluate a caller-owned Context.filter. A getter can begin teardown; + // entries exist for rollback, but no creation edge may escape afterward. + if (!isActive()) { + throw new Error(`agent "${id}" setup aborted: owner disposed during setup`) + } + this.ctx.sessions.announce(session) + // Session listeners can dispose an owner. Finish that dispatch while + // both entries/scope remain live, then skip the agent edge entirely. + if (!isActive()) { + throw new Error(`agent "${id}" setup aborted: owner disposed during setup`) + } + this.ctx.agents.announce(agent) + // Creation listeners may synchronously dispose either owner. Cordis + // flips the relevant fiber state before it invokes nested effects, so + // re-check here and never unlock a driver after teardown began. + if (!isActive()) { + throw new Error(`agent "${id}" setup aborted: owner disposed during setup`) + } + // Setup is over and both entries are live. Open the driving surface just + // before session-start so its listeners retain their supported ability to + // inject/queue, while setup itself can never drive an unpublished agent. + driver.enableDrive() + agentEvents(this.ctx, agent).emit('agent/session-start', source) + // session-start is the final synchronous listener boundary before the + // loop begins. Teardown there must win just like teardown from either + // creation announcement; the prebuilt driver disposer makes rollback + // quiescent even though the loop never started. + if (!isActive()) { + throw new Error(`agent "${id}" setup aborted: owner disposed during setup`) + } + driver.startDriver() + } finally { + publishing = false + markPublicationSettled() + } + } + + return { + agent, + active: isActive, + deactivated, + publish, + disposeAgent, + } + } catch (error: unknown) { + // Preparation failed before startOwned received a lifecycle object. Give + // a factory unload that already captured the placeholder a real boundary, + // and retire the entry only after the minted scope (if any) is quiescent. + const failedScope = scope + const ownershipInactive = ownerIsDisposed() || ownerFiber.uid === null || !this.factoryIsActive() + || ownerFiber.state === FiberState.UNLOADING + || ownerFiber.state === FiberState.DISPOSED + || ownerFiber.state === FiberState.FAILED + || ownerAgent?.status === 'disposed' + const reportedError = ownershipInactive && error instanceof CordisError + ? new Error(`agent "${id}" setup aborted: owner disposed during setup`, { cause: error }) + : error + let fallbackTask: Promise | undefined + const cleanup = disposeAgent ?? (() => (fallbackTask ??= (async () => { + try { + await stopPrepared?.() + } finally { + try { + await failedScope?.dispose() + } finally { + // Factory and caller quiescence include the prepared driver, + // minted scope, and both unpublished identities even when the + // complete lifecycle effect could not be installed. + reservations.release() + afterQuiescence?.() + } + } + })())) + markLifecycleReady(cleanup) + const cleanupTask = cleanup() + // Retire the provisional owner edge. If owner unload already claimed it, + // this is an inert repeat and that first caller is following cleanupTask. + void disposeOwnerSentinel() + void cleanupTask.then( + untrackFactory, + /* v8 ignore next -- Scope.dispose is specified to contain child + * failures; preserve diagnostics if that lower-level contract breaks */ + (cleanupError: unknown) => { + untrackFactory() + try { + this.ctx.logger.error(new AggregateError([reportedError, cleanupError], 'agent lifecycle preparation and rollback failed')) + } catch { + // Only a logger-export failure is swallowed: the original + // preparation error is already propagating to the caller. + } + }, + ) + throw new LifecyclePreparationFailure(reportedError, cleanup) } } @@ -463,7 +839,16 @@ export class AgentLoop extends Service implements AgentFactory { source: SessionStartSource, reservations: RegistrationReservations, ): { agent: ReactLoopAgent; disposeAgent: () => Promise } { - const lifecycle = this.prepareLifecycle(id, options, session, reservations) + let lifecycle: ReturnType + try { + lifecycle = this.prepareLifecycle(this.ctx, id, options, session, reservations) + } catch (error: unknown) { + /* v8 ignore next -- prepareLifecycle converts every failure into its + * rollback-bearing internal error before crossing this boundary */ + if (!(error instanceof LifecyclePreparationFailure)) throw error + void error.dispose() + throw error.reason + } try { lifecycle.publish(source) return { agent: lifecycle.agent, disposeAgent: lifecycle.disposeAgent } @@ -477,10 +862,10 @@ export class AgentLoop extends Service implements AgentFactory { * Build an {@link AgentHandle} for a PREPARED session + a fresh agent. The * handle's `dispose()` runs the composite effect's disposer (see * {@link start}) — which stops the loop, awaits its exit and outstanding - * idle-injection flushes, unregisters the agent, and detaches the session, in - * that order. - * The same composite effect is what a fiber unload disposes, so both teardown - * triggers honor the ordering identically. + * idle-injection flushes, unregisters the agent, detaches the session, + * unwinds the scope, and releases both ids, in that order. Caller-fiber unload + * also invokes an independent sentinel that follows this memoized boundary, + * so handle-first and owner-first races honor the same ordering. * * `dispose()` is MEMOIZED: the underlying cordis effect disposer is * single-shot (a second call returns immediately because the effect's epoch is @@ -491,13 +876,49 @@ export class AgentLoop extends Service implements AgentFactory { * `AgentHandle.dispose(): Promise` contract (mirrors the ACP `quiesce()` * helper). */ - private async startOwned( + private startOwned( + ownerCtx: Context, id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource, reservations: RegistrationReservations, setup?: (agentCtx: Context) => Promise | void, - ): Promise { - const lifecycle = this.prepareLifecycle(id, options, session, reservations) + afterQuiescence?: () => void, + ): OwnedAgentStart { + let lifecycle: ReturnType try { + lifecycle = this.prepareLifecycle(ownerCtx, id, options, session, reservations, afterQuiescence) + } catch (error: unknown) { + /* v8 ignore next 1 -- prepareLifecycle wraps every synchronous failure */ + if (!(error instanceof LifecyclePreparationFailure)) throw error + return { + dispose: error.dispose, + result: (async () => { + await error.dispose() + throw error.reason + })(), + } + } + return { + dispose: lifecycle.disposeAgent, + result: this.finishOwnedStart(lifecycle, id, source, setup), + } + } + + /** Await setup and publish after {@link startOwned} established ownership synchronously. */ + private async finishOwnedStart( + lifecycle: ReturnType, + id: AgentId, + source: SessionStartSource, + setup?: (agentCtx: Context) => Promise | void, + ): Promise { + try { + // Scope minting emits Cordis's synchronous internal/plugin notification. + // A listener can unload either owner there; never run arbitrary setup in + // the already-doomed scope while the tracked disposer is catching up. + /* v8 ignore next 3 -- prepareLifecycle returns success only after its + * final synchronous liveness check; no callback runs before this line */ + if (!lifecycle.active()) { + throw new Error(`agent "${id}" setup aborted: owner disposed during setup`) + } // The owner-disposal branch makes a never-settling setup unable to hold // the transaction or its ID reservations forever. Promise.race installs // rejection observation on setup even if owner disposal wins first. diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index b2a4fe30d5..be1a0fe5b5 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -296,6 +296,39 @@ describe('ReactLoopAgent', () => { expect(agent.status).toBe('disposed') }) + it('a pre-start disposal makes a later driver-start attempt inert', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('pre-start-dispose')) + const prepared = prepareReactLoopAgent(ctx, AgentId('pre-start-dispose'), { model: 'mock' }, session) + + await prepared.dispose() + expect(prepared.agent.status).toBe('disposed') + const dispose = prepared.startDriver() + await dispose() + await expect(prepared.agent.done).resolves.toBeUndefined() + expect(prepared.agent.session.events).toEqual([]) + await ctx.fiber.dispose() + }) + + it('does not claim a session when concrete-agent construction rejects options', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('constructor-retry')) + const badOptions = { + get model(): string { + throw new Error('bad model getter') + }, + } + + expect(() => prepareReactLoopAgent(ctx, AgentId('bad-constructor'), badOptions, session)) + .toThrow('bad model getter') + const prepared = prepareReactLoopAgent(ctx, AgentId('constructor-retry'), { model: 'mock' }, session) + await prepared.dispose() + expect(prepared.agent.status).toBe('disposed') + await ctx.fiber.dispose() + }) + it('setting the same status does not emit agent/status again', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 8ed557faaf..d739ff0ed3 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -228,6 +228,27 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx.fiber.dispose() }) + it('successful resume disposal retires both caller ownership sentinels', async () => { + const sessionId = SessionId('resume-retired-sentinels-s') + const agentId = AgentId('resume-retired-sentinels') + const root = await persistSession(sessionId) + const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) + const handle = await ctx.agents.resume({ + agentId, + resumeSessionId: sessionId, + agentOptions: { model: 'mock' }, + }) + const sentinelLabels = [ + `agentLoop.resumeLoad(${agentId})`, + `agentLoop.ownerLifecycle(${agentId})`, + ] + + expect(ctx.fiber.getEffects().map(effect => effect.label)).toEqual(expect.arrayContaining(sentinelLabels)) + await handle.dispose() + expect(ctx.fiber.getEffects().filter(effect => sentinelLabels.includes(effect.label))).toEqual([]) + await ctx.fiber.dispose() + }) + it('resume setup rejection publishes nothing, unwinds, and releases both identities', async () => { const sessionId = SessionId('resume-setup-reject') const root = await persistSession(sessionId) @@ -351,6 +372,93 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx.fiber.dispose() }) + it('AgentLoop unload aborts persistence load and awaits reservation release', async () => { + const sessionId = SessionId('resume-load-factory-unload') + const agentId = AgentId('resume-load-factory-race') + const root = await persistSession(sessionId) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + const loopFiber = await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionPersistenceJsonl, { root }) + ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('next')])) + + const snapshot = await ctx.sessionPersistence.load(sessionId) + const lateLoad = Promise.withResolvers() + const loadStarted = Promise.withResolvers() + ctx.sessionPersistence.load = (id) => { + expect(id).toBe(sessionId) + loadStarted.resolve(undefined) + return lateLoad.promise + } + const published: string[] = [] + ctx.on('session/created', () => void published.push('session/created')) + ctx.on('agent/created', () => void published.push('agent/created')) + + const resuming = ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } }) + await loadStarted.promise + const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during persistence load/) + await promptly(loopFiber.dispose()) + await rejection + + expect(published).toEqual([]) + expect(ctx.agents.get(agentId)).toBeUndefined() + expect(ctx.sessions.get(sessionId)).toBeUndefined() + const agentReservation = ctx.agents.reserve(agentId) + const sessionReservation = ctx.sessions.reserve(sessionId) + sessionReservation.release() + agentReservation.release() + + lateLoad.resolve(structuredClone(snapshot)) + await Promise.resolve() + await Promise.resolve() + expect(published).toEqual([]) + await ctx.fiber.dispose() + }) + + it('option snapshot reentrancy cannot install a resume sentinel after factory unload begins', async () => { + const sessionId = SessionId('resume-snapshot-factory-unload') + const agentId = AgentId('resume-snapshot-factory-race') + const root = await persistSession(sessionId) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + const loopFiber = await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionPersistenceJsonl, { root }) + ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('next')])) + + let loads = 0 + const load = ctx.sessionPersistence.load.bind(ctx.sessionPersistence) + ctx.sessionPersistence.load = (id) => { + loads += 1 + return load(id) + } + const options = { + agentId, + resumeSessionId: sessionId, + get agentOptions() { + void loopFiber.dispose() + return { model: 'mock' } + }, + } + + await expect(ctx.agents.resume(options)).rejects.toThrow('agent loop is not active') + await loopFiber.dispose() + expect(loads).toBe(0) + expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.resumeLoad(${agentId})`)).toEqual([]) + const agentReservation = ctx.agents.reserve(agentId) + const sessionReservation = ctx.sessions.reserve(sessionId) + sessionReservation.release() + agentReservation.release() + await ctx.fiber.dispose() + }) + it('snapshots resume identities and agent options before persistence load', async () => { const sessionId = SessionId('resume-snapshot-source') const root = await persistSession(sessionId) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 5ed11d396a..275fdadb5a 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context, symbols, type EffectMeta, type Fiber } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -12,16 +12,20 @@ import * as concreteAgentModule from '../src/agent.ts' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { MockAdapter, textResponse } from './mock-adapter.ts' -async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok')])) { +async function harnessWithLoop(adapter: MockAdapter = new MockAdapter([textResponse('ok')])): Promise<{ ctx: Context; loopFiber: Fiber }> { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, { persona: 'You are the deployment.' }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentLoop, { agents: [] }) + const loopFiber = await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) - return ctx + return { ctx, loopFiber } +} + +async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok')])): Promise { + return (await harnessWithLoop(adapter)).ctx } function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { @@ -37,6 +41,17 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }] +/** Invoke the exact lifecycle effect to exercise same-stack reentrant teardown. */ +function disposeCurrentLifecycle(ownerCtx: Context): void { + const lifecycle = [...ownerCtx.fiber._disposables] + .find((dispose) => { + const effect = (dispose as typeof dispose & { [symbols.effect]?: EffectMeta })[symbols.effect] + return effect?.label === 'agentLoop.lifecycle()' + }) + if (lifecycle === undefined) throw new Error('agent lifecycle effect not found') + void lifecycle() +} + describe('agent scope lifecycle', () => { it('wires agent.ctx: tagged with the agent, DX field set, ctx.agent safe elsewhere', async () => { const ctx = await harness() @@ -312,6 +327,508 @@ describe('agent scope lifecycle', () => { expect(ctx.sessions.get(SessionId('owner-race-s-2'))).toBeUndefined() }) + it('an AgentLoop unload aborts pending setup, awaits cleanup, and releases both ids', async () => { + const { ctx, loopFiber } = await harnessWithLoop() + const gate = Promise.withResolvers() + const setupStarted = Promise.withResolvers() + const published: string[] = [] + ctx.on('session/created', () => void published.push('session/created')) + ctx.on('agent/created', () => void published.push('agent/created')) + + const creating = ctx.agents.create({ + agentId: AgentId('factory-setup-race'), + sessionId: SessionId('factory-setup-race-s'), + agentOptions: { model: 'mock' }, + setup: async () => { + setupStarted.resolve(undefined) + await gate.promise + }, + }) + await setupStarted.promise + + await loopFiber.dispose() + await expect(creating).rejects.toThrow(/owner disposed during setup/) + expect(published).toEqual([]) + expect(ctx.agents.get(AgentId('factory-setup-race'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('factory-setup-race-s'))).toBeUndefined() + + // Factory unload itself reached the reservation-release boundary. + const agentReservation = ctx.agents.reserve(AgentId('factory-setup-race')) + const sessionReservation = ctx.sessions.reserve(SessionId('factory-setup-race-s')) + sessionReservation.release() + agentReservation.release() + gate.resolve(undefined) + await ctx.fiber.dispose() + }) + + it('factory unload during scope minting skips setup and awaits provisional cleanup', async () => { + const { ctx, loopFiber } = await harnessWithLoop() + let unloaded = false + let setupCalls = 0 + ctx.on('internal/plugin', (fiber) => { + if (unloaded || fiber.name !== 'scope') return + unloaded = true + void loopFiber.dispose() + }) + + const creating = ctx.agents.create({ + agentId: AgentId('factory-scope-race'), + sessionId: SessionId('factory-scope-race-s'), + agentOptions: { model: 'mock' }, + setup: () => { setupCalls += 1 }, + }) + await expect(creating).rejects.toThrow(/owner disposed during setup/) + await loopFiber.dispose() + expect(setupCalls).toBe(0) + expect(ctx.agents.get(AgentId('factory-scope-race'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('factory-scope-race-s'))).toBeUndefined() + + const agentReservation = ctx.agents.reserve(AgentId('factory-scope-race')) + const sessionReservation = ctx.sessions.reserve(SessionId('factory-scope-race-s')) + sessionReservation.release() + agentReservation.release() + await ctx.fiber.dispose() + }) + + it('caller unload during scope minting owns and drains the half-built child', async () => { + const ctx = await harness() + const gate = Promise.withResolvers() + const cleanupStarted = Promise.withResolvers() + let ownerFiber!: Fiber + let ownerDisposal!: Promise + let scopeFiber: Fiber | undefined + let creating!: ReturnType + ctx.on('internal/plugin', (fiber) => { + if (fiber.name !== 'scope' || scopeFiber !== undefined) return + scopeFiber = fiber + fiber.ctx.effect(() => async () => { + cleanupStarted.resolve(undefined) + await gate.promise + }) + ownerDisposal = ownerFiber.dispose() + }) + + const owner = ctx.plugin(Object.assign((inner: Context) => { + ownerFiber = inner.fiber + creating = inner.agents.create({ + agentId: AgentId('caller-scope-race'), + sessionId: SessionId('caller-scope-race-s'), + agentOptions: { model: 'mock' }, + }) + }, { inject: ['agents'] })) + + await cleanupStarted.promise + let ownerSettled = false + void ownerDisposal.then(() => { ownerSettled = true }) + await Promise.resolve() + expect(ownerSettled).toBe(false) + gate.resolve(undefined) + await expect(creating).rejects.toThrow(/owner disposed during setup/) + await ownerDisposal + await owner + expect(scopeFiber?.uid).toBeNull() + expect(ctx.agents.get(AgentId('caller-scope-race'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('caller-scope-race-s'))).toBeUndefined() + await owner.dispose() + await ctx.fiber.dispose() + }) + + it('synchronous create rechecks provider liveness before its first publication edge', async () => { + const { ctx, loopFiber } = await harnessWithLoop() + const sessionsBefore = ctx.sessions.list().length + let unloaded = false + ctx.on('internal/plugin', (fiber) => { + if (unloaded || fiber.name !== 'scope') return + unloaded = true + void loopFiber.dispose() + }) + + expect(() => ctx.agentLoop.create(AgentId('config-scope-race'), { model: 'mock' })) + .toThrow(/owner disposed during setup/) + await loopFiber.dispose() + expect(ctx.agents.get(AgentId('config-scope-race'))).toBeUndefined() + expect(ctx.sessions.list()).toHaveLength(sessionsBefore) + await ctx.fiber.dispose() + }) + + it('synchronous create releases both reservations when session preparation fails', async () => { + const ctx = await harness() + const id = AgentId('config-prepare-failure') + + expect(() => ctx.agentLoop.create(id, { model: 'mock' }, { cwd: 'relative' })) + .toThrow(/absolute path/) + const replacement = ctx.agentLoop.create(id, { model: 'mock' }, { cwd: '/recovered' }) + expect(ctx.agents.get(id)).toBe(replacement) + await replacement.whenIdle() + await ctx.fiber.dispose() + }) + + it('turns owner disposal from the caller association getter into a rollback boundary', async () => { + const ctx = await harness() + let creating!: ReturnType + let getterCalls = 0 + const creationStarted = Promise.withResolvers() + const owner = ctx.plugin(Object.assign((inner: Context) => { + Object.defineProperty(inner, 'agent', { + configurable: true, + get() { + getterCalls += 1 + void inner.fiber.dispose() + return undefined + }, + }) + creating = inner.agents.create({ + agentId: AgentId('association-dispose'), + sessionId: SessionId('association-dispose-s'), + agentOptions: { model: 'mock' }, + }) + creationStarted.resolve(undefined) + }, { inject: ['agents'] })) + + await creationStarted.promise + await expect(creating).rejects.toThrow(/owner disposed during setup/) + await owner + expect(getterCalls).toBe(1) + expect(ctx.agents.get(AgentId('association-dispose'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('association-dispose-s'))).toBeUndefined() + const replacement = await ctx.agents.create({ + agentId: AgentId('association-dispose'), + sessionId: SessionId('association-dispose-s'), + agentOptions: { model: 'mock' }, + }) + await replacement.dispose() + await ctx.fiber.dispose() + }) + + it('factory unload awaits reservations when reentrant scope preparation throws', async () => { + const { ctx, loopFiber } = await harnessWithLoop() + let triggered = false + ctx.on('internal/plugin', (fiber) => { + if (triggered || fiber.name !== 'scope') return + triggered = true + void loopFiber.dispose() + throw new Error('scope preparation failed') + }) + + await expect(ctx.agents.create({ + agentId: AgentId('factory-scope-throw'), + sessionId: SessionId('factory-scope-throw-s'), + agentOptions: { model: 'mock' }, + })).rejects.toThrow('scope preparation failed') + await loopFiber.dispose() + expect(ctx.agents.get(AgentId('factory-scope-throw'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('factory-scope-throw-s'))).toBeUndefined() + + const agentReservation = ctx.agents.reserve(AgentId('factory-scope-throw')) + const sessionReservation = ctx.sessions.reserve(SessionId('factory-scope-throw-s')) + sessionReservation.release() + agentReservation.release() + await ctx.fiber.dispose() + }) + + it('factory unload during session preparation awaits create reservation release', async () => { + const { ctx, loopFiber } = await harnessWithLoop() + let unloading!: Promise + const meta = { + get cwd() { + unloading = loopFiber.dispose() + return '/factory-unload' + }, + } + + const creating = ctx.agents.create({ + agentId: AgentId('factory-prepare-race'), + sessionId: SessionId('factory-prepare-race-s'), + agentOptions: { model: 'mock' }, + meta, + }) + await unloading + await expect(creating).rejects.toThrow('agent loop is not active') + + const agentReservation = ctx.agents.reserve(AgentId('factory-prepare-race')) + const sessionReservation = ctx.sessions.reserve(SessionId('factory-prepare-race-s')) + sessionReservation.release() + agentReservation.release() + await ctx.fiber.dispose() + }) + + it('AgentLoop unload is a structural co-owner of every live programmatic agent', async () => { + const { ctx, loopFiber } = await harnessWithLoop() + const loop = ctx.agentLoop + const agentId = AgentId('factory-live') + const handle = await ctx.agents.create({ + agentId, + sessionId: SessionId('factory-live-s'), + agentOptions: { model: 'mock' }, + }) + + await loopFiber.dispose() + expect(handle.agent.status).toBe('disposed') + expect(ctx.agents.get(agentId)).toBeUndefined() + expect(ctx.sessions.get(SessionId('factory-live-s'))).toBeUndefined() + expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.ownerLifecycle(${agentId})`)).toEqual([]) + // The consumer handle shares the provider's completed quiescence boundary. + await handle.dispose() + + const agentReservation = ctx.agents.reserve(agentId) + const sessionReservation = ctx.sessions.reserve(SessionId('factory-live-s')) + sessionReservation.release() + agentReservation.release() + await expect(loop.createAgent(ctx, { + agentId: AgentId('factory-inactive'), + sessionId: SessionId('factory-inactive-s'), + })).rejects.toThrow('agent loop is not active') + await ctx.fiber.dispose() + }) + + it('keeps AgentLoop dependencies available when the caller injects only agents', async () => { + const ctx = await harness() + let creating!: ReturnType + const owner = await ctx.plugin(Object.assign((inner: Context) => { + creating = inner.agents.create({ + agentId: AgentId('dependency-origin'), + sessionId: SessionId('dependency-origin-s'), + agentOptions: { model: 'mock' }, + setup: (agentCtx) => { + agentCtx.tools.register({ + name: 'dependency-origin-tool', + description: 'proves AgentLoop dependency origin', + parameters: {}, + execute: () => Promise.resolve(text('ok')), + }) + agentCtx.systemPrompt.section({ + name: 'dependency-origin-section', + order: 1, + text: 'factory dependency surface', + }) + }, + }) + }, { inject: ['agents'] })) + + const handle = await creating + const assembly = await ctx.systemPrompt.assemble(assembleContextFor(handle.agent)) + expect(assembly.tools.map(tool => tool.name)).toContain('dependency-origin-tool') + expect(assembly.sections.map(section => section.name)).toContain('dependency-origin-section') + await handle.dispose() + await owner.dispose() + await ctx.fiber.dispose() + }) + + it('keeps both entries and the scope live through a reentrant session/created teardown', async () => { + const ctx = await harness() + let ownerCtx!: Context + let creating!: ReturnType + const lifecycle: string[] = [] + ctx.on('session/created', (session) => { + if (session.id !== SessionId('session-created-barrier-s')) return + lifecycle.push('session-created:dispose') + disposeCurrentLifecycle(ownerCtx) + }) + ctx.on('session/created', (session) => { + if (session.id !== SessionId('session-created-barrier-s')) return + const agent = ctx.agents.get(AgentId('session-created-barrier'))! + expect(ctx.sessions.get(session.id)).toBe(session) + expect(agent.session).toBe(session) + agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') }) + lifecycle.push('session-created:observer') + }) + ctx.on('agent/created', () => void lifecycle.push('agent-created')) + ctx.on('agent/disposed', () => void lifecycle.push('agent-disposed')) + ctx.on('session/disposed', (session) => { + if (session.id === SessionId('session-created-barrier-s')) lifecycle.push('session-disposed') + }) + + const owner = await ctx.plugin(Object.assign((inner: Context) => { + ownerCtx = inner + creating = inner.agents.create({ + agentId: AgentId('session-created-barrier'), + sessionId: SessionId('session-created-barrier-s'), + agentOptions: { model: 'mock' }, + }) + }, { inject: ['agents'] })) + + await expect(creating).rejects.toThrow(/owner disposed during setup/) + await owner.dispose() + expect(lifecycle).toEqual([ + 'session-created:dispose', + 'session-created:observer', + 'session-disposed', + 'scope-disposed', + ]) + expect(ctx.agents.get(AgentId('session-created-barrier'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('session-created-barrier-s'))).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('keeps both entries and the scope live through a reentrant agent/created teardown', async () => { + const ctx = await harness() + let ownerCtx!: Context + let creating!: ReturnType + const lifecycle: string[] = [] + ctx.on('session/created', (session) => { + if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-created') + }) + ctx.on('agent/created', (agent) => { + if (agent.id !== AgentId('agent-created-barrier')) return + lifecycle.push('agent-created:dispose') + disposeCurrentLifecycle(ownerCtx) + }) + ctx.on('agent/created', (agent) => { + if (agent.id !== AgentId('agent-created-barrier')) return + expect(ctx.agents.get(agent.id)).toBe(agent) + expect(ctx.sessions.get(agent.session.id)).toBe(agent.session) + agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') }) + lifecycle.push('agent-created:observer') + }) + ctx.on('agent/disposed', (agent) => { + if (agent.id === AgentId('agent-created-barrier')) lifecycle.push('agent-disposed') + }) + ctx.on('session/disposed', (session) => { + if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-disposed') + }) + + const owner = await ctx.plugin(Object.assign((inner: Context) => { + ownerCtx = inner + creating = inner.agents.create({ + agentId: AgentId('agent-created-barrier'), + sessionId: SessionId('agent-created-barrier-s'), + agentOptions: { model: 'mock' }, + }) + }, { inject: ['agents'] })) + + await expect(creating).rejects.toThrow(/owner disposed during setup/) + await owner.dispose() + expect(lifecycle).toEqual([ + 'session-created', + 'agent-created:dispose', + 'agent-created:observer', + 'agent-disposed', + 'session-disposed', + 'scope-disposed', + ]) + expect(ctx.agents.get(AgentId('agent-created-barrier'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('agent-created-barrier-s'))).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('rechecks owner liveness after carrier capture before the first creation edge', async () => { + const ctx = await harness() + let ownerCtx!: Context + const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner }, { inject: ['agents'] })) + const agentId = AgentId('carrier-owner-race') + const sessionId = SessionId('carrier-owner-race-s') + const lifecycle: string[] = [] + let filterReads = 0 + ctx.on('session/created', (session) => { + if (session.id === sessionId) lifecycle.push('session-created') + }) + ctx.on('session/disposed', (session) => { + if (session.id === sessionId) lifecycle.push('session-disposed') + }) + ctx.on('agent/created', (agent) => { + if (agent.id === agentId) lifecycle.push('agent-created') + }) + ctx.on('agent/disposed', (agent) => { + if (agent.id === agentId) lifecycle.push('agent-disposed') + }) + + const creating = ownerCtx.agents.create({ + agentId, + sessionId, + agentOptions: { model: 'mock' }, + setup(agentCtx) { + Object.defineProperty(agentCtx.agent!.session, Context.filter, { + configurable: true, + get() { + filterReads += 1 + void owner.dispose() + return undefined + }, + }) + }, + }) + + await expect(creating).rejects.toThrow(/owner disposed during setup/) + await owner.dispose() + expect(filterReads).toBe(1) + expect(lifecycle).toEqual([]) + expect(ctx.agents.get(agentId)).toBeUndefined() + expect(ctx.sessions.get(sessionId)).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('rechecks caller liveness after creation listeners before unlocking the driver', async () => { + const ctx = await harness() + const starts: string[] = [] + let ownerCtx!: Context + let creating!: ReturnType + ctx.on('agent/session-start', agent => void starts.push(agent.id)) + ctx.on('agent/created', (agent) => { + if (agent.id === AgentId('listener-dispose')) void ownerCtx.fiber.dispose() + }) + + const owner = await ctx.plugin(Object.assign((inner: Context) => { + ownerCtx = inner + creating = inner.agents.create({ + agentId: AgentId('listener-dispose'), + sessionId: SessionId('listener-dispose-s'), + agentOptions: { model: 'mock' }, + }) + }, { inject: ['agents'] })) + + await expect(creating).rejects.toThrow(/owner disposed during setup/) + await owner.dispose() + expect(starts).toEqual([]) + expect(ctx.agents.get(AgentId('listener-dispose'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('listener-dispose-s'))).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('rechecks caller liveness after session-start before starting the driver', async () => { + const ctx = await harness() + let ownerCtx!: Context + let creating!: ReturnType + let announced!: ReactLoopAgent + const statuses: string[] = [] + let scopeDisposed = false + let observerSawLive = false + ctx.on('agent/status', (agent, status) => { + if (agent.id === AgentId('session-start-dispose')) statuses.push(status) + }) + ctx.on('agent/session-start', (agent) => { + if (agent.id !== AgentId('session-start-dispose')) return + announced = agent as ReactLoopAgent + disposeCurrentLifecycle(ownerCtx) + }) + ctx.on('agent/session-start', (agent) => { + if (agent.id !== AgentId('session-start-dispose')) return + expect(ctx.agents.get(agent.id)).toBe(agent) + expect(ctx.sessions.get(agent.session.id)).toBe(agent.session) + agent.ctx.effect(() => () => { scopeDisposed = true }) + observerSawLive = true + }) + + const owner = await ctx.plugin(Object.assign((inner: Context) => { + ownerCtx = inner + creating = inner.agents.create({ + agentId: AgentId('session-start-dispose'), + sessionId: SessionId('session-start-dispose-s'), + agentOptions: { model: 'mock' }, + }) + }, { inject: ['agents'] })) + + await expect(creating).rejects.toThrow(/owner disposed during setup/) + await owner.dispose() + expect(announced.status).toBe('disposed') + expect(statuses).toEqual(['disposed']) + expect(observerSawLive).toBe(true) + expect(scopeDisposed).toBe(true) + expect(announced.session.events).toEqual([]) + expect(ctx.agents.get(AgentId('session-start-dispose'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('session-start-dispose-s'))).toBeUndefined() + await ctx.fiber.dispose() + }) + it('a rejecting setup publishes nothing and unwinds the unpublished scope', async () => { const ctx = await harness() const published: string[] = [] @@ -527,6 +1044,89 @@ describe('agent scope lifecycle', () => { await unload }) + it('successful handle disposal retires its caller ownership sentinel', async () => { + const ctx = await harness() + const agentId = AgentId('retired-owner-sentinel') + const handle = await ctx.agents.create({ + agentId, + sessionId: SessionId('retired-owner-sentinel-s'), + agentOptions: { model: 'mock' }, + }) + + expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.ownerLifecycle(${agentId})`) + await handle.dispose() + expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.ownerLifecycle(${agentId})`)).toEqual([]) + await ctx.fiber.dispose() + }) + + it('owner unload after handle-first teardown follows the same in-flight boundary', async () => { + const ctx = await harness() + const gate = Promise.withResolvers() + const cleanupStarted = Promise.withResolvers() + let handle!: Awaited> + const owner = await ctx.plugin(Object.assign(async (inner: Context) => { + handle = await inner.agents.create({ + agentId: AgentId('manual-first'), + sessionId: SessionId('manual-first-s'), + agentOptions: { model: 'mock' }, + setup(agentCtx) { + agentCtx.effect(() => async () => { + cleanupStarted.resolve(undefined) + await gate.promise + }) + }, + }) + }, { inject: ['agents'] })) + + const disposing = handle.dispose() + await cleanupStarted.promise + let ownerSettled = false + const unloading = owner.dispose().then(() => { ownerSettled = true }) + await Promise.resolve() + expect(ownerSettled).toBe(false) + gate.resolve(undefined) + await Promise.all([disposing, unloading]) + expect(ctx.agents.get(AgentId('manual-first'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('manual-first-s'))).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('retains both identity reservations until scope teardown reaches quiescence', async () => { + const ctx = await harness() + const gate = Promise.withResolvers() + const cleanupStarted = Promise.withResolvers() + const sessionDisposed = Promise.withResolvers() + const agentId = AgentId('quiescent-reservation') + const sessionId = SessionId('quiescent-reservation-s') + ctx.on('session/disposed', (session) => { + if (session.id === sessionId) sessionDisposed.resolve(undefined) + }) + const first = await ctx.agents.create({ + agentId, + sessionId, + agentOptions: { model: 'mock' }, + setup(agentCtx) { + agentCtx.effect(() => async () => { + cleanupStarted.resolve(undefined) + await gate.promise + }) + }, + }) + + const disposing = first.dispose() + await Promise.all([sessionDisposed.promise, cleanupStarted.promise]) + expect(ctx.agents.get(agentId)).toBeUndefined() + expect(ctx.sessions.get(sessionId)).toBeUndefined() + await expect(ctx.agents.create({ agentId, sessionId, agentOptions: { model: 'mock' } })) + .rejects.toThrow(/reserved/) + + gate.resolve(undefined) + await disposing + const replacement = await ctx.agents.create({ agentId, sessionId, agentOptions: { model: 'mock' } }) + await replacement.dispose() + await ctx.fiber.dispose() + }) + it('handle.dispose() awaits an idle-injection flush before unregistering or detaching', async () => { const ctx = await harness() const handle = await ctx.agents.create({ diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 1b1e65a289..84a3877298 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -8,28 +8,28 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i ### Public API -The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher every agent-subject event goes through (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while registry/store-owned reservation capabilities keep both identities unpublished; creation awaits setup and a same-turn owner-unload checkpoint before either creation notification or the first assembly. Setup composes, it never drives or publishes: driving verbs and ordinary agent/session insertion both reject until the owning publication boundary. +The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair deliberately reuses the stable carrier captured before entry commit and applies the same per-listener containment directly. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while registry/store-owned reservation capabilities keep both identities unpublished; creation awaits setup and a same-turn owner-unload checkpoint before either creation notification or the first assembly. Setup composes, it never drives or publishes: driving verbs and ordinary agent/session insertion both reject until the owning publication boundary. - `ctx.agents.register(agent: Agent): () => Promise | void` — record an **already-constructed** agent. Disposed with the calling fiber. -- Advanced ordered lifecycle: `reserve(id)` returns an opaque unpublished-identity capability owned by the calling fiber (owner unload releases an abandoned reservation); `enter(agent, reservation?): () => void` inserts under one captured, runtime-pinned id without announcing; and `announce(agent)` emits `agent/created` exactly once for that exact live entry, rejecting repeat or reentrant announcement. While reserved, bare `register`/`enter` calls for the id reject, including from setup. The factory uses this split; ordinary plugins use `register()`. +- Advanced ordered lifecycle: `reserve(id)` returns an opaque unpublished-identity capability whose `release` is the exact owner effect disposer, allowing the factory to place ID release after scope quiescence instead of racing owner unload as a sibling. `enter(agent, reservation?): () => void` claims the ID across runtime pinning and stable lifecycle-carrier construction, then inserts without announcing; a Proxy trap or filter getter cannot reentrantly overwrite the commit. `announce(agent)` reuses that carrier and emits `agent/created` exactly once for the exact live entry, rejecting repeat or reentrant announcement. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach is exact-object guarded, so a later listener cannot observe inverted lifecycle edges and a stale capability cannot delete a replacement. While reserved, bare `register`/`enter` calls for the id reject, including from setup. The factory uses this split; ordinary plugins use `register()`. - `ctx.agents.get(id: AgentId): Agent | undefined` - `ctx.agents.list(): Agent[]` #### Factory seam (creation) -Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. +Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. The registry canonicalizes an already traced Service to its concrete target, captures and validates the factory's `createAgent` and `resume` callbacks once at registration, retains that target as their intentional receiver, and passes each call an explicit caller-bound `ownerCtx`; later method replacement cannot redirect a transaction, double tracing cannot break raw-identity state, and a plain non-Cordis factory receives enough context to implement caller ownership. - `ctx.agents.setFactory(factory: AgentFactory): () => Promise | void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose. -- `ctx.agents.create(options: CreateAgentOptions): Promise` — snapshot caller-owned IDs/options/metadata and hand the one-read raw seed synchronously to the session boundary for one-pass lossless-JSON materialization, construct and await optional setup while unpublished, insert and announce both session and agent, open the `agent/session-start` driving boundary, then start a new loop on the caller-supplied `sessionId`. Registry/store reservation capabilities block every competing public insertion across setup; seed rejection, setup rejection, or owner unload publishes nothing. Publication is rollback-covered: if a creation listener throws, entries and scope unwind but effects of already-delivered notifications remain observable; any creation announcement that began is paired by `agent/disposed` or `session/disposed`. Rejects if no factory is registered. -- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — snapshot caller-owned IDs/options, load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh agent scope, await optional setup while unpublished, then follow the same insert → announce → session-start → loop-start boundary. The IDs are reserved across persistence load and setup; load/setup rejection or owner unload publishes nothing. Rejects if no factory is registered or session persistence is unconfigured. +- `ctx.agents.create(options: CreateAgentOptions): Promise` — snapshot caller-owned IDs/options/metadata and hand the one-read raw seed synchronously to the session boundary for one-pass lossless-JSON materialization, construct and await optional setup while unpublished, insert both session and agent, then recheck caller and factory liveness before the first creation announcement and after each later notification boundary. Only a still-live transaction opens `agent/session-start` and starts a new loop on the caller-supplied `sessionId`. Registry/store reservation capabilities block every competing public insertion across setup; seed rejection, setup rejection, caller unload, factory unload, or cancellation from a creation listener publishes no drivable agent. Publication is rollback-covered: if a creation listener throws, entries and scope unwind but effects of already-delivered notifications remain observable; any creation announcement that began is paired by `agent/disposed` or `session/disposed`. Rejects if no factory is registered. +- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — snapshot caller-owned IDs/options, load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh agent scope, await optional setup while unpublished, then follow the same insert both → pre-announcement liveness check → session announcement → liveness check → agent announcement → liveness check → session-start → final liveness check → loop-start boundary. The IDs are reserved across persistence load, setup, and teardown quiescence; load/setup rejection, caller unload, or factory unload leaves no drivable or live publication, while any creation edge that already began is paired during rollback. Rejects if no factory is registered or session persistence is unconfigured. -`AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit plus every outstanding idle-injection flush (quiescence — NOT just the `disposed` status flip), unregisters the agent, removes its session from the store, and finally unwinds its scoped world. This order captures every agent-started `session/flush` before the session is detached and keeps scoped listeners alive through those checkpoints. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge and in-process subagent backends are production consumers; config-created agents are owned by the loop fiber and never need a handle. +`AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **consumer capability** — no observer holding the bare registry entry can tear the agent down. The caller fiber and the registered factory provider are structural co-owners: caller unload enforces structured ownership, while factory unload must stop old instances because their scoped dependency surface belongs to that provider. `dispose()` from any owner reaches one memoized quiescence boundary: it stops the loop, `await`s its exit plus every outstanding idle-injection flush (not just the `disposed` status flip), unregisters the agent, removes its session from the store, and finally unwinds its scoped world. This order captures every agent-started `session/flush` before the session is detached and keeps scoped listeners alive through those checkpoints. `ctx.agents.get(id)` still returns a bare `Agent`; the ACP bridge and in-process subagent backends hold consumer handles, while config-created agents are already owned by the loop fiber. ### Live events `dsh-agent` declares the live `agent/*` coordination vocabulary so plugins do not depend on the concrete loop. Exact signatures, dispatch modes, scope-filtering rules, and payload contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the [architecture turn flow](../../../docs/architecture.md#turn-flow) shows their order relative to durable session events. -The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist, but concrete driving remains locked until the immediately following `agent/session-start`; that non-vetoing notification is the first supported startup injection point. `agent/disposed` runs after the driver is quiescent and the agent leaves the registry, while ordered teardown may still be detaching its session and unwinding its scope. +The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist, but concrete driving remains locked until the immediately following `agent/session-start`; that non-vetoing notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves. Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` is a serial surface-mutation checkpoint, while `agent/turn-stop` is the owner-final exception: it runs after ordinary continuation and steering folding, and its terminal state remains through turn close and flush so steering from those later listeners cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale is in [the agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#owner-final-policy-boundaries). diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts index 881faa675f..f8726c50cc 100644 --- a/packages/core/agent/src/dispatch.ts +++ b/packages/core/agent/src/dispatch.ts @@ -1,12 +1,13 @@ /** - * Fused scope-carrier dispatch for agent-subject events, plus the assembly - * context builder. The ONE sanctioned spelling for dispatching `agent/*` - * events: `agentEvents(ctx, agent).waterfall('agent/request', …)` builds the - * scope carrier ({@link scopeTarget} keyed by the agent) AND injects the - * subject as the first event argument in one move, so the correct dispatch is - * also the shortest — a dispatch site cannot pass a carrier keyed to one - * agent while naming another as the subject, which is the invariant the - * dev-mode scoped-dispatch check asserts at runtime. + * Fused scope-carrier dispatch for agent-subject operations, plus the assembly + * context builder. The sanctioned ordinary spelling is + * `agentEvents(ctx, agent).waterfall('agent/request', …)`: it builds the scope + * carrier ({@link scopeTarget} keyed by the agent) AND injects the subject as + * the first argument in one move, so a site cannot name a different subject. + * The registry lifecycle pair is the deliberate exception: `enter()` captures + * one stable carrier before commit and `announce()`/detach dispatch through it + * directly, preventing a mutable filter getter from changing or reentering the + * paired edges. The dev scoped-dispatch invariant checks both shapes. * * @module @deepseek-ai/dsh-agent/dispatch */ diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index be7bb02c67..5526006796 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -5,11 +5,11 @@ * @module @deepseek-ai/dsh-agent */ -import { Context, Service } from 'cordis' +import { Context, getTraceable, Service, symbols } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' +import type { Scoped } from '@deepseek-ai/dsh-scope' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type { Agent, AgentId, AgentOptions } from './types.ts' -import { agentEvents } from './dispatch.ts' export * from './types.ts' export { agentEvents, assembleContextFor } from './dispatch.ts' @@ -112,17 +112,21 @@ export interface ResumeAgentOptions { /** * An owned agent plus its disposer, returned by {@link AgentRegistry.create} / - * {@link AgentRegistry.resume}. The disposer is a CAPABILITY: only the holder - * can tear this agent down. `dispose()` stops the loop, awaits its exit and - * every outstanding idle-injection flush (quiescence — NOT just the `disposed` + * {@link AgentRegistry.resume}. The disposer is a CAPABILITY: among consumers, + * only the holder can tear this agent down. The registered factory provider is + * also a structural owner because the scoped agent depends on that provider's + * service surface; provider unload stops and drains every live handle it made. + * `dispose()` stops the loop, awaits its exit and every outstanding + * idle-injection flush (quiescence — NOT just the `disposed` * status flip), unregisters the agent, removes its session from the store, and * finally unwinds its scoped world. This order captures every agent-started * `session/flush` before the session is detached and keeps scoped listeners * alive through those checkpoints. * - * `ctx.agents.get(id)` still returns a bare {@link Agent} — the handle is only - * for the OWNER that created it. Config-created agents (the loop's own startup) - * are owned by the loop fiber and never need a handle. + * `ctx.agents.get(id)` still returns a bare {@link Agent} — the handle is + * exposed only to the consumer owner that created it; the structural provider + * reaches the same teardown internally. Config-created agents (the loop's own + * startup) are owned by the loop fiber and never need a handle. */ export interface AgentHandle { agent: Agent @@ -146,20 +150,62 @@ export interface AgentFactory { * that began is paired by `agent/disposed` or `session/disposed` during * rollback. The owner disposes the resolved handle to stop/drain, * unregister, remove the session, and unwind the scope. + * The registry passes a context carrying the `create()` caller's fiber and + * scope as `ownerCtx`. The implementation attaches the unpublished + * transaction and resulting lifecycle to that owner; it must not infer + * ownership from the factory object's registration context. + * @param ownerCtx - caller-bound context that owns the transaction and live handle. * @param options - agent/session identity, configuration, and optional setup. * @returns the owned handle after setup, both announcements, and loop start complete. */ - createAgent(options: CreateAgentOptions): Promise + createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise /** * Load a persisted session and resume an agent on it. Async because it awaits * both `ctx.sessionPersistence.load` and the optional unpublished setup * transaction; must be called after that service exists (consumers inject * `sessionPersistence`). Publication and drive unlocking follow the same * ordered boundary as {@link createAgent}. + * @param ownerCtx - caller-bound context that owns load, setup, and the live handle. * @param options - persisted identity, configuration, and optional setup. * @returns the owned handle after setup, both announcements, and loop start complete. */ - resume(options: ResumeAgentOptions): Promise + resume(ownerCtx: Context, options: ResumeAgentOptions): Promise +} + +/** One accepted factory target plus the callback identities captured at registration. */ +interface AcceptedAgentFactory { + target: AgentFactory + createAgent: AgentFactory['createAgent'] + resume: AgentFactory['resume'] +} + +/** Slot reservation while callback accessors are being captured. */ +const ACCEPTING_FACTORY = Symbol('accepting agent factory') + +/** Capture and validate the complete factory contract exactly once. */ +function acceptAgentFactory(factory: unknown): AcceptedAgentFactory { + if ((typeof factory !== 'object' && typeof factory !== 'function') || factory === null) { + throw new TypeError('agent factory must be a non-null object or function') + } + // A service read through ctx is already a Cordis trace proxy. Retaining that + // proxy and tracing it again for each create() caller produces two shadow + // layers; raw-identity state (AgentLoop's private ownership controller is + // one example) then unwraps only to the inner proxy instead of its service. + // Canonicalize the one framework-produced layer at acceptance and capture + // callbacks from the concrete target. Plain objects expose no original. + const original: unknown = Reflect.get(factory, symbols.original) + const target = ((typeof original === 'object' || typeof original === 'function') && original !== null) + ? original + : factory + const createAgent: unknown = Reflect.get(target, 'createAgent') + const resume: unknown = Reflect.get(target, 'resume') + if (typeof createAgent !== 'function') throw new TypeError('agent factory createAgent must be a function') + if (typeof resume !== 'function') throw new TypeError('agent factory resume must be a function') + return Object.freeze({ + target: target as AgentFactory, + createAgent: createAgent as AgentFactory['createAgent'], + resume: resume as AgentFactory['resume'], + }) } /** Thrown when create/resume is called before an agent factory is registered. */ @@ -187,6 +233,8 @@ export interface AgentRegistrationReservation { /** * Release the unpublished reservation; idempotent. The registry also * releases it automatically when the fiber that called `reserve` disposes. + * This function is that exact Cordis effect disposer, so an ordered + * lifecycle may yield it by identity and place release after quiescence. * @returns nothing. */ release(): void @@ -201,13 +249,21 @@ export interface AgentRegistrationReservation { */ export class AgentRegistry extends Service { private store = new Map() + /** Ids claimed across caller-code boundaries before their exact entry commits. */ + private enteringIds = new Set() /** The one accepted registry key for each live agent; never reread caller state. */ private acceptedIds = new WeakMap() /** Unpublished identities held across factory setup/load transactions. */ private reservations = new Map() /** Entries whose `agent/created` announcement phase began. */ private announced = new WeakSet() - private factory: AgentFactory | undefined + /** Entries currently dispatching `agent/created`; detach waits for that dispatch to unwind. */ + private announcing = new WeakSet() + /** A detach requested reentrantly from `agent/created`. */ + private pendingDetach = new WeakSet() + /** Stable lifecycle dispatch carrier captured before an entry commits. */ + private carriers = new WeakMap>() + private factory: AcceptedAgentFactory | typeof ACCEPTING_FACTORY | undefined constructor(ctx: Context) { super(ctx, 'agents') @@ -234,39 +290,27 @@ export class AgentRegistry extends Service { */ reserve(id: AgentId): AgentRegistrationReservation { if (typeof id !== 'string') throw new TypeError('agent id must be a string') - if (this.store.has(id) || this.reservations.has(id)) { + if (this.store.has(id) || this.reservations.has(id) || this.enteringIds.has(id)) { throw new Error(`agent "${id}" is already registered or reserved`) } - let active = true const rawRelease = (): void => { - if (!active) return - active = false this.reservations.delete(id) } - let disposeEffect!: () => Promise | void - const reservation: AgentRegistrationReservation = Object.freeze({ - id, - release: () => { - rawRelease() - // Remove the now-inert ownership effect on manual transaction settle; - // its cleanup is the exact idempotent raw release above. - void disposeEffect() - }, - }) + // `release` is the exact effect disposer. A composite lifecycle can yield + // it by identity, moving automatic owner cleanup from a racing sibling to + // the transaction's final ordered position. + const release = this.ctx.effect(() => rawRelease, `agents.reserve(${id})`) + const reservation: AgentRegistrationReservation = Object.freeze({ id, release }) this.reservations.set(id, reservation) - try { - disposeEffect = this.ctx.effect(() => rawRelease, `agents.reserve(${id})`) - } catch (error: unknown) { - rawRelease() - throw error - } return reservation } /** * Register the agent-creation factory (the loop calls this on construction, - * effect-scoped). Throws if a factory is already registered. Returns the - * disposer; on dispose the factory slot is cleared. + * effect-scoped). The registry captures both callback identities once and + * later invokes them against the retained target receiver. Throws if a + * factory is already registered. Returns the disposer; on dispose the + * factory slot is cleared. * @param factory - the loop-owned factory {@link create}/{@link resume} delegate to. * @returns the disposer that clears the factory slot. The exact * Cordis effect disposer (single-shot): composite (generator) effects may @@ -275,7 +319,17 @@ export class AgentRegistry extends Service { setFactory(factory: AgentFactory): () => Promise | void { const dispose = this.ctx.effect(() => { if (this.factory !== undefined) throw new Error('an agent factory is already registered') - this.factory = factory + // Claim the slot before reading caller-controlled method accessors. A + // getter may synchronously re-enter setFactory(); it must observe the + // registration in progress instead of installing a nested factory that + // the outer call would silently overwrite. + this.factory = ACCEPTING_FACTORY + try { + this.factory = acceptAgentFactory(factory) + } catch (error: unknown) { + this.factory = undefined + throw error + } return () => { this.factory = undefined } }, 'agents.setFactory()') // The exact cordis effect disposer (the agents.register() convention): a @@ -285,6 +339,13 @@ export class AgentRegistry extends Service { return dispose } + /** Return the accepted factory, excluding absence and reentrant acceptance. */ + private requireFactory(): AcceptedAgentFactory { + const accepted = this.factory + if (accepted === undefined || accepted === ACCEPTING_FACTORY) throw new Error(NO_FACTORY_MESSAGE) + return accepted + } + /** * Create and publish a new agent through the registered factory. * Distinct from {@link register} (which records an already-constructed @@ -295,8 +356,14 @@ export class AgentRegistry extends Service { * @returns the handle after setup, rollback-covered publication, and loop start complete. */ async create(options: CreateAgentOptions): Promise { - if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE) - return this.factory.createAgent(options) + const accepted = this.requireFactory() + const ownerCtx = this.ctx + // Re-trace a Service-backed factory through the accessing context + // explicitly. This preserves AgentLoop's dependency origin while binding + // its effects to ownerCtx; plain factories receive ownerCtx as an explicit + // capability and need no Cordis tracker magic. + const receiver = getTraceable(ownerCtx, accepted.target) + return Reflect.apply(accepted.createAgent, receiver, [ownerCtx, options]) } /** @@ -307,8 +374,10 @@ export class AgentRegistry extends Service { * @returns the handle after setup, rollback-covered publication, and loop start complete. */ async resume(options: ResumeAgentOptions): Promise { - if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE) - return this.factory.resume(options) + const accepted = this.requireFactory() + const ownerCtx = this.ctx + const receiver = getTraceable(ownerCtx, accepted.target) + return Reflect.apply(accepted.resume, receiver, [ownerCtx, options]) } /** @@ -347,7 +416,9 @@ export class AgentRegistry extends Service { * @param reservation - the exact unpublished-id capability, when a factory * reserved this id across setup. * @returns an idempotent closure that removes this exact entry and emits - * `agent/disposed` with listener failures contained. + * `agent/disposed` with listener failures contained. When called from a + * synchronous `agent/created` listener, removal and disposal wait until + * that creation dispatch unwinds. */ enter(agent: Agent, reservation?: AgentRegistrationReservation): () => void { const id = agent.id @@ -361,39 +432,108 @@ export class AgentRegistry extends Service { if (this.acceptedIds.has(agent)) { throw new Error(`agent "${id}" is already registered`) } - if (this.store.has(id)) { + if (this.store.has(id) || this.enteringIds.has(id)) { throw new Error(`agent "${id}" is already registered`) } + this.enteringIds.add(id) + let carrier: Scoped try { // Registration accepts ownership of the public identity contract. Pin an // own data slot from the one captured value so a custom JavaScript Agent // with a getter or writable field cannot later present a different id to // event listeners while the registry still owns the accepted key. - Object.defineProperty(agent, 'id', { - value: id, - enumerable: true, - writable: false, - configurable: false, - }) - } catch { - // Only the engine's property-definition failure is swallowed; the stable - // public error below is the registration contract exposed to callers. - throw new TypeError('agent id must be installable as a stable own property') + try { + Object.defineProperty(agent, 'id', { + value: id, + enumerable: true, + writable: false, + configurable: false, + }) + } catch { + // Only the engine's property-definition failure is normalized; filter + // construction below retains its own precise failure. + throw new TypeError('agent id must be installable as a stable own property') + } + // Capture one carrier for the paired lifecycle edges. Constructing it + // reads a custom Agent's Context.filter and is therefore caller code; + // the id claim above makes a same-id reentrant enter lose deterministically. + carrier = scopeTarget(agent, agent) + } finally { + // Kept through the entire caller-code window; the final commit below is + // synchronous and callback-free. + this.enteringIds.delete(id) + } + const currentReservation = this.reservations.get(id) + if (reservation === undefined) { + /* v8 ignore next 2 -- reserve() rejects enteringIds, so no callback in + * carrier construction can install a new same-id reservation */ + if (currentReservation !== undefined) { + throw new Error(`agent "${id}" is reserved for unpublished creation`) + } + } else if (currentReservation !== reservation) { + throw new Error(`agent "${id}" registration reservation is not active for this id`) + } + /* v8 ignore next 2 -- the enteringIds claim blocks every public same-id + * commit until this callback-free final check has completed */ + if (this.acceptedIds.has(agent) || this.store.has(id)) { + throw new Error(`agent "${id}" is already registered`) } this.store.set(id, agent) this.acceptedIds.set(agent, id) + this.carriers.set(agent, carrier) let entered = true - return () => { + const detach = (): void => { if (!entered) return entered = false - this.store.delete(id) - this.acceptedIds.delete(agent) - // An insertion rolled back before announce was never externally created, - // so emitting disposed would invent an impossible lifecycle edge. Marking - // happens before the created emit: if a later created listener throws, - // earlier listeners may already have observed it and must see disposal. - if (!this.announced.delete(agent)) return - agentEvents(this.ctx, agent).emit('agent/disposed') + // Every callback reached by this creation dispatch must observe the same + // live entry, and disposal must follow creation. A listener may own + // the advanced detach capability, so make that ordering structural: + // visibility and the paired disposal are deferred until announce()'s + // synchronous dispatch has unwound. + if (this.announcing.has(agent)) { + this.pendingDetach.add(agent) + return + } + this.detachEntered(agent, id) + } + return detach + } + + /** Remove one exact entered agent and emit its paired disposal when announced. */ + private detachEntered(agent: Agent, id: AgentId): void { + this.pendingDetach.delete(agent) + // A stale capability can never delete a later same-id lifecycle. The + // commit claim prevents this mismatch in normal operation; retain the + // exact-object guard as the final identity boundary. + /* v8 ignore next 1 -- the commit claim makes replacement impossible; this + * remains the exact-identity backstop against future mutation paths */ + if (this.store.get(id) !== agent || this.acceptedIds.get(agent) !== id) return + this.store.delete(id) + this.acceptedIds.delete(agent) + const carrier = this.carriers.get(agent) + this.carriers.delete(agent) + // An insertion rolled back before announce was never externally created, + // so emitting disposed would invent an impossible lifecycle edge. Marking + // happens before the created emit: if a later created listener throws, + // earlier listeners may already have observed it and must see disposal. + if (!this.announced.delete(agent)) return + /* v8 ignore next -- enter commits the carrier with the exact store entry */ + if (carrier === undefined) throw new Error(`agent "${id}" has no dispatch carrier`) + this.emitDisposed(agent, carrier, id) + } + + /** Emit the paired disposal edge through the entry's stable carrier. */ + private emitDisposed(agent: Agent, carrier: Scoped, id: AgentId): void { + const args: unknown[] = [carrier, 'agent/disposed', agent] + for (const callback of this.ctx.events.dispatch('emit', args)) { + try { + const returned: unknown = callback(...args) + void Promise.resolve(returned).catch((error: unknown) => { + this.ctx.logger.warn(`agent "${id}": agent/disposed listener rejected: ${renderThrown(error)}`) + }) + } catch (error: unknown) { + this.ctx.logger.warn(`agent "${id}": agent/disposed listener threw: ${renderThrown(error)}`) + } } } @@ -409,21 +549,30 @@ export class AgentRegistry extends Service { if (id === undefined || this.store.get(id) !== agent) { throw new Error(`agent "${id ?? ''}" is not live in this registry`) } - if (this.announced.has(agent)) { + if (this.announced.has(agent) || this.announcing.has(agent)) { throw new Error(`agent "${id}" was already announced`) } + const carrier = this.carriers.get(agent) + /* v8 ignore next -- enter commits the carrier with the exact store entry */ + if (carrier === undefined) throw new Error(`agent "${id}" has no dispatch carrier`) // Mark before dispatch so a listener cannot recursively create a second // lifecycle edge; detach still pairs a partially delivered first edge. + this.announcing.add(agent) this.announced.add(agent) - const args: unknown[] = [scopeTarget(agent, agent), 'agent/created', agent] - for (const callback of this.ctx.events.dispatch('emit', args)) { - // A synchronous creation failure vetoes publication and rolls back. - // Returned-promise rejection happens after this synchronous boundary, so - // observe and report it instead of leaking an unhandled rejection. - const returned: unknown = callback(...args) - void Promise.resolve(returned).catch((error: unknown) => { - this.ctx.logger.warn(`agent "${id}": agent/created listener rejected: ${renderThrown(error)}`) - }) + const args: unknown[] = [carrier, 'agent/created', agent] + try { + for (const callback of this.ctx.events.dispatch('emit', args)) { + // A synchronous creation failure vetoes publication and rolls back. + // Returned-promise rejection happens after this synchronous boundary, so + // observe and report it instead of leaking an unhandled rejection. + const returned: unknown = callback(...args) + void Promise.resolve(returned).catch((error: unknown) => { + this.ctx.logger.warn(`agent "${id}": agent/created listener rejected: ${renderThrown(error)}`) + }) + } + } finally { + this.announcing.delete(agent) + if (this.pendingDetach.has(agent)) this.detachEntered(agent, id) } } diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index b6a32fa050..0e914888b9 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -291,7 +291,11 @@ declare module 'cordis' { * to inject or queue work during startup. A synchronous listener throw * vetoes publication and rollback emits the matching disposal edges; * returned-promise rejection is observed and logged but cannot - * retroactively veto this synchronous boundary. + * 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. * @param agent - the newly registered agent with its live session and completed setup. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered * through `agent.ctx` fires only for that agent's dispatches; a listener on a @@ -302,11 +306,12 @@ declare module 'cordis' { */ 'agent/created'(this: Scoped, agent: Agent): void /** - * An agent was removed from the registry after its driver and any in-flight - * turn reached quiescence. Ordered teardown may still be detaching the - * session and unwinding the agent's scoped registrations when this - * notification runs. - * @param agent - the deregistered agent; its driving handle is now inert. + * 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. + * @param agent - the exact agent removed from the registry. * 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 @@ -348,11 +353,12 @@ declare module 'cordis' { /** * The agent's session lifecycle began, fired once before its first turn. * `source` says why ({@link SessionStartSource}: fresh startup, a resumed - * persisted session, …). A pure NOTIFICATION (emit, not waterfall): it - * carries no veto — a session-start listener that wants to seed context does - * so via `agent.inject()` (a `context/message` the first request sees), not - * by returning a decision. Cannot block the session from starting; that gap - * is deliberate (a bridge logs/injects, it does not gate startup). + * 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. * @param agent - the agent whose session lifecycle began. * @param source - why the session started (fresh startup, resume, …). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 87a0e51e9d..ab3202a297 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context, Service, symbols } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Agent, AgentId, agentEvents } from '@deepseek-ai/dsh-agent' +import type { AgentFactory, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' function stubAgent(rawId: string): Agent { const id = AgentId(rawId) @@ -178,6 +179,96 @@ describe('AgentRegistry', () => { expect(() => ctx.agents.enter(pinnedAccessor)).toThrow(/installable as a stable own property/) }) + it('claims an id across a Proxy defineProperty trap before committing the exact entry', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const id = AgentId('reentrant-enter') + const nested = stubAgent(id) + let nestedError = '' + let attempted = false + const target = stubAgent(id) + const outer = new Proxy(target, { + defineProperty(inner, property, descriptor) { + if (property === 'id' && !attempted) { + attempted = true + try { + ctx.agents.enter(nested) + } catch (error: unknown) { + nestedError = String(error) + } + } + return Reflect.defineProperty(inner, property, descriptor) + }, + }) + + const detach = ctx.agents.enter(outer) + expect(nestedError).toMatch(/already registered/) + expect(ctx.agents.get(id)).toBe(outer) + detach() + expect(ctx.agents.get(id)).toBeUndefined() + }) + + it('captures one lifecycle carrier before commit so a filter getter cannot invert edges', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const events: string[] = [] + const agent = stubAgent('reentrant-carrier') + let detach = (): void => {} + Object.defineProperty(agent, Context.filter, { + configurable: true, + get() { + events.push('filter-getter') + detach() + return undefined + }, + }) + detach = ctx.agents.enter(agent) + ctx.on('agent/created', () => { events.push('created') }) + ctx.on('agent/disposed', () => { events.push('disposed') }) + + ctx.agents.announce(agent) + expect(events).toEqual(['filter-getter', 'created']) + expect(ctx.agents.get(agent.id)).toBe(agent) + detach() + expect(events).toEqual(['filter-getter', 'created', 'disposed']) + expect(ctx.agents.get(agent.id)).toBeUndefined() + }) + + it('revalidates an exact reservation after carrier construction runs caller code', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const reservation = ctx.agents.reserve(AgentId('released-during-enter')) + const agent = stubAgent('released-during-enter') + Object.defineProperty(agent, Context.filter, { + configurable: true, + get() { + reservation.release() + return undefined + }, + }) + + expect(() => ctx.agents.enter(agent, reservation)).toThrow(/reservation is not active/) + expect(ctx.agents.get(agent.id)).toBeUndefined() + }) + + it('observes an async agent/disposed rejection through the stable carrier', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + ctx.on('agent/disposed', () => Promise.reject(new Error('late disposal failure')) as never) + const agent = stubAgent('async-disposed') + const detach = ctx.agents.enter(agent) + ctx.agents.announce(agent) + + detach() + await Promise.resolve() + await Promise.resolve() + expect(warnings).toEqual([ + 'agent "async-disposed": agent/disposed listener rejected: Error: late disposal failure', + ]) + }) + it('uses an opaque one-id reservation to gate unpublished factory insertion', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) @@ -251,6 +342,34 @@ describe('AgentRegistry', () => { detach() expect({ created, disposed }).toEqual({ created: 1, disposed: 1 }) }) + + it('defers a reentrant detach until the creation dispatch unwinds', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const order: string[] = [] + const agent = stubAgent('reentrant-detach') + const detach = ctx.agents.enter(agent) + + ctx.on('agent/created', (created) => { + order.push('created:first') + detach() + expect(ctx.agents.get(created.id)).toBe(created) + }) + ctx.on('agent/created', (created) => { + order.push('created:second') + expect(ctx.agents.get(created.id)).toBe(created) + }) + ctx.on('agent/disposed', (disposed) => { + order.push('disposed') + expect(ctx.agents.get(disposed.id)).toBeUndefined() + }) + + ctx.agents.announce(agent) + + expect(order).toEqual(['created:first', 'created:second', 'disposed']) + expect(ctx.agents.get(agent.id)).toBeUndefined() + detach() + }) }) describe('agentEvents()', () => { @@ -281,14 +400,17 @@ describe('agentEvents()', () => { describe('AgentRegistry factory seam', () => { /** A stub AgentFactory that records calls and returns a stub agent. */ function stubFactory() { - const calls: { create: unknown[]; resume: unknown[] } = { create: [], resume: [] } - const factory: import('@deepseek-ai/dsh-agent').AgentFactory = { - async createAgent(options) { - calls.create.push(options) + const calls: { + create: Array<{ ownerCtx: Context; options: CreateAgentOptions }> + resume: Array<{ ownerCtx: Context; options: ResumeAgentOptions }> + } = { create: [], resume: [] } + const factory: AgentFactory = { + async createAgent(ownerCtx, options) { + calls.create.push({ ownerCtx, options }) return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() } }, - resume(options) { - calls.resume.push(options) + resume(ownerCtx, options) { + calls.resume.push({ ownerCtx, options }) return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }) }, } @@ -310,11 +432,151 @@ describe('AgentRegistry factory seam', () => { const created = await ctx.agents.create({ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } }) expect(created.agent.id).toBe('c1') - expect(calls.create).toEqual([{ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } }]) + expect(calls.create).toHaveLength(1) + expect(calls.create[0]!.ownerCtx.fiber).toBe(ctx.fiber) + expect(calls.create[0]!.options) + .toEqual({ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } }) const resumed = await ctx.agents.resume({ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') }) expect(resumed.agent.id).toBe('r1') - expect(calls.resume).toEqual([{ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') }]) + expect(calls.resume).toHaveLength(1) + expect(calls.resume[0]!.ownerCtx.fiber).toBe(ctx.fiber) + expect(calls.resume[0]!.options).toEqual({ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') }) + }) + + it('passes the calling fiber to a plain factory for create and resume ownership', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const { factory, calls } = stubFactory() + ctx.agents.setFactory(factory) + let callerFiber: Context['fiber'] | undefined + + const owner = await ctx.plugin(Object.assign(async (inner: Context) => { + callerFiber = inner.fiber + await inner.agents.create({ agentId: AgentId('owned-create'), sessionId: SessionId('owned-session') }) + await inner.agents.resume({ agentId: AgentId('owned-resume'), resumeSessionId: SessionId('persisted') }) + }, { inject: ['agents'] })) + + expect(calls.create[0]!.ownerCtx.fiber).toBe(callerFiber) + expect(calls.resume[0]!.ownerCtx.fiber).toBe(callerFiber) + await owner.dispose() + }) + + it('captures factory callbacks once while retaining the intentional target receiver', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const reads = { create: 0, resume: 0 } + const receivers: unknown[] = [] + const replacements: string[] = [] + const target = { label: 'accepted-target' } as { label: string } & AgentFactory + + Object.defineProperties(target, { + createAgent: { + configurable: true, + get() { + reads.create += 1 + return function (this: typeof target, _ownerCtx: Context, options: CreateAgentOptions) { + receivers.push(this) + return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }) + } + }, + }, + resume: { + configurable: true, + get() { + reads.resume += 1 + return function (this: typeof target, _ownerCtx: Context, options: ResumeAgentOptions) { + receivers.push(this) + return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }) + } + }, + }, + }) + + ctx.agents.setFactory(target) + Object.defineProperties(target, { + createAgent: { + value: () => { + replacements.push('create') + return Promise.resolve({ agent: stubAgent('replacement'), dispose: () => Promise.resolve() }) + }, + }, + resume: { + value: () => { + replacements.push('resume') + return Promise.resolve({ agent: stubAgent('replacement'), dispose: () => Promise.resolve() }) + }, + }, + }) + + await ctx.agents.create({ agentId: AgentId('captured-create'), sessionId: SessionId('captured-session') }) + await ctx.agents.resume({ agentId: AgentId('captured-resume'), resumeSessionId: SessionId('captured-persisted') }) + + expect(reads).toEqual({ create: 1, resume: 1 }) + expect(receivers).toEqual([target, target]) + expect(replacements).toEqual([]) + }) + + it('reserves the factory slot before reading reentrant callback accessors', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const nested = stubFactory().factory + const reads: string[] = [] + const reentrantCreate: Promise[] = [] + const target = {} as AgentFactory + + Object.defineProperties(target, { + createAgent: { + get() { + reads.push('createAgent') + expect(() => ctx.agents.setFactory(nested)).toThrow(/already registered/) + reentrantCreate.push(ctx.agents.create({ + agentId: AgentId('during-acceptance'), + sessionId: SessionId('during-acceptance-session'), + })) + return (_ownerCtx: Context, options: CreateAgentOptions) => Promise.resolve({ + agent: stubAgent(options.agentId), + dispose: () => Promise.resolve(), + }) + }, + }, + resume: { + get() { + reads.push('resume') + return (_ownerCtx: Context, options: ResumeAgentOptions) => Promise.resolve({ + agent: stubAgent(options.agentId), + dispose: () => Promise.resolve(), + }) + }, + }, + }) + + ctx.agents.setFactory(target) + expect(reentrantCreate).toHaveLength(1) + await expect(Promise.all(reentrantCreate)).rejects.toThrow(/no agent factory/) + await expect(ctx.agents.create({ + agentId: AgentId('after-acceptance'), + sessionId: SessionId('after-acceptance-session'), + })).resolves.toMatchObject({ agent: { id: 'after-acceptance' } }) + expect(reads).toEqual(['createAgent', 'resume']) + }) + + it('validates the complete factory shape when accepting it', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + + expect(() => ctx.agents.setFactory(null as unknown as AgentFactory)).toThrow(/non-null object or function/) + expect(() => ctx.agents.setFactory(42 as unknown as AgentFactory)).toThrow(/non-null object or function/) + expect(() => ctx.agents.setFactory({ resume() { return Promise.resolve() } } as unknown as AgentFactory)) + .toThrow(/createAgent must be a function/) + expect(() => ctx.agents.setFactory({ createAgent() { return Promise.resolve() } } as unknown as AgentFactory)) + .toThrow(/resume must be a function/) + + const callable = Object.assign(() => undefined, stubFactory().factory) + const dispose = ctx.agents.setFactory(callable) + await expect(ctx.agents.create({ agentId: AgentId('callable'), sessionId: SessionId('callable-session') })) + .resolves.toBeDefined() + await dispose() }) it('setFactory rejects a second factory', async () => { @@ -337,4 +599,81 @@ describe('AgentRegistry factory seam', () => { // factory slot cleared → create throws again await expect(ctx.agents.create({ agentId: AgentId('a2'), sessionId: SessionId('s2') })).rejects.toThrow(/no agent factory/) }) + + it('canonicalizes an already traced Service factory before caller retracing', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const states = new WeakMap() + class TracedFactory extends Service implements AgentFactory { + constructor(inner: Context) { + super(inner, 'tracedFactory') + states.set(this, []) + } + + private calls(): string[] { + const original = (this as unknown as { [symbols.original]?: TracedFactory })[symbols.original] ?? this + const calls = states.get(original) + if (calls === undefined) throw new Error('factory receiver did not canonicalize to the raw service') + return calls + } + + createAgent(_ownerCtx: Context, options: CreateAgentOptions) { + this.calls().push('create') + return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }) + } + + resume(_ownerCtx: Context, options: ResumeAgentOptions) { + this.calls().push('resume') + return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }) + } + } + await ctx.plugin(TracedFactory) + const traced = (ctx as Context & { tracedFactory: TracedFactory }).tracedFactory + ctx.agents.setFactory(traced) + + await ctx.agents.create({ agentId: AgentId('traced-create'), sessionId: SessionId('traced-session') }) + await ctx.agents.resume({ agentId: AgentId('traced-resume'), resumeSessionId: SessionId('traced-persisted') }) + const raw = (traced as unknown as { [symbols.original]?: TracedFactory })[symbols.original] + expect(states.get(raw!)).toEqual(['create', 'resume']) + }) + + it('rolls back register and factory acceptance when their owner unloads reentrantly', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + let ownerCtx!: Context + const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner }, { inject: ['agents'] })) + const agent = stubAgent('register-unload-race') + ctx.on('agent/created', (created) => { + if (created === agent) void owner.dispose() + }) + + ownerCtx.agents.register(agent) + await owner.dispose() + expect(ctx.agents.get(agent.id)).toBeUndefined() + + let factoryOwnerCtx!: Context + const factoryOwner = await ctx.plugin(Object.assign((inner: Context) => { factoryOwnerCtx = inner }, { inject: ['agents'] })) + const target = {} as AgentFactory + Object.defineProperties(target, { + createAgent: { + get() { + void factoryOwner.dispose() + return (_inner: Context, options: CreateAgentOptions) => Promise.resolve({ + agent: stubAgent(options.agentId), + dispose: () => Promise.resolve(), + }) + }, + }, + resume: { + value: (_inner: Context, options: ResumeAgentOptions) => Promise.resolve({ + agent: stubAgent(options.agentId), + dispose: () => Promise.resolve(), + }), + }, + }) + factoryOwnerCtx.agents.setFactory(target) + await factoryOwner.dispose() + await expect(ctx.agents.create({ agentId: AgentId('after-owner'), sessionId: SessionId('after-owner-s') })) + .rejects.toThrow(/no agent factory/) + }) }) diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md index 727acea4fb..1813ff5071 100644 --- a/packages/core/scope/README.md +++ b/packages/core/scope/README.md @@ -9,7 +9,7 @@ Scoped-context registration primitive. `createScope(ctx, key)` mints a Cordis co - `Scope.rawDispose` The EXACT Cordis disposer for the backing fiber — a composite (generator) effect yields THIS function to nest the scope's teardown at that yield position (Cordis dedupes nested effects by function identity; yielding a wrapper leaves the scope disposing as a concurrent sibling). - `Scope.dispose(): Promise` Idempotent, shared quiescence boundary for every registration made through the scope. Racing/repeat calls await the same teardown, including when `rawDispose` invoked the underlying single-shot Cordis disposer first. - `scopeOf(ctx: Context): ScopeKey | undefined` The tag a context (or any context derived from it) carries; `undefined` = context-global. -- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped` Build the dispatch `thisArg` for a scope-filtered event: capture and compose `base`'s own `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). The carrier uses a dedicated surrogate proxy target whose immutable filter slot cannot be replaced by a base property pinned before, during, or after construction; ordinary property access, writes, own-key visibility, methods, invocation, and construction delegate to `base`, and callable carriers match the base's constructable/non-constructable shape. For non-overlay base-owned properties, descriptor queries preserve values and flags except that configurable is normalized to `true`, as required to report those properties through an extensible surrogate. Listener `this` stays `base`-shaped. `{ global: true }` listeners bypass filtering (Cordis semantics). +- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped` Build the dispatch `thisArg` for a scope-filtered event: capture and compose `base`'s own `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). The captured base filter and the exposed composed filter are invoked through captured JavaScript primordials, and the composed filter's frozen invocation surface cannot be replaced or tampered with. The carrier uses a dedicated surrogate proxy target; ordinary property access, writes, own-key visibility, methods, invocation, and construction delegate to `base`, and callable carriers match the base's constructable/non-constructable shape. For non-overlay base-owned properties, descriptor queries preserve values and flags except that configurable is normalized to `true`, as required to report those properties through an extensible surrogate; defining through the carrier is therefore supported only with an explicit `configurable: true` descriptor, while an omitted or false flag is rejected before the base is touched. Listener `this` stays `base`-shaped. `{ global: true }` listeners bypass filtering (Cordis semantics). - `Scoped` The compile-time carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. - `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name. - `scopeHost(ctx, services)` Test/tooling host that snapshots the requested service list before activation, fails loud with stable missing-service diagnostics, and whose shared `dispose()` waits for both the host fiber and every minted scope, including a child already tearing down through `rawDispose`. diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index 9c917fc89d..158d6d37d8 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -25,6 +25,14 @@ import type { Context, Fiber } from 'cordis' import { Context as CordisContext } from 'cordis' +// Capture the invocation primordials once. A carrier holder can reach the +// composed Context.filter function, so neither that function's mutable +// property surface nor a base filter's own `.call` may choose how isolation +// predicates are invoked. +const reflectApply = Reflect.apply +// eslint-disable-next-line @typescript-eslint/unbound-method +const functionCall = Function.prototype.call + /** * The identity a scope is keyed by. Opaque and compared by object identity — * never inspected. The harness convention: a live `Agent` is the key of its @@ -194,8 +202,11 @@ function isConstructable(value: (...args: unknown[]) => unknown): boolean { * - its tag IS `key` (a scoped listener seeing exactly its own subject), * * AND `base`'s own filter (a Cordis `Service`'s isolation check) also admits - * it. Dispatching with `key === undefined` — a subject-less dispatch, e.g. a - * tool call with no calling agent or a bare (agent-less) session's events — + * it. Both the captured base filter and the composed filter are invoked + * through captured JavaScript primordials, so mutating either function's + * public `.call` property cannot bypass either predicate. Dispatching with + * `key === undefined` — a subject-less dispatch, e.g. a tool call with no + * calling agent or a bare (agent-less) session's events — * admits only untagged listeners: a scoped listener never fires for someone * else's (or nobody's) subject. Listeners registered `{ global: true }` * bypass all filtering (Cordis semantics). @@ -211,7 +222,11 @@ function isConstructable(value: (...args: unknown[]) => unknown): boolean { * subject always travels in the event's arguments. The returned carrier is * branded {@link Scoped} and runtime-marked ({@link isScopeCarrier} / * {@link carrierKeyOf}) so both the type system and the dev invariants can - * tell a carrier from a bare subject. + * tell a carrier from a bare subject. Defining an ordinary property through + * the carrier is supported only when its descriptor explicitly says + * `configurable: true`; an omitted or false flag is rejected before touching + * `base`, because the extensible surrogate cannot truthfully report a new + * non-configurable base property. * @param base - the object the event is dispatched on behalf of (the owning * service, or the subject agent itself); its own `Context.filter` is * preserved and composed. @@ -225,10 +240,19 @@ export function scopeTarget(base: T, key: ScopeKey | undefined throw new TypeError('scope target Context.filter must be a function when present') } const filter = (ctx: Context): boolean => { - if (baseFilter && !baseFilter.call(base, ctx)) return false + if (baseFilter && !reflectApply(functionCall, baseFilter, [base, ctx])) return false const tag = scopeOf(ctx) return tag === undefined || tag === key } + // Cordis invokes a dispatch filter as `filter.call(thisArg, listenerCtx)`. + // Pin that property to the captured primordial, then freeze the callable so + // a carrier holder cannot replace it with an always-true scope bypass. + Object.defineProperty(filter, 'call', { + value: functionCall, + writable: false, + configurable: false, + }) + Object.freeze(filter) const overlay: Record = { [CordisContext.filter]: filter, [kCarrier]: Object.freeze({ key }), @@ -317,7 +341,7 @@ export function scopeTarget(base: T, key: ScopeKey | undefined return undefined }, defineProperty(_target, prop, attributes) { - if (Object.hasOwn(overlay, prop)) return false + if (Object.hasOwn(overlay, prop) || attributes.configurable !== true) return false return Reflect.defineProperty(base, prop, attributes) }, deleteProperty(_target, prop) { diff --git a/packages/core/scope/tests/scope.spec.ts b/packages/core/scope/tests/scope.spec.ts index 5b379d15e5..7242aa942d 100644 --- a/packages/core/scope/tests/scope.spec.ts +++ b/packages/core/scope/tests/scope.spec.ts @@ -189,10 +189,54 @@ describe('scopeTarget dispatch filtering', () => { ctx.emit(scopeTarget(vetoBase, keyA), 'scope-test/ping', 'vetoed') expect(heard).toEqual([]) - // A base whose filter accepts delegates to the scope predicate. - const openBase = { [Context.filter]: () => true } + // A base whose filter accepts delegates to the scope predicate, with the + // real base preserved as its `this` receiver. + let baseReceiverWasOpen = false + const openBase = { + [Context.filter](this: object): boolean { + baseReceiverWasOpen = this === openBase + return true + }, + } ctx.emit(scopeTarget(openBase, keyA), 'scope-test/ping', 'open') expect(heard).toEqual(['global:open', 'A:open']) + expect(baseReceiverWasOpen).toBe(true) + + // A function's public `.call` property is not its invocation semantics. + // An always-true replacement must not override the base predicate's veto. + const tamperedVeto = (): boolean => false + Object.defineProperty(tamperedVeto, 'call', { value: () => true }) + ctx.emit(scopeTarget({ [Context.filter]: tamperedVeto }, keyA), 'scope-test/ping', 'tampered-veto') + expect(heard).toEqual(['global:open', 'A:open']) + }) + + it('pins the exposed composed filter invocation so a carrier holder cannot bypass isolation', async () => { + const ctx = new Context() + const keyA = { name: 'A' } + const keyB = { name: 'B' } + const scopeA = await mintScope(ctx, keyA) + const scopeB = await mintScope(ctx, keyB) + const heard: string[] = [] + ctx.on('scope-test/ping', value => void heard.push(`global:${value}`)) + scopeA.ctx.on('scope-test/ping', value => void heard.push(`A:${value}`)) + scopeB.ctx.on('scope-test/ping', value => void heard.push(`B:${value}`)) + + const carrier = scopeTarget(ctx, keyA) + const exposedFilter: unknown = Reflect.get(carrier, Context.filter) + expect(typeof exposedFilter).toBe('function') + const filter = exposedFilter as ((ctx: Context) => boolean) & { call: (...args: unknown[]) => unknown } + const primordialCall: unknown = Reflect.get(Function.prototype, 'call') + expect(Object.getOwnPropertyDescriptor(filter, 'call')).toMatchObject({ + value: primordialCall, + writable: false, + configurable: false, + }) + expect(Object.isFrozen(filter)).toBe(true) + expect(Reflect.set(filter, 'call', () => true)).toBe(false) + expect(Reflect.defineProperty(filter, 'call', { value: () => true })).toBe(false) + + ctx.emit(carrier, 'scope-test/ping', 'still-A-only') + expect(heard).toEqual(['global:still-A-only', 'A:still-A-only']) }) it('keeps listener `this` base-shaped through the carrier (waterfall)', async () => { @@ -256,6 +300,14 @@ describe('scopeTarget dispatch filtering', () => { Object.defineProperty(carrier, 'extra', { value: 1, configurable: true }) expect((base as typeof base & { extra?: number }).extra).toBe(1) expect(delete (carrier as typeof carrier & { extra?: number }).extra).toBe(true) + + // A non-configurable property cannot be reflected truthfully through the + // extensible surrogate. Reject before mutating the delegated base; an + // omitted `configurable` has JavaScript's false default and is rejected too. + expect(Reflect.defineProperty(carrier, 'sealed', { value: 1, configurable: false })).toBe(false) + expect(Object.hasOwn(base, 'sealed')).toBe(false) + expect(Reflect.defineProperty(carrier, 'default-sealed', { value: 2 })).toBe(false) + expect(Object.hasOwn(base, 'default-sealed')).toBe(false) expect(Reflect.preventExtensions(carrier)).toBe(false) expect(Reflect.setPrototypeOf(carrier, null)).toBe(false) }) diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 29f89e35c8..7389d6fade 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -19,9 +19,9 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall `create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before the store-owned append observer detaches — `create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect: - `ctx.sessions.prepare(id?, options?): Session` — read `options.seed`/`options.meta` once, validate and detach the metadata/header, and construct the `Session` WITHOUT entering it into the store. Same options as `create`. -- `ctx.sessions.reserve(id): SessionRegistrationReservation` — hold an unpublished id under the calling fiber and construct its one owned Session through `reservation.prepare(options?)`. Until `release()` or owner unload, bare `prepare`/`create`/`enter` calls for that id reject; the factory later presents the exact capability to `enter`, making setup-time publication structurally impossible without leaking an abandoned reservation across HMR disposal. -- `ctx.sessions.enter(session, reservation?): () => void` — install the module-private `session/event` observer, capture its scope carrier, and add the session under one accepted id; returns the idempotent DETACH disposer, which clears notification, carrier, and accepted-key state. Does NOT emit `session/created` (the caller installs the disposer first, then calls `announce`, so a throwing listener rolls the attach back). It re-checks the id because public `prepare`/`enter` calls may be interleaved; a stale prepared object must not overwrite a live same-id session. A factory passes the opaque capability from `reserve(id)` so setup cannot enter the reserved session or publish a same-id replacement before the owning transaction. -- `ctx.sessions.announce(session): void` — begin the one allowed `session/created` announcement for an entered session; repeat and reentrant calls reject before dispatch. Its detach emits `session/disposed` exactly once, including rollback after a partially delivered creation notification; a never-announced entry emits neither edge. +- `ctx.sessions.reserve(id): SessionRegistrationReservation` — hold an unpublished id under the calling fiber and construct its one owned Session through `reservation.prepare(options?)`. `release` is the exact owner effect disposer, so the agent lifecycle can adopt it and keep the ID reserved until scope cleanup quiesces. Until that release, bare `prepare`/`create`/`enter` calls for the id reject; the factory later presents the exact capability to `enter`, making setup-time publication structurally impossible without leaking an abandoned reservation across HMR disposal. +- `ctx.sessions.enter(session, reservation?): () => void` — claim the ID across caller-controlled filter/carrier evaluation, then install the module-private `session/event` observer and add the exact session under its accepted key; a reentrant same-ID entry cannot be overwritten. Returns the idempotent, exact-object-guarded DETACH disposer, which clears notification, carrier, and accepted-key state without letting a stale capability delete a replacement. Does NOT emit `session/created` (the caller installs the disposer first, then calls `announce`, so a throwing listener rolls the attach back). It re-checks the id because public `prepare`/`enter` calls may be interleaved. A factory passes the opaque capability from `reserve(id)` so setup cannot enter the reserved session or publish a same-id replacement before the owning transaction. +- `ctx.sessions.announce(session): void` — begin the one allowed `session/created` announcement for an entered session; repeat and reentrant calls reject before dispatch. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, so another creation listener cannot observe `session/disposed` before its own `session/created` callback. Detach emits `session/disposed` exactly once, including rollback after a partially delivered creation notification; a never-announced entry emits neither edge. `dsh-agent-loop` is the canonical consumer: after unpublished agent setup it enters both session and agent before announcing either, then nests loop stop, agent removal, session detach, and scope unwind in one ordered lifecycle. The final flush therefore settles before this package detaches the session, whether teardown starts from an `AgentHandle` or owner-fiber unload. diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index b000e7083b..d8b3afb139 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -37,7 +37,9 @@ declare module 'cordis' { * 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. + * 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 @@ -648,7 +650,9 @@ export interface SessionRegistrationReservation { prepare(options?: CreateSessionOptions): Session /** * Release the unpublished reservation; idempotent. The store also releases - * it automatically when the fiber that called `reserve` disposes. + * it automatically when the fiber that called `reserve` disposes. This + * function is that exact Cordis effect disposer, so an ordered lifecycle may + * yield it by identity and place release after quiescence. * @returns nothing. */ release(): void @@ -662,10 +666,16 @@ export interface SessionRegistrationReservation { */ export class SessionStore extends Service { private store = new Map() + /** Ids claimed across caller-code boundaries before their exact entry commits. */ + private enteringIds = new Set() /** The one accepted map key for each live session; never reread caller state. */ private acceptedIds = new WeakMap() /** Sessions whose creation announcement began and therefore require a pair. */ private announced = new WeakSet() + /** Entries currently dispatching `session/created`; detach waits for dispatch to unwind. */ + private announcing = new WeakSet() + /** A detach requested reentrantly from `session/created`. */ + private pendingDetach = new WeakSet() /** Unpublished identities held across factory load/setup transactions. */ private reservations = new Map() /** The exact prepared object owned by each reservation capability. */ @@ -696,18 +706,19 @@ export class SessionStore extends Service { */ reserve(id: SessionId): SessionRegistrationReservation { if (typeof id !== 'string') throw new TypeError('session id must be a string') - if (this.store.has(id) || this.reservations.has(id)) { + if (this.store.has(id) || this.reservations.has(id) || this.enteringIds.has(id)) { throw new Error(`session "${id}" already exists or is reserved`) } let active = true let prepared = false const rawRelease = (): void => { - if (!active) return active = false this.reservedSessions.delete(reservation) this.reservations.delete(id) } - let disposeEffect!: () => Promise | void + // `release` is the exact effect disposer, so an ordered composite can + // adopt the automatic owner cleanup instead of racing it as a sibling. + const release = this.ctx.effect(() => rawRelease, `sessions.reserve(${id})`) const reservation: SessionRegistrationReservation = Object.freeze({ id, prepare: (options?: CreateSessionOptions) => { @@ -720,20 +731,9 @@ export class SessionStore extends Service { this.reservedSessions.set(reservation, session) return session }, - release: () => { - rawRelease() - // Remove the now-inert ownership effect on manual transaction settle; - // its cleanup is the exact idempotent raw release above. - void disposeEffect() - }, + release, }) this.reservations.set(id, reservation) - try { - disposeEffect = this.ctx.effect(() => rawRelease, `sessions.reserve(${id})`) - } catch (error: unknown) { - rawRelease() - throw error - } return reservation } @@ -845,7 +845,9 @@ export class SessionStore extends Service { * @param session - a {@link prepare}d session not yet in the store. * @param reservation - the exact unpublished-id capability when a factory * reserved this session across setup. - * @returns the detach disposer (observer + store removal). + * @returns the detach disposer (observer + store removal). When called from + * a synchronous `session/created` listener, removal and disposal wait until + * that creation dispatch unwinds. * @throws if a session with this id is already in the store. */ enter(session: Session, reservation?: SessionRegistrationReservation): () => void { @@ -858,30 +860,71 @@ export class SessionStore extends Service { || this.reservedSessions.get(reservation) !== session) { throw new Error(`session "${id}" registration reservation does not own this prepared session`) } - if (this.store.has(id)) throw new Error(`session "${id}" already exists`) + if (this.store.has(id) || this.enteringIds.has(id)) { + throw new Error(`session "${id}" already exists`) + } if (appendObservers.has(session)) throw new Error(`session "${id}" is already attached to a store`) + this.enteringIds.add(id) // The carrier is decided HERE, once, from the ENTERING context's scope tag // (`this.ctx` is the caller's context — the tracker mechanism): every // session/created|event|flush dispatch for this session uses it, so the // session's whole event feed is scope-filtered consistently. The base is // the session itself (scoped listeners' `this` is the session). - const carrier = scopeTarget(session, scopeOf(this.ctx)) + let carrier: Scoped + try { + carrier = scopeTarget(session, scopeOf(this.ctx)) + } finally { + this.enteringIds.delete(id) + } + const currentReservation = this.reservations.get(id) + if (reservation === undefined) { + /* v8 ignore next 2 -- reserve() rejects enteringIds, so carrier + * construction cannot install a new same-id reservation */ + if (currentReservation !== undefined) { + throw new Error(`session "${id}" is reserved for unpublished creation`) + } + } else if (currentReservation !== reservation + || this.reservedSessions.get(reservation) !== session) { + throw new Error(`session "${id}" registration reservation does not own this prepared session`) + } + /* v8 ignore next 1 -- enteringIds prevents a same-store commit during carrier construction */ + if (this.store.has(id)) throw new Error(`session "${id}" already exists`) + if (appendObservers.has(session)) throw new Error(`session "${id}" is already attached to a store`) this.carriers.set(session, carrier) const emitCtx = this.ctx appendObservers.set(session, (event) => { emitCtx.emit(carrier, 'session/event', session, event) }) this.acceptedIds.set(session, id) this.store.set(id, session) let entered = true - return () => { + const detach = (): void => { if (!entered) return entered = false - const wasAnnounced = this.announced.delete(session) - appendObservers.delete(session) - this.acceptedIds.delete(session) - this.carriers.delete(session) - this.store.delete(id) - if (wasAnnounced) this.emitDisposed(session, carrier, id) + // A creation listener may own the advanced detach capability. Keep the + // entry and its event observer live until the synchronous creation + // dispatch unwinds, then publish the paired disposal edge. + if (this.announcing.has(session)) { + this.pendingDetach.add(session) + return + } + this.detachEntered(session, id, carrier) } + return detach + } + + /** Remove one exact entered session and emit its paired disposal when announced. */ + private detachEntered(session: Session, id: SessionId, carrier: Scoped): void { + this.pendingDetach.delete(session) + // A stale capability cannot remove observers or storage belonging to a + // later same-id lifecycle. + /* v8 ignore next 1 -- the commit claim makes replacement impossible; this + * remains the exact-identity backstop against future mutation paths */ + if (this.store.get(id) !== session || this.acceptedIds.get(session) !== id) return + const wasAnnounced = this.announced.delete(session) + appendObservers.delete(session) + this.acceptedIds.delete(session) + this.carriers.delete(session) + this.store.delete(id) + if (wasAnnounced) this.emitDisposed(session, carrier, id) } /** Emit `session/created` exactly once for an {@link enter}ed session (with @@ -892,25 +935,31 @@ export class SessionStore extends Service { * @throws if the session is not live or its announcement already began, * including a reentrant call from a creation listener. */ announce(session: Session): void { - const carrier = this.liveCarrierFor(session) + const { carrier, id } = this.liveEntryFor(session) if (this.announced.has(session)) { - throw new Error(`session "${session.id}" was already announced`) + throw new Error(`session "${id}" was already announced`) } // Mark before emit: Cordis emit may deliver to earlier listeners and then // throw. Rollback must still pair that partial creation with disposal, and // a listener cannot recursively create a second lifecycle edge. this.announced.add(session) const args: unknown[] = [carrier, 'session/created', session] - for (const callback of this.ctx.events.dispatch('emit', args)) { - // Synchronous throws intentionally propagate and veto publication; the - // yielded detach then emits the paired disposal edge. An async function - // is nevertheless assignable to a void listener, so observe its returned - // promise: rejection is too late to roll back and must be logged instead - // of becoming unhandled. - const returned: unknown = callback(...args) - void Promise.resolve(returned).catch((error: unknown) => { - this.ctx.logger.warn(`session "${session.id}": session/created listener rejected: ${renderThrown(error)}`) - }) + this.announcing.add(session) + try { + for (const callback of this.ctx.events.dispatch('emit', args)) { + // Synchronous throws intentionally propagate and veto publication; the + // yielded detach then emits the paired disposal edge. An async function + // is nevertheless assignable to a void listener, so observe its returned + // promise: rejection is too late to roll back and must be logged instead + // of becoming unhandled. + const returned: unknown = callback(...args) + void Promise.resolve(returned).catch((error: unknown) => { + this.ctx.logger.warn(`session "${id}": session/created listener rejected: ${renderThrown(error)}`) + }) + } + } finally { + this.announcing.delete(session) + if (this.pendingDetach.has(session)) this.detachEntered(session, id, carrier) } } @@ -940,11 +989,11 @@ export class SessionStore extends Service { * @returns resolves when every flush listener has settled; rejects if one rejects. */ async flush(session: Session): Promise { - await this.ctx.parallel(this.liveCarrierFor(session), 'session/flush', session) + await this.ctx.parallel(this.liveEntryFor(session).carrier, 'session/flush', session) } - /** Return the exact live session's carrier; detached/prepared objects reject. */ - private liveCarrierFor(session: Session): Scoped { + /** Return the exact live session's accepted id and carrier; detached/prepared objects reject. */ + private liveEntryFor(session: Session): { id: SessionId; carrier: Scoped } { const id = this.acceptedIds.get(session) if (id === undefined || this.store.get(id) !== session) { throw new Error(`session "${id ?? session.id}" is not live in this store`) @@ -957,7 +1006,7 @@ export class SessionStore extends Service { if (carrier === undefined) { throw new Error(`session "${id}" has no dispatch carrier`) } - return carrier + return { id, carrier } } /** diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index ac0f06123c..6edeac0084 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -740,6 +740,79 @@ describe('SessionStore', () => { expect(ctx.sessions.get(SessionId('racy'))).toBe(live) }) + it('claims an id across Context.filter evaluation before committing the exact session', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const id = SessionId('reentrant-enter') + const nested = new Session(id) + const outer = new Session(id) + let nestedError = '' + let attempted = false + Object.defineProperty(outer, Context.filter, { + configurable: true, + get() { + if (!attempted) { + attempted = true + try { + ctx.sessions.enter(nested) + } catch (error: unknown) { + nestedError = String(error) + } + } + return undefined + }, + }) + + const detach = ctx.sessions.enter(outer) + expect(nestedError).toMatch(/already exists/) + expect(ctx.sessions.get(id)).toBe(outer) + detach() + expect(ctx.sessions.get(id)).toBeUndefined() + }) + + it('revalidates reservation ownership after carrier construction runs caller code', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const id = SessionId('released-during-enter') + const reservation = ctx.sessions.reserve(id) + const session = reservation.prepare() + Object.defineProperty(session, Context.filter, { + configurable: true, + get() { + reservation.release() + return undefined + }, + }) + + expect(() => ctx.sessions.enter(session, reservation)).toThrow(/does not own this prepared session/) + expect(ctx.sessions.get(id)).toBeUndefined() + }) + + it('rejects when carrier construction attaches the same session to another store', async () => { + const firstCtx = new Context() + const secondCtx = new Context() + await firstCtx.plugin(SessionStore) + await secondCtx.plugin(SessionStore) + const session = new Session(SessionId('cross-store-carrier')) + let attempted = false + let detachSecond = (): void => {} + Object.defineProperty(session, Context.filter, { + configurable: true, + get() { + if (!attempted) { + attempted = true + detachSecond = secondCtx.sessions.enter(session) + } + return undefined + }, + }) + + expect(() => firstCtx.sessions.enter(session)).toThrow(/already attached to a store/) + expect(firstCtx.sessions.get(session.id)).toBeUndefined() + expect(secondCtx.sessions.get(session.id)).toBe(session) + detachSecond() + }) + it('prepare() + enter() + announce() register a session and emit session/created', async () => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -869,6 +942,49 @@ describe('SessionStore', () => { expect({ created, disposed }).toEqual({ created: 1, disposed: 1 }) }) + it('defers a reentrant detach until the creation dispatch unwinds', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const order: string[] = [] + const session = ctx.sessions.prepare(SessionId('reentrant-detach')) + const detach = ctx.sessions.enter(session) + + ctx.on('session/created', (created) => { + order.push('created:first') + detach() + expect(ctx.sessions.get(created.id)).toBe(created) + }) + ctx.on('session/created', (created) => { + order.push('created:second') + expect(ctx.sessions.get(created.id)).toBe(created) + }) + ctx.on('session/disposed', (disposed) => { + order.push('disposed') + expect(ctx.sessions.get(disposed.id)).toBeUndefined() + }) + + ctx.sessions.announce(session) + + expect(order).toEqual(['created:first', 'created:second', 'disposed']) + expect(ctx.sessions.get(session.id)).toBeUndefined() + detach() + }) + + it('rolls back create when its owner unloads from session/created', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + let ownerCtx!: Context + const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner }, { inject: ['sessions'] })) + const id = SessionId('create-unload-race') + ctx.on('session/created', (session) => { + if (session.id === id) void owner.dispose() + }) + + ownerCtx.sessions.create(id) + await owner.dispose() + expect(ctx.sessions.get(id)).toBeUndefined() + }) + it('synthesizes a minimal current-version header for a bare-created session', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index e5b40ccfdc..1e812ce60d 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -13,7 +13,7 @@ Runs a child as a child [`Agent`](../../core/agent) on the same cordis context ( 3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts); there is deliberately NO re-prompt for a structured child that finished cleanly without calling `structured_output` — the shortfall maps to an `error` result for the parent; 4. reads the result, scoped to the child's OWN events (everything at or after `seedLength`, so a seeded child that produced no message of its own never returns the seeded parent's last message): the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. A structured run surfaces the captured value as `result.structured`; a structured child that finished cleanly WITHOUT ever capturing settles `error` (a clean finish without the demanded result is a failure, not a success with a missing field). -`SubagentService` waits for `run.started` before emitting `subagent/start`, so a synchronous start observer can resolve the published child with `ctx.agents.get(run.id)`; the result driver awaits the same boundary before sending the prompt. An attempt that never publishes rejects readiness and emits no false start/end pair; its result reports a deliberate cancel/dispose as `aborted` and propagates an infrastructure fault. `dispose()` awaits creation or rollback and then delegates to `AgentHandle.dispose()` (stop and drain → remove agent → detach session → unwind scope). Before readiness, `cancel()` deactivates the unpublished owner so no agent, session, or lifecycle event can escape; after readiness it cancels the live child immediately. Either path records the cancellation, so a cancel landing before any `turn/end` settles `aborted`, honoring the cancel contract rather than the generic no-turn `error`. +`SubagentService` waits for `run.started` before emitting `subagent/start`, so a synchronous start observer can resolve the published child with `ctx.agents.get(run.id)`; the result driver awaits the same boundary before sending the prompt. An attempt that never publishes rejects readiness and emits no false start/end pair; its result reports a deliberate cancel/dispose as `aborted` and propagates an infrastructure fault. `dispose()` awaits creation or rollback and then delegates to `AgentHandle.dispose()` (stop and drain → remove agent → detach session → unwind scope). Before readiness, `cancel()` deactivates the creation owner: before creation notification begins, no agent/session lifecycle edge escapes; if cancellation is triggered synchronously by a creation observer, every begun edge is paired by rollback and the driver never unlocks or starts. After readiness, cancellation reaches the live child immediately. Either path records the cancellation, so a cancel landing before any `turn/end` settles `aborted`, honoring the cancel contract rather than the generic no-turn `error`. ### `InProcessRunOptions` diff --git a/packages/workflow/workflow-workerthread/README.md b/packages/workflow/workflow-workerthread/README.md index 0aba0eb680..1879e60e7e 100644 --- a/packages/workflow/workflow-workerthread/README.md +++ b/packages/workflow/workflow-workerthread/README.md @@ -23,7 +23,7 @@ What the seam guarantees regardless, because benign scripts hit these constantly `start()` shape-validates the meta DATA host-side and parse-checks the body with the identical wrapper the worker compiles (`new vm.Script`, discarded), preserving the seam's synchronous `META_INVALID`/`SCRIPT_PARSE` throws; one redundant parse per run is the deliberate price. It then spawns the worker (unbuilt: a JavaScript data-URL bootstrap registers tsx's ESM and CommonJS transforms inside the worker before importing `src/worker.ts`, giving the whole mixed-module source graph full TypeScript and tsconfig-path transformation on every supported Node line; built: the sibling `lib/worker.js` bundle) with the meta, body, `args`, and worker-side limits as `workerData`. -Inside the worker, `runWorkerSession` builds the execution core (hooks, combinators, concurrency semaphore, caps, fatal-error discipline) over a **child port**. `agent()` sends `child-start`, and the host starts the child on `ctx.subagents` with parent attribution, the shared per-run abort signal, and `outputSchema`/`model` pass-through. +Inside the worker, `runWorkerSession` builds the execution core (hooks, combinators, concurrency semaphore, caps, fatal-error discipline) over a **child port**. `agent()` sends `child-start`, and the host starts the child through the holder-bound `SubagentService` handle captured synchronously by `start()`, with parent attribution, the shared per-run abort signal, and `outputSchema`/`model` pass-through. This capture is part of the seam's holder-owned lifetime: unloading the engine removes `ctx.workflows` for new calls but does not invalidate an already returned run whose worker starts another child afterward. The host observes `run.result` immediately but buffers its snapshotted wire projection until `run.started` fulfills. It then replies `child-started` with the child id before forwarding settlement, so `workflow/agent-start` always names a ready child and precedes its end. A readiness rejection replies `child-start-error`, emits no workflow agent pair, and makes the host dispose the attempt because the worker never received a handle; the worker classifies it as fatal `AGENT_START` unless cancellation already owns the run. If readiness fulfills, an infrastructure result rejection crosses as `child-failed`/`AGENT_RESULT` regardless of whether that rejection settled before or after readiness. Child disposal acknowledgements complete the RPC. diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index 56c0e455f8..8c839fbccb 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -1,9 +1,10 @@ /** * The host half of one worker-engine run: spawn the Worker, bridge its child - * RPC onto `ctx.subagents`, fan its observer messages into the engine's - * events, and own cancellation, the settle-within-grace guarantee, and child - * cleanup. The worker's lifetime IS the run's lifetime: `dispose()` always - * ends with `worker.terminate()`, so no thread outlives its run. + * RPC onto the holder-bound subagent service, fan its observer messages into + * the engine's events, and own cancellation, the settle-within-grace + * guarantee, and child cleanup. The worker's lifetime IS the run's lifetime: + * `dispose()` always ends with `worker.terminate()`, so no thread outlives its + * run. * * The run's `result` promise settles exactly once, from whichever of these * lands first: the worker's `result` message (a host-side cancellation in @@ -47,6 +48,7 @@ import type { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { assertNever } from '@deepseek-ai/dsh-llm' import { snapshotJsonValue } from '@deepseek-ai/dsh-session' +import type SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentRun } from '@deepseek-ai/dsh-subagent' import type { WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowMeta, WorkflowResult, WorkflowRun, WorkflowRunId } from '@deepseek-ai/dsh-workflow' import { renderThrown } from './realm.ts' @@ -121,7 +123,9 @@ function resolveWorkerSpawn(init: WorkerInit): { entry: URL; options: WorkerOpti * `start()` directly. Owns the Worker, the child registry, and the result * settlement; `result` never rejects. `meta` is this handle's OWN clone * (event payloads carry separate clones), so a consumer mutating it corrupts - * nothing. + * nothing. The holder-bound SubagentService handle is captured before the + * engine returns this run, so unloading the engine removes only the ability to + * start another workflow; this run can still start and clean up its children. */ export class WorkerRun implements WorkflowRun { /** Settles exactly once with the run's outcome; never rejects. */ @@ -151,6 +155,7 @@ export class WorkerRun implements WorkflowRun { constructor( private readonly ctx: Context, + private readonly subagents: SubagentService, readonly id: WorkflowRunId, readonly meta: WorkflowMeta, private readonly parent: Agent, @@ -329,7 +334,7 @@ export class WorkerRun implements WorkflowRun { this.hostStarted += 1 let run: SubagentRun try { - run = this.ctx.subagents.start(this.provider, { + run = this.subagents.start(this.provider, { prompt: [{ type: 'text', text: request.prompt }], parent: this.parent, signal: this.controller.signal, diff --git a/packages/workflow/workflow-workerthread/src/index.ts b/packages/workflow/workflow-workerthread/src/index.ts index e0b8caf3fb..8a21847cd5 100644 --- a/packages/workflow/workflow-workerthread/src/index.ts +++ b/packages/workflow/workflow-workerthread/src/index.ts @@ -168,8 +168,17 @@ export class WorkerWorkflowEngine extends WorkflowService { ...request.args !== undefined ? { args: request.args } : {}, limits, } + // Capture the dependency while this service call is still traced through + // the start() holder. Cordis strips the engine-provider shadow when it + // returns the SubagentService handle, so an already-returned run can keep + // starting children after an engine HMR unload removes ctx.workflows. + // Re-resolving `this.ctx.subagents` later from WorkerRun would instead walk + // the now-inactive engine fiber and break the seam's holder-owned lifetime. + const runCtx = this.ctx + const subagents = runCtx.subagents const workerRun = new WorkerRun( - this.ctx, + runCtx, + subagents, id, structuredClone(meta), request.parent, diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 9ccbb17a4e..641b26518d 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -148,8 +148,8 @@ async function setup(options?: SetupOptions) { // A fixed concurrency ceiling: the auto-resolved default is machine-derived // (cores - 2, floored at 1), so tests that expect N children in flight // would wedge on small CI runners. - await ctx.plugin(WorkerWorkflowEngine, { provider: 'stub', maxConcurrentAgents: 8, ...options?.config }) - return { ctx, provider, parent: fakeParent() } + const engineFiber = await ctx.plugin(WorkerWorkflowEngine, { provider: 'stub', maxConcurrentAgents: 8, ...options?.config }) + return { ctx, provider, parent: fakeParent(), engineFiber } } /** The standard test meta plus a body, spread into a start request. */ @@ -1235,6 +1235,34 @@ describe('dsh-workflow-workerthread', () => { expect(ctx.get('workflows')).toBeUndefined() }) + it('keeps a holder-owned run usable when the engine unloads before its child starts', async () => { + const { ctx, parent, provider, engineFiber } = await setup({ reply: () => text('survived reload') }) + let handle!: ReturnType + const holder = await ctx.plugin(Object.assign((inner: Context) => { + handle = inner.workflows.start({ ...scripted("return await agent('after reload')"), parent }) + }, { inject: ['workflows'] })) + + try { + // A real worker cannot deliver child-start in the synchronous start() + // slice. Unload the provider before that message arrives: the returned + // run belongs to `holder`, not to the engine fiber being reloaded. + expect(provider.runs).toHaveLength(0) + await engineFiber.dispose() + expect(ctx.get('workflows')).toBeUndefined() + + await expect(handle.result).resolves.toEqual({ + value: 'survived reload', + stopReason: 'completed', + agentsStarted: 1, + }) + expect(provider.runs).toHaveLength(1) + } finally { + await handle.dispose() + await holder.dispose() + await ctx.fiber.dispose() + } + }) + it('has the class-plugin export shape (default = the engine service class)', () => { expect(workerEngineModule.default).toBe(WorkerWorkflowEngine) const loader = Object.create(Loader.prototype) as Loader diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 32ea8539c6..ae1fa8d170 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -81,12 +81,15 @@ const FENCE = 'ts cordis-catalog' */ export const LINK_MAP: Record = { Agent: 'core.md', + AgentRegistrationReservation: 'core.md', ContentBlock: 'core.md', Message: 'core.md', MessageSource: 'core.md', GenerateOptions: 'core.md', LlmCallConfig: 'core.md', SessionEvent: 'core.md', + SessionStartSource: 'core.md', + SessionRegistrationReservation: 'session.md', StreamChunk: 'llm-streaming.md', TurnEndReason: 'session.md', ToolDefinition: 'tools.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 5459755be5..048ce58937 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -249,6 +249,9 @@ const DYNAMIC_EVENT_DISPATCHERS: Array<{ event: string; pkg: string; method: str // Creation notifications preserve synchronous veto/rollback but observe // returned promises explicitly so async listener rejection is not unhandled. { event: 'agent/created', pkg: 'agent', method: 'events.dispatch' }, + // Registry disposal reuses the stable carrier captured before entry commit + // and contains each listener directly rather than rebuilding via agentEvents. + { event: 'agent/disposed', pkg: 'agent', method: 'events.dispatch' }, { event: 'session/created', pkg: 'session', method: 'events.dispatch' }, // Session disposal uses direct callback resolution so teardown contains each // synchronous throw and returned-promise rejection independently. diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index e238583e69..62111d1551 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -11,6 +11,7 @@ { "doc": "docs/core-data-structures/core.md", "symbol": "LlmCallConfig", "source": "packages/llm/llm/src/call-config.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "Agent", "source": "packages/core/agent/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "AgentRegistrationReservation", "source": "packages/core/agent/src/index.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "HookContext", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationDecision", "source": "packages/core/agent/src/types.ts" }, @@ -40,6 +41,7 @@ { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceOp", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceIntent", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceNode", "source": "packages/core/session/src/surface.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "SessionRegistrationReservation", "source": "packages/core/session/src/index.ts" }, { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" }, diff --git a/vendor/README.md b/vendor/README.md index bf0f0b5a8c..0a709495db 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -35,6 +35,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references. 4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. +6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. ## Sync procedure diff --git a/vendor/cordis/src/fiber.ts b/vendor/cordis/src/fiber.ts index fd472e7733..7e7766b48d 100644 --- a/vendor/cordis/src/fiber.ts +++ b/vendor/cordis/src/fiber.ts @@ -80,6 +80,35 @@ interface EffectRunner { getOuterStack: () => string[] } +// Public effect disposers remain single-shot, but structural owners and outer +// effects must still be able to join a cleanup that another caller started. +const effectInertia = new WeakMap void | Promise>() + +function runDisposable(dispose: Disposable) { + const result = dispose() + return effectInertia.get(dispose)?.() ?? result +} + +/** Notify plugin teardown without allowing one observer to break ownership cleanup. */ +function emitPluginDisposed(context: Context, fiber: Fiber) { + const args: any[] = ['internal/plugin', fiber] + let callbacks: Function[] + try { + callbacks = context.events.dispatch('emit', args) + } catch (error) { + context.logger.error(error) + return + } + for (const callback of callbacks) { + try { + const returned = callback(...args) + void Promise.resolve(returned).catch(error => context.logger.error(error)) + } catch (error) { + context.logger.error(error) + } + } +} + /** Lifecycle state for one plugin fiber. */ export const enum FiberState { PENDING, @@ -175,24 +204,19 @@ export class Fiber { collect, } - this.context.emit('internal/plugin', this) - - for (const name of Object.keys(this.inject)) { - this._checkImpl(name) - } - + let shouldRefresh = false this.dispose = parent.fiber.effect(() => { const remove = runtime.fibers.push(this) try { this.config = resolveConfig(runtime, config) - this._refresh() + shouldRefresh = true } catch (error) { this.ctx.logger.error(error) this._error = error } return async () => { this.uid = null - this.context.emit('internal/plugin', this) + emitPluginDisposed(this.context, this) if (this.ctx.registry.has(runtime.callback)) { remove() if (!runtime.fibers.length) { @@ -200,6 +224,16 @@ export class Fiber { } } this._setEpoch(INACTIVE) + // A PENDING fiber can already own effects registered by an + // internal/plugin observer. Its epoch is still INACTIVE, so + // _setEpoch() has no transition to drive; explicitly unload that + // pre-activation work before reporting disposal complete. + if (!this.inertia) { + this._updateState(() => { + this.inertia = this._unload() + return FiberState.UNLOADING + }) + } // `this.inertia` itself should never reject — both `_reload` and // `_unload` swallow their own work errors via `ctx.logger.error`. // If it *does* reject, the only remaining cause is the logger @@ -211,6 +245,28 @@ export class Fiber { } } }, 'ctx.plugin()') + + try { + // Publish only after the parent owns a fully assigned disposer. A + // synchronous observer may dispose either this fiber or its parent. + this.context.emit('internal/plugin', this) + } catch (error) { + // Publication failed synchronously. The disposer removes the child + // from both the parent and runtime before control escapes. + void Promise.resolve(this.dispose()).catch(reason => this.ctx.logger.error(reason)) + throw error + } + + // Keep the initial notification's historical PENDING view. The loader + // may also extend `inject` in that notification, so resolve dependencies + // only after publication. A reentrant parent unload makes the child + // disposer responsible for draining any PENDING effects instead. + if (this.uid !== null && parent.fiber.state !== FiberState.UNLOADING) { + for (const name of Object.keys(this.inject)) { + this._checkImpl(name) + } + if (shouldRefresh) this._refresh() + } } else { this.uid = 0 this.ctx = this.context = parent @@ -292,21 +348,28 @@ export class Fiber { effect(execute: () => Effect, label?: string): AsyncDisposable> effect(execute: () => Effect, label = 'anonymous'): any { this.assertActive() + if (this.state === FiberState.UNLOADING) { + throw new CordisError('INACTIVE_EFFECT') + } const disposables: Disposable[] = [] + let disposing = false + let disposalTask: void | Promise const dispose = () => { + if (disposing) return disposalTask + disposing = true let task!: void | Promise - for (const dispose of disposables.splice(0).reverse()) { + for (const disposable of disposables.splice(0).reverse()) { if (task) { - task = task.then(dispose) + task = task.then(() => runDisposable(disposable)) } else { - const result = dispose() + const result = runDisposable(disposable) if (isObject(result) && 'then' in result) { task = result as any } } } - return task + return disposalTask = task } const meta: EffectMeta = { label, children: [] } @@ -324,34 +387,107 @@ export class Fiber { } let task: void | Promise + let executing = true + let resolveSetup: (() => void) | undefined + let rejectSetup: ((reason: unknown) => void) | undefined + let setupBarrier: Promise | undefined + let setupFailed = false + let inFlight: void | Promise + let removeWrapper = () => false + + const waitForSetup = () => { + setupBarrier ??= new Promise((resolve, reject) => { + resolveSetup = resolve + rejectSetup = reject + }) + return setupBarrier + } + + const disposeAfter = (setup: PromiseLike) => { + return Promise.resolve(setup).then( + () => dispose(), + async (reason) => { + await dispose() + throw reason + }, + ) + } + + const finalizeDisposal = (callback: () => void | Promise) => { + let result: void | Promise + try { + result = callback() + } catch (error) { + removeWrapper() + throw error + } + if (isObject(result) && 'then' in result) { + const pending = Promise.resolve(result).finally(() => { + removeWrapper() + if (inFlight === pending) inFlight = undefined + }) + return inFlight = pending + } + removeWrapper() + return result + } + + const wrapper = defineProperty(() => { + // A synchronous setup failure can race an owner unload that already + // captured this wrapper but has not invoked it yet. The failed effect is + // never returned publicly, so let that internal caller await rollback. + if (!runner.epoch) return setupFailed ? inFlight : undefined + runner.epoch = false + return finalizeDisposal(() => { + if (executing) return disposeAfter(waitForSetup()) + return task ? disposeAfter(task) : dispose() + }) + }, symbols.effect, meta) as AsyncDisposable + effectInertia.set(wrapper, () => inFlight) + + // Make the effect visible to a reentrant owner unload before execute() + // runs any plugin code. Async teardown stays owner-visible until it + // settles, allowing an outer effect to join cleanup another caller began. + removeWrapper = this._disposables.push(wrapper) try { task = this._execute(runner) } catch (reason) { - dispose() + executing = false + setupFailed = true + runner.epoch = false + let cleanup: void | Promise + try { + cleanup = finalizeDisposal(dispose) + } finally { + rejectSetup?.(reason) + } + if (isObject(cleanup) && 'then' in cleanup) { + cleanup.catch(error => this.ctx.logger.error(error)) + } throw reason } + executing = false + if (setupBarrier) { + Promise.resolve(task).then(resolveSetup, rejectSetup) + } // prevent unhandled rejection — both from `task` itself and from the // disposer chain if it fails to settle cleanly. - task?.catch(dispose).catch((error) => this.ctx.logger.error(error)) - - const wrapper = defineProperty(() => { - if (!runner.epoch) return - runner.epoch = false - return task ? task.then(dispose) : dispose() - }, symbols.effect, meta) as AsyncDisposable + task?.catch(() => { + if (!runner.epoch) return dispose() + return finalizeDisposal(dispose) + }).catch((error) => this.ctx.logger.error(error)) const disposeAsync = () => { if (!runner.epoch) return runner.epoch = false - return dispose() + return finalizeDisposal(dispose) } wrapper.then = async (onFulfilled, onRejected) => { return Promise.resolve(task) .then(() => disposeAsync) .then(onFulfilled, onRejected) } - disposables.push(this._disposables.push(wrapper)) return wrapper } @@ -434,7 +570,12 @@ export class Fiber { const oldEpoch = this._runner.epoch try { await Promise.resolve() - await this._execute(this._runner) + // A disposer queued before this checkpoint may already have invalidated + // the load. Do not run plugin code for a stale epoch; the state update + // below will drain any effects collected while the fiber was PENDING. + if (this._runner.epoch === oldEpoch) { + await this._execute(this._runner) + } } catch (reason) { // impl guarantees that the error is non-null (?) this.ctx.logger.error(reason) @@ -457,7 +598,7 @@ export class Fiber { await composeError(async (info) => { await Promise.resolve() info.error = new Error() - await dispose() + await runDisposable(dispose) }, this._runner.getOuterStack) } catch (reason) { this.ctx.logger.error(reason) From 9fc2260bb638621205658b9cdab1eb3f6a6d6d7a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:17:31 +0800 Subject: [PATCH 43/64] fix(workflow): harden terminal cleanup races Queue worker results before settlement cleanup, claim terminal and death boundaries before provider callbacks, and close late-message admission. Make child cancellation and disposal reentrancy-safe across the workflow bridge and generic subagent wrapper, with adversarial regression coverage and RFC documentation. --- .../2026-07-08-agent-scope-contexts.md | 57 ++- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/src/index.ts | 20 +- .../subagent/subagent/tests/service.spec.ts | 51 +++ .../workflow/workflow-workerthread/README.md | 12 +- .../workflow-workerthread/src/host.ts | 264 ++++++++--- .../workflow-workerthread/src/runtime.ts | 28 +- .../workflow-workerthread/src/session.ts | 14 +- .../tests/session.spec.ts | 30 ++ .../tests/workflow-workerthread.spec.ts | 423 +++++++++++++++++- 10 files changed, 803 insertions(+), 98 deletions(-) diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md index c29fb4f83b..df6433dd26 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -613,7 +613,7 @@ Starting a run reads every top-level request field once before capability valida The driver first installs provider ownership. Only after that succeeds does it attach the request's abort listener and create one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves neither a child nor an orphaned listener. Calling `runOwner.ctx.agents.create()` gives the child factory an explicit `ownerCtx` carrying the run-owner fiber and scope, while the registry's traced factory receiver preserves AgentLoop's injected dependency origin. Parent teardown, provider teardown, and manual run disposal all dispose this same run-owner node; moving it out of the active state synchronously prevents an unpublished setup from publishing afterward, while all three paths follow one quiescence promise. This structured ownership does not change the child's flat capability view. -The provider's run separates acceptance from publication with `started: Promise`, but the service does not expose that caller-owned handle directly. It captures `id`, `started`, `result`, and each method once, binds methods to the provider-owned run handle, and returns a frozen service-owned wrapper. Capturing `dispose` first also preserves a rollback capability if a later accessor or method check reveals a malformed handle. +The provider's run separates acceptance from publication with `started: Promise`, but the service does not expose that caller-owned handle directly. It captures `id`, `started`, `result`, and each method once, binds methods to the provider-owned run handle, and returns a frozen service-owned wrapper. Capturing `dispose` first also preserves a rollback capability if a later accessor or method check reveals a malformed handle. The wrapper installs its shared disposal promise before invoking the raw provider callback, so synchronous reentry through the returned wrapper and ordinary repeat calls join one provider disposal rather than slipping through a not-yet-assigned memo. If the raw disposer directly returns that same reentrant wrapper promise, the service rejects the cyclic provider contract instead of awaiting a promise that depends on itself forever. The wrapper's `result` promise captures `output`, optional `structured`, and `stopReason` once and resolves to one detached, deeply frozen lossless-JSON value shared by the caller and lifecycle telemetry. Malformed terminal data is an infrastructure fault; it rejects only after the service has started rollback of the provider attempt. The service observes the normalized result immediately, before waiting for readiness, so an early rejection is never temporarily unhandled. @@ -658,24 +658,57 @@ SubagentService.start(...): Workflow worker bridge after receiving returnedRun: register the run so cancellation can reach pre-publication work attach result settlement handlers immediately and snapshot the outcome - if returnedRun.started fulfills: - send ChildStarted; then send the buffered or eventual outcome - else: - send ChildStartError and dispose the attempt + re-check terminal admission after provider start returns + if admission closed: + if the exact run remains registered: cancel once and dispose it + if worker-message admission remains open: send ChildStartError + else wait for returnedRun.started: + on fulfillment: + re-check terminal admission + if closed: apply the same identity-guarded refusal + else: send ChildStarted; then send the buffered or eventual outcome + on rejection: + if worker-message admission remains open: send ChildStartError + if the exact run remains registered: dispose it -Before publishing the workflow's own result: - abort the shared child-request signal - call cancel("workflow settled") on every host-registered run - only then settle the workflow result +Worker after choosing its result: + queue Result on the worker-to-host port + only then reap stray child handles + +Host at workflow Result receipt: + cancellationWasRequested = external cancellation is already in flight + atomically claim chosen = + if cancellationWasRequested and result is not cancelled: + cancelledResult + else: + result + + if not cancellationWasRequested: + abort the shared child-request signal + call cancel("workflow settled") on every host-registered run + + settle chosen + +Host at the first worker death signal: + close worker-message admission + claim death or preserve an earlier cancellation/Result/grace outcome + cancel and dispose every registered child + synthesize missing lifecycle ends + +Host at physical worker exit: + perform a final disposal-only sweep + do not repeat explicit child cancellation ``` -Every downstream protocol that announces a subagent must honor the same boundary. The workflow worker bridge therefore registers the returned run before waiting, observes and snapshots `result` immediately, sends `ChildStarted` only after `started` fulfills, and sends `ChildStartError` plus host-driven disposal when readiness rejects. +Every downstream protocol that announces a subagent must honor the same boundary. The workflow worker bridge therefore registers the returned run before waiting, observes and snapshots `result` immediately, and sends `ChildStarted` only after `started` fulfills while admission remains open. A readiness rejection is refused and host-disposed; `ChildStartError` is sent while worker-message admission remains open, and an already-retired exact run is not cleaned twice. Provider `start()` is itself arbitrary code and may synchronously reenter workflow cancellation before its returned run reaches that registry. The bridge attaches both promise observers, re-checks terminal admission immediately after `start()` returns and again at readiness, and turns a closed boundary into identity-guarded cancellation, disposal, and refusal rather than late worker admission or lifecycle announcement. An arbitrary provider may still fulfill its own `started` promise after the workflow boundary; the bridge refuses and cleans up that attempt instead of claiming it can undo provider-side publication. Cancellation before readiness is a publication decision, not merely a flag for later result mapping. The in-process run synchronously deactivates its owner fiber. If cancellation lands before publication, the factory's liveness check prevents either creation edge. If it begins synchronously inside `session/created`, `agent/created`, or `agent/session-start`, the publication barrier lets the current notification phase unwind without revoking its world, the next liveness check prevents every later phase and driver start, and rollback pairs every creation edge that already began. In either case `started` rejects, no `subagent/start` or `subagent/end` is emitted, and the run result settles as `aborted`. -Before the workflow's own result becomes observable, its host likewise drives both permitted cancellation channels: it aborts the shared request signal and calls each registered run's `cancel()`, including runs still waiting on readiness. Provider cancel callbacks are contained independently so one broken implementation cannot prevent peers from receiving cancellation or wedge the workflow result. +Receipt of the worker's `Result` message is the workflow host's atomic first-wins boundary. The worker queues that message before its own settlement-reap `ChildCancel` messages, so same-port FIFO prevents an internal child callback from masquerading as earlier run cancellation. Each contender records its claim before its own callback fanout: external `cancel()` records its reason first, while Result receipt snapshots any earlier cancellation and claims the resulting terminal outcome before invoking settlement-cleanup provider code. A caller, signal, or dispose cancellation already in flight therefore overrides a non-cancelled worker report, while the report wins otherwise. Before exposing that chosen result, the host drives both permitted child-cancellation channels by aborting the shared request signal and calling every registered run's `cancel()`, including runs still waiting on readiness. Those calls are settlement-only cleanup, and the terminal claim makes a reentrant `WorkerRun.cancel()` a side-effect-free loser rather than merely repairing its result afterward. Host fanout and the worker's FIFO-later `ChildCancel` can both reach the explicit channel, so a per-call gate invokes each provider `cancel()` at most once; the seam does not require that callback to be idempotent. Explicit child cancel callbacks are contained independently so one throwing callback cannot starve peers or alter settlement. -Together these rules prevent an early result rejection from going unhandled, ensure `workflow/agent-start` never names an unpublished child, and prevent a child from publishing after its workflow has ended. +Unexpected worker death uses the same terminal-claim rule, but terminal ownership, message admission, and exit cleanup are separate state. The host snapshots whether external cancellation was already accepted, claims either `cancelled` or the death error, closes inbound worker messages, and only then reaps children and synthesizes missing lifecycle events. Closing admission is necessary because Node may emit `error`, deliver an already-queued `message`, and only then emit `exit`; without the logical barrier, that message could start a child or narrate after `workflow/end`. Provider code reentering cancellation during cleanup cannot rewrite a death-first error; conversely, a cancellation accepted before death remains the winner. If Result or grace already claimed the outcome, death preserves it while still reaping promptly. Physical exit then performs a final disposal-only sweep without repeating explicit provider cancellation. `handle.dispose()` claims its public promise before that traversal invokes cancellation or disposal callbacks, and every `disposeChild` path independently claims the call ID promise before invoking the wrapped child disposer. Public-first reentry returns the existing holder promise; worker-first reentry may begin holder disposal, whose child traversal joins the already-claimed call ID promise. This distinction is necessary because grace settlement precedes `worker.terminate()` and its exit event: suppressing a duplicate outcome, late message, or repeated cleanup request must not suppress disposal of survivors in the host registry. + +Together these rules prevent an early result rejection from going unhandled, ensure `workflow/agent-start` never names an unready child, and prevent the bridge from admitting or announcing a child after its workflow has ended. Parent teardown reaches `runOwner` by nesting; the provider and returned run handle reach the same node through their explicit disposers. diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 7e63f07376..e29706bd8b 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -21,7 +21,7 @@ Unlike the bash seam (one executor per context, second load throws), **multiple | `registerProvider(provider)` | Read and validate the name, capability object and four boolean flags, `inheritsParentContext`, and `start` callback exactly once, then register a frozen acceptance snapshot under the accepted name. Malformed fixed fields fail loud before registration; later caller mutation cannot change registry behavior or HMR cleanup, while `start` stays bound to the original provider receiver. Throws `SubagentError('DUPLICATE_PROVIDER')` on a name clash. Effect-scoped (HMR-safe); returns the disposer. | | `getProvider(name)` | Look up the frozen registry snapshot (`undefined` if absent). | | `list()` | Registered provider names (insertion order). | -| `start(name, request)` | Resolve the provider (`NO_PROVIDER` if absent), read every caller field once into one acceptance snapshot, validate every requested START-TIME capability and scalar value before any child is created, and materialize prompt/schema/options/filter data through a single-pass lossless-JSON snapshot before delegating to `provider.start`. Acquire and memoize the provider run's disposer before reading the rest of its handle, then return a frozen service-owned wrapper whose fields are captured once, whose methods remain bound to the provider handle, and whose `result` is one detached, deeply frozen normalization shared by the caller and telemetry. Malformed handle access/binding starts rollback before the synchronous fault escapes; malformed terminal data rejects only after rollback reaches quiescence. Emit `subagent/start` only after `run.started` fulfills and the paired `subagent/end` after that started run settles; a pre-publication readiness rejection emits neither. | +| `start(name, request)` | Resolve the provider (`NO_PROVIDER` if absent), read every caller field once into one acceptance snapshot, validate every requested START-TIME capability and scalar value before any child is created, and materialize prompt/schema/options/filter data through a single-pass lossless-JSON snapshot before delegating to `provider.start`. Acquire the provider run's disposer before reading the rest of its handle, then return a frozen service-owned wrapper whose fields are captured once, whose methods remain bound to the provider handle, and whose `result` is one detached, deeply frozen normalization shared by the caller and telemetry. The wrapper claims its shared disposal promise before invoking raw provider code, so synchronous reentry and ordinary repeats join one provider call; a raw disposer that directly returns that same reentrant wrapper promise is rejected as a cyclic provider contract instead of hanging forever. Malformed handle access/binding starts rollback before the synchronous fault escapes; malformed terminal data rejects only after rollback reaches quiescence. Emit `subagent/start` only after `run.started` fulfills and the paired `subagent/end` after that started run settles; a pre-publication readiness rejection emits neither. | ## Capabilities: two kinds, discovered two ways diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index b319a7f31d..74b7df171b 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -379,14 +379,30 @@ export class SubagentService extends Service { let disposal: Promise | undefined const dispose = (): Promise => { if (disposal === undefined) { + // Claim the shared transaction before invoking provider code: a raw + // disposer can synchronously reenter this wrapper through a reference + // retained by its caller, and both calls must join one provider call. + const claimed = Promise.withResolvers() + disposal = claimed.promise try { // Invoke through the captured callable without reading its public // `bind`/`length`/`name` properties. Disposal is the recovery // capability itself; hostile function metadata must not prevent the // seam from exercising it when a later handle field is malformed. - disposal = Promise.resolve(Reflect.apply(inputDispose, acceptedRun, [])) + const returned: unknown = Reflect.apply(inputDispose, acceptedRun, []) + // A raw disposer can reenter the service wrapper and directly return + // that same shared promise. Awaiting it here would make the promise + // depend on itself forever; reject the cyclic provider contract loud. + if (returned === claimed.promise) { + claimed.reject(new TypeError(`subagent provider "${name}" run dispose returned its own wrapper disposal promise`)) + return disposal + } + void Promise.resolve(returned).then( + () => { claimed.resolve(undefined) }, + (error: unknown) => { claimed.reject(error) }, + ) } catch (error: unknown) { - disposal = Promise.reject(error instanceof Error + claimed.reject(error instanceof Error ? error : new Error('subagent provider run dispose threw a non-Error value', { cause: error })) } diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index d1a531ca09..e9b66ce158 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -150,6 +150,57 @@ describe('SubagentService', () => { } }) + it('claims wrapper disposal before a raw provider disposer can reenter it', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const observed: { reentrant?: Promise } = {} + const providerDispose = vi.fn(() => { + observed.reentrant = run.dispose() + return Promise.resolve() + }) + ctx.subagents.registerProvider({ + name: 'dispose-reentry', + capabilities: NO_CAPS, + inheritsParentContext: false, + start: () => ({ + id: AgentId('dispose-reentry-child'), + started: Promise.resolve(), + result: Promise.resolve({ output: [], stopReason: 'completed' }), + cancel() {}, + dispose: providerDispose, + }), + }) + const run = ctx.subagents.start('dispose-reentry', baseRequest()) + + const disposal = run.dispose() + + expect(observed.reentrant).toBe(disposal) + await disposal + expect(providerDispose).toHaveBeenCalledOnce() + }) + + it('rejects a raw disposer that directly returns its reentrant wrapper promise instead of hanging', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const providerDispose = vi.fn(() => run.dispose()) + ctx.subagents.registerProvider({ + name: 'dispose-self-cycle', + capabilities: NO_CAPS, + inheritsParentContext: false, + start: () => ({ + id: AgentId('dispose-self-cycle-child'), + started: Promise.resolve(), + result: Promise.resolve({ output: [], stopReason: 'completed' }), + cancel() {}, + dispose: providerDispose, + }), + }) + const run = ctx.subagents.start('dispose-self-cycle', baseRequest()) + + await expect(run.dispose()).rejects.toThrow('run dispose returned its own wrapper disposal promise') + expect(providerDispose).toHaveBeenCalledOnce() + }) + it.each([ { label: 'a non-string name', patch: { name: 42 }, message: 'name must be a string' }, { label: 'null capabilities', patch: { capabilities: null }, message: 'capabilities must be an object' }, diff --git a/packages/workflow/workflow-workerthread/README.md b/packages/workflow/workflow-workerthread/README.md index 1879e60e7e..409ab57132 100644 --- a/packages/workflow/workflow-workerthread/README.md +++ b/packages/workflow/workflow-workerthread/README.md @@ -25,7 +25,7 @@ What the seam guarantees regardless, because benign scripts hit these constantly Inside the worker, `runWorkerSession` builds the execution core (hooks, combinators, concurrency semaphore, caps, fatal-error discipline) over a **child port**. `agent()` sends `child-start`, and the host starts the child through the holder-bound `SubagentService` handle captured synchronously by `start()`, with parent attribution, the shared per-run abort signal, and `outputSchema`/`model` pass-through. This capture is part of the seam's holder-owned lifetime: unloading the engine removes `ctx.workflows` for new calls but does not invalidate an already returned run whose worker starts another child afterward. -The host observes `run.result` immediately but buffers its snapshotted wire projection until `run.started` fulfills. It then replies `child-started` with the child id before forwarding settlement, so `workflow/agent-start` always names a ready child and precedes its end. A readiness rejection replies `child-start-error`, emits no workflow agent pair, and makes the host dispose the attempt because the worker never received a handle; the worker classifies it as fatal `AGENT_START` unless cancellation already owns the run. If readiness fulfills, an infrastructure result rejection crosses as `child-failed`/`AGENT_RESULT` regardless of whether that rejection settled before or after readiness. Child disposal acknowledgements complete the RPC. +The host observes `run.result` immediately but buffers its snapshotted wire projection until `run.started` fulfills. Provider `start()` is arbitrary code and can synchronously reenter workflow cancellation before its returned run reaches the host registry, so the host registers the run, attaches both promise observers, and re-checks admission after `start()` returns and again at readiness. A closed boundary never admits or announces the run to the worker: while the exact run remains registered, the host invokes explicit cancel once and disposes it; `child-start-error` is sent only while worker-message admission remains open. If the run was already retired, the identity guard sends no cleanup through the deleted call ID. An ordinary readiness rejection sends `child-start-error` while possible and disposes the provider attempt without adding an explicit cancellation. Otherwise the host replies `child-started` with the child id before forwarding settlement, so `workflow/agent-start` always names a ready child and precedes its end. The worker classifies a start error as fatal `AGENT_START` unless cancellation already owns the run. If readiness fulfills, an infrastructure result rejection crosses as `child-failed`/`AGENT_RESULT` regardless of whether that rejection settled before or after readiness. Child disposal acknowledgements complete the RPC. Observer narration (`phase`/`log`/`agent-start`/`agent-end`) crosses as messages and re-emits as the seam's `workflow/*` events. A **ready→go handshake** gates the body: a cancellation racing worker boot arrives before `go`, so a run cancelled before start never executes the body at all. @@ -35,9 +35,15 @@ Values LEAVING the script (hook options/schemas, the script's return) are materi ## Cancellation, death, disposal -Per-run limits: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` posts the cancel to the worker (its hooks start throwing `CANCELLED`; the script dies at its next await) and cancels every host-side child NOW on **both seam channels** — the shared request signal aborts AND each registered child's explicit `cancel()` is called host-side, because the seam leaves a provider free to honor either channel and a worker wedged in a synchronous spin could not relay its own per-child cancel RPCs (those later land as idempotent no-ops). Each provider-owned cancel callback is exception-contained independently, so a broken child cannot prevent peer cancellation or workflow settlement. The caller's optional start-signal callback is retained by exact identity only while the run is live and removed at the first settlement or teardown, so a long-lived signal cannot retain completed `WorkerRun` instances. The grace then arms: a run still unsettled `disposeGraceMs` later force-settles `cancelled` and the worker is **terminated**. A cancellation that lands before the body runs (the ready→go handshake) reports `cancelled` without executing anything; a worker `result` racing an in-flight host cancellation reports `cancelled` too (first-wins settlement — the seam-visible result had not settled when cancellation was requested); post-cancel `phase`/`log` narration is suppressed host-side, while cancelled children still deliver their paired `agent-end`. +Cancellation is bounded and host-driven. Per-run limits are a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` first records its reason, then posts to the worker (its hooks start throwing `CANCELLED`; the script dies at its next await) and cancels every host-side child NOW on **both seam channels**: the shared request signal aborts and each registered child's explicit `cancel()` runs host-side. The seam leaves a provider free to honor either channel, and a worker wedged in a synchronous spin could not relay its own per-child cancel RPCs. A host-side per-call gate turns the worker's later explicit-cancel relay into a no-op, because the seam does not require `SubagentRun.cancel()` to be idempotent. Each explicit child `cancel()` callback is exception-contained independently, post-cancel `phase`/`log` narration is suppressed host-side, and cancelled children still deliver paired `agent-end` events. The caller's optional start-signal callback is retained by exact identity only while the run is live and removed at the first settlement or teardown. -A worker that dies unexpectedly (an OOM, a script reaching `process.exit` through the documented vm escape) settles the run `stopReason: 'error'` with the exit diagnostics — or `'cancelled'` when a cancel was in flight — and the host-side child registry is what winds every surviving child down. `dispose()` = cancel + immediate host-driven disposal of every registered child (a wedged worker can relay no dispose RPC, so child teardown overlaps the grace instead of starting after it; the worker's own dispose RPCs join the same per-child disposal) + bounded wait (result, then child-registry quiescence, capped by the grace) + unconditional `worker.terminate()`: the thread never outlives its run. Before an ordinary run settlement becomes observable, the host cancels every stray child on both channels too—even a fire-and-forget run still waiting on `started`, for which the worker has no handle yet—and `dispose()` then waits for their disposal (bounded by the grace) before returning. `agent-start`/`agent-end` pairing is host-guaranteed the same way: forwarded starts live in a ledger, worker-reported ends pair them on the graceful paths, and the termination paths (grace force-settle, worker death) synthesize the missing ends (outcome `cancelled`) before the run settles — a start still in flight across the force-settle can surface after `workflow/end`, immediately paired the same way. +Terminal arbitration is first-wins at explicit host-side claim points. A cancellation before ready→go reports `cancelled` without executing the body. For a later race, the worker queues Result before its settlement-reap `ChildCancel` messages; external `cancel()` records its reason before its fanout, while Result receipt snapshots any earlier cancellation and records the terminal outcome before settlement-cleanup fanout. Same-port FIFO and those claim points mean earlier caller/signal/dispose cancellation overrides a non-cancelled report, while an arrived report cannot be rewritten by a cleanup callback. Once Result has won, a losing reentrant `cancel()` has no state, message, child-fanout, or grace-timer effect. If no earlier terminal source settles the run, the grace callback claims `cancelled`, synthesizes missing lifecycle ends, settles the result, and terminates the worker after `disposeGraceMs`. + +Worker death separates outcome ownership, message admission, and resource cleanup. An unexpected OOM, `error`, message failure, or premature exit claims `stopReason: 'error'` with diagnostics—or preserves an external cancellation already in flight—before reaping children or synthesizing observer events. Reentrant provider cancellation therefore cannot turn a death-first error into cancellation. The first death signal also closes worker-message admission because Node may deliver a queued `message` between `error` and `exit`; late protocol data cannot start a child, emit narration, or compete with the outcome. If Result or grace already claimed the outcome, death preserves it while still reaping promptly. The eventual `exit` then performs a final disposal-only sweep, joining any in-flight disposal without repeating explicit child cancellation. This separation lets grace settlement become observable before `worker.terminate()` reports exit without leaking the host-side registry. + +Disposal is the holder's bounded resource guarantee: cancel, begin host-driven disposal of every registered child immediately, wait for result plus child-registry quiescence up to the same grace, and unconditionally terminate the worker. `handle.dispose()` claims its public promise before that traversal invokes cancellation or disposal callbacks. Independently, every `disposeChild` path claims the call ID's promise before invoking the wrapped child disposer. Public-first reentry therefore returns the existing holder promise; worker-first reentry may begin holder disposal, whose child traversal joins the already-claimed call ID promise. Neither order can start a second provider disposal. A wedged worker can relay no dispose RPC, so host-driven teardown overlaps the grace; any later worker RPC joins the same per-child disposal. Before ordinary settlement becomes observable, the host also cancels every stray on both channels, including a fire-and-forget run still waiting on readiness. That work is settlement-only cleanup after the terminal claim, so provider reentry cannot rewrite the chosen result; `dispose()` then waits for its completion within the bound. + +Lifecycle pairing is host-guaranteed independently of outcome arbitration. Forwarded starts live in a ledger and worker-reported ends pair them on graceful paths. When death or grace is the terminal source, the host synthesizes missing ends with outcome `cancelled` before `workflow/end`. If Result settled first, later death cleanup may synthesize a survivor's end afterward; a start already crossing force-settlement may likewise surface after `workflow/end`. The same ledger still pairs every forwarded start exactly once. **Engine-specific limitations**: worker startup is paid per run; on a termination path `agentsStarted` reports the HOST-observed count (accepted `child-start`s — calls still queued worker-side for a concurrency slot are unknowable then); and a returned promise or thenable resolves per JavaScript semantics BEFORE materialization — that is what makes an un-awaited `return agent('x')` work — with the value-boundary guard applying to the resolution. diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index 8c839fbccb..7e87c14fae 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -7,19 +7,31 @@ * run. * * The run's `result` promise settles exactly once, from whichever of these - * lands first: the worker's `result` message (a host-side cancellation in - * flight overrides a non-cancelled report — the seam-visible result had not - * settled when cancellation was requested), an unexpected worker death - * (`error`/`messageerror`/premature `exit` → `stopReason: 'error'`, or - * `'cancelled'` when a cancel was in flight), or the post-cancel grace timer - * (a script that never settles is force-settled `cancelled` and its worker - * terminated — the real kill an in-process engine could not perform). + * lands first: receipt of the worker's `result` message, an unexpected worker + * death (`error`/`messageerror`/premature `exit` → `stopReason: 'error'`, or + * `'cancelled'` when a cancel was in flight), or the post-cancel grace timer (a + * script that never settles is force-settled `cancelled` and its worker + * terminated — the real kill an in-process engine could not perform). At + * `result` receipt the host snapshots whether caller/signal/dispose + * cancellation is already in flight: an earlier cancellation overrides a + * non-cancelled report; otherwise the report wins before settlement-only child + * cleanup invokes arbitrary provider callbacks. Worker death uses the same + * boundary: it claims `error` (or a previously requested `cancelled`) before + * reaping children, so cleanup callbacks cannot rewrite the outcome. That + * first signal also closes inbound message admission: Node may emit `error`, + * then deliver queued messages, then emit `exit`, but those late messages may + * neither create work nor narrate after settlement. If Result or grace already + * owns the outcome, death preserves it while still cleaning resources; the + * eventual exit performs a final disposal-only sweep without repeating child + * cancellation. * * Children live in a host-side registry (callId → run) as soon as the provider * accepts them, so cancellation reaches even a pre-publication attempt. Both * explicit run cancellation and the shared request signal are driven when the * workflow is cancelled OR normally settles, so a fire-and-forget child cannot - * survive merely by honoring only one channel. The host observes `result` + * survive merely by honoring only one channel. A per-call gate invokes each + * explicit provider `cancel()` at most once even though host fanout and the + * worker's later relay can both request it. The host observes `result` * immediately but acknowledges the child to the worker only after `started` * fulfills; readiness failure is a start error and the host disposes the * attempt because the worker never received a handle. The @@ -31,10 +43,12 @@ * paths share ONE disposal per child (memoized by callId; the seam's * dispose() is idempotent anyway, the memo keeps the bookkeeping and the * containment warn single). Lifecycle pairing is host-guaranteed the same - * way: every forwarded `agent-start` lives in a ledger, and a start the - * dead or terminated worker never paired is closed by a synthesized - * `agent-end` (outcome `'cancelled'`) before the run settles. On a - * termination path `agentsStarted` reports the + * way: every forwarded `agent-start` lives in a ledger, and a start the dead + * or terminated worker never paired is closed exactly once by a synthesized + * `agent-end` (outcome `'cancelled'`). When death or grace is the terminal + * source, already-known pairs close before the run settles; cleanup after an + * earlier Result can close a survivor afterward. On a termination path + * `agentsStarted` reports the * HOST-observed count (accepted `child-start` messages) — `agent()` calls * still queued worker-side for a concurrency slot are unknowable then; the * worker's own count rides the result message on every graceful path. @@ -132,6 +146,10 @@ export class WorkerRun implements WorkflowRun { readonly result: Promise private settleResolve!: (result: WorkflowResult) => void private settled = false + /** A Result/death/grace outcome atomically won before teardown callbacks. */ + private terminalClaimed = false + /** The first death signal closes worker-message admission and owns failure-time cleanup. */ + private workerDeathObserved = false private cancelReason: string | undefined private graceTimer: NodeJS.Timeout | undefined private readonly worker: Worker @@ -143,6 +161,8 @@ export class WorkerRun implements WorkflowRun { private readonly children = new Map() /** In-flight child disposals by callId — the memo that gives every path (worker RPC, dispose(), reap) ONE shared disposal per child. */ private readonly childDisposals = new Map>() + /** callIds whose explicit provider cancel callback has already been invoked. */ + private readonly childCancellations = new Set() /** Started-but-not-ended agents by seq — the pairing ledger the HOST guarantees (see {@link endAgent}). */ private readonly liveAgents = new Map() private readonly quiescenceWaiters: (() => void)[] = [] @@ -172,12 +192,12 @@ export class WorkerRun implements WorkflowRun { const { entry, options } = resolveWorkerSpawn(init) this.worker = new Worker(entry, options) this.worker.on('message', (message: WorkerToHostMessage) => { this.onMessage(message) }) - this.worker.on('error', (error) => { this.onWorkerDeath(`workflow worker failed: ${renderThrown(error)}`) }) + this.worker.on('error', (error) => { this.onWorkerDeath(`workflow worker failed: ${renderThrown(error)}`, false) }) /* v8 ignore next -- messageerror: not constructible from the engine's own protocol (every payload is JSON data) */ - this.worker.on('messageerror', (error) => { this.onWorkerDeath(`workflow worker message failed to deserialize: ${renderThrown(error)}`) }) + this.worker.on('messageerror', (error) => { this.onWorkerDeath(`workflow worker message failed to deserialize: ${renderThrown(error)}`, false) }) this.worker.on('exit', (code) => { this.workerGone = true - this.onWorkerDeath(`workflow worker exited before the run settled (exit code ${code})`) + this.onWorkerDeath(`workflow worker exited before the run settled (exit code ${code})`, true) }) if (signal?.aborted) { this.cancel('workflow start signal already aborted') @@ -205,18 +225,24 @@ export class WorkerRun implements WorkflowRun { * @param reason - human-readable cause (default `'workflow cancelled'`). */ cancel(reason?: string): void { - // A settled run has nothing left to cancel: without this guard the + // A settled run has nothing left to cancel, and a terminal source claimed + // before its cleanup callbacks must exclude cancellation reentered by one + // of those callbacks. Without the settled guard the // ordinary consumer path (await result, then dispose -> cancel) would arm // a grace timer nothing ever clears, pinning the run and its Worker // closure until the grace expires - a bounded leak per completed run. - if (this.settled || this.cancelReason !== undefined) return + if (this.settled || this.terminalClaimed || this.cancelReason !== undefined) return this.cancelReason = reason ?? 'workflow cancelled' this.post(HostToWorkerType.Cancel, { reason: this.cancelReason }) // The explicit channel is driven host-side, not left to the worker: a // provider honoring only run.cancel() must not wait on a wedged worker's - // ChildCancel relay (those later RPCs land as idempotent no-ops). + // ChildCancel relay (the per-call cancellation gate makes those later + // RPCs no-ops without imposing idempotence on the provider). this.cancelChildren(this.cancelReason) this.graceTimer = setTimeout(() => { + // Cancellation already owns the race through cancelReason; close the + // terminal boundary explicitly before observer teardown callbacks. + this.terminalClaimed = true // The worker may no longer speak (it is about to be terminated): pair // every stranded start before the run settles, so ends precede // workflow/end. @@ -244,7 +270,13 @@ export class WorkerRun implements WorkflowRun { * @returns resolves when the run's resources are released or abandoned. */ dispose(): Promise { - this.disposed ??= (async () => { + if (this.disposed !== undefined) return this.disposed + // Claim the public transaction BEFORE its body invokes child/provider + // disposal. A raw provider callback can reenter handle.dispose(); it must + // join this promise rather than start a second traversal. + const claimed = Promise.withResolvers() + this.disposed = claimed.promise + void (async () => { this.detachInputSignal() this.cancel('workflow disposed') for (const [callId, run] of [...this.children]) void this.disposeChild(callId, run) @@ -257,13 +289,17 @@ export class WorkerRun implements WorkflowRun { ]) await this.worker.terminate() this.reapChildren('workflow disposed') - })() + })().then( + () => { claimed.resolve(undefined) }, + /* v8 ignore next -- result/quiescence never reject and Worker.terminate is the only external promise */ + (error: unknown) => { claimed.reject(error) }, + ) return this.disposed } /** Post one message to the worker (payload looked up from the tag's map entry), tolerating a thread that is already gone. */ private post(type: T, payload: HostToWorkerPayloads[T]): void { - if (this.workerGone) return + if (this.workerGone || this.workerDeathObserved) return try { this.worker.postMessage({ type, ...payload }) } catch (error: unknown) { @@ -276,6 +312,11 @@ export class WorkerRun implements WorkflowRun { } private onMessage(message: WorkerToHostMessage): void { + // Node may emit `error`, then deliver an already-queued `message`, then + // emit `exit`. The first death signal is the host's logical delivery + // barrier: nothing arriving afterward may create a child, narrate after + // workflow/end, or compete with the chosen outcome. + if (this.workerDeathObserved) return switch (message.type) { case WorkerToHostType.Ready: this.post(HostToWorkerType.Go, {}) @@ -308,7 +349,7 @@ export class WorkerRun implements WorkflowRun { case WorkerToHostType.ChildCancel: { const run = this.children.get(message.callId) - if (run !== undefined) this.cancelChild(run, message.reason) + if (run !== undefined) this.cancelChild(message.callId, run, message.reason) } break case WorkerToHostType.ChildDispose: @@ -323,12 +364,27 @@ export class WorkerRun implements WorkflowRun { } } - private onChildStart(callId: number, request: ChildStartRequest): void { + /** Why a child may no longer cross the provider readiness boundary. */ + private childAdmissionFailure(): { reason: string; rendered: string } | undefined { if (this.cancelReason !== undefined) { - // The worker's start raced our cancel: refuse — a child must never - // start on an already-aborted signal (a provider subscribing only to - // future abort events would never observe it). - this.post(HostToWorkerType.ChildStartError, { callId, rendered: `workflow run cancelled: ${this.cancelReason}` }) + return { reason: this.cancelReason, rendered: `workflow run cancelled: ${this.cancelReason}` } + } + if (this.workerDeathObserved) { + return { reason: 'workflow worker gone', rendered: 'workflow worker is no longer available' } + } + if (this.terminalClaimed) { + return { reason: 'workflow settled', rendered: 'workflow run already settled' } + } + return undefined + } + + private onChildStart(callId: number, request: ChildStartRequest): void { + const initialFailure = this.childAdmissionFailure() + if (initialFailure !== undefined) { + // Refuse after a terminal boundary: a child must never start on an + // already-aborted signal (a provider subscribing only to future abort + // events would never observe it). + this.post(HostToWorkerType.ChildStartError, { callId, rendered: initialFailure.rendered }) return } this.hostStarted += 1 @@ -382,22 +438,54 @@ export class WorkerRun implements WorkflowRun { }, ) - // The provider owns the publication boundary. Only acknowledge the child - // after it is real, then flush any result that settled unusually early. A - // readiness rejection is a START failure, not AGENT_RESULT: the worker - // never receives a handle, so the host must also dispose the registered - // attempt. A concurrent host disposal may already have removed it; the - // identity guard preserves the one-disposal memo in that race. + // The provider owns the publication boundary. Observe both promises before + // invoking cancellation/disposal below: provider.start() itself is + // arbitrary code and may have reentered handle.cancel() before the returned + // run reached our registry. Exactly one branch answers this ChildStart. + let startReplySent = false + const refusePublication = (failure: { reason: string; rendered: string }): void => { + startReplySent = true + this.post(HostToWorkerType.ChildStartError, { callId, rendered: failure.rendered }) + // A prior dispose/death can finish and remove this run while readiness + // is still pending. In that case teardown already owned cancellation and + // disposal; touching the retired callId would repeat cancel and orphan a + // fresh gate entry after finishChild deleted it. + if (this.children.get(callId) !== run) return + this.cancelChild(callId, run, failure.reason) + void this.disposeChild(callId, run) + } + + // Only acknowledge the child after it is real, then flush any result that + // settled unusually early. Re-check admission at that exact boundary: a + // cancellation while readiness was pending is a refusal, not a late + // publication into a terminal workflow. A readiness rejection is a START + // failure, not AGENT_RESULT; the worker never receives a handle, so the + // host disposes the registered attempt. Identity guards preserve the one + // disposal memo against concurrent host teardown. void run.started.then( () => { + if (startReplySent) return + const failure = this.childAdmissionFailure() + if (failure !== undefined) { + refusePublication(failure) + return + } + startReplySent = true this.post(HostToWorkerType.ChildStarted, { callId, childId }) void forwardResult.then((forward) => { forward() }) }, (error: unknown) => { + if (startReplySent) return + startReplySent = true this.post(HostToWorkerType.ChildStartError, { callId, rendered: renderThrown(error) }) if (this.children.get(callId) === run) void this.disposeChild(callId, run) }, ) + + // Close the synchronous hole around provider.start(): cancel()/dispose() + // can run before the returned run is visible to their children loop. + const reentrantFailure = this.childAdmissionFailure() + if (reentrantFailure !== undefined) refusePublication(reentrantFailure) } private onChildDispose(callId: number): void { @@ -427,17 +515,26 @@ export class WorkerRun implements WorkflowRun { private disposeChild(callId: number, run: SubagentRun): Promise { let disposal = this.childDisposals.get(callId) if (disposal === undefined) { + // Claim before run.dispose() invokes provider code. Reentrant holder + // disposal then joins this exact child transaction instead of entering + // the provider wrapper twice before either memo is installed. + const claimed = Promise.withResolvers() + disposal = claimed.promise + this.childDisposals.set(callId, disposal) // The seam promises a Promise, but invoke inside an async boundary so a // contract-violating synchronous throw is contained exactly like a // rejected disposal and cannot break host quiescence. - disposal = (async () => { await run.dispose() })().then( - () => { this.finishChild(callId) }, + void (async () => { await run.dispose() })().then( + () => { + this.finishChild(callId) + claimed.resolve(undefined) + }, (error: unknown) => { this.ctx.logger.warn(`workflow-workerthread: child dispose failed: ${renderThrown(error)}`) this.finishChild(callId) + claimed.resolve(undefined) }, ) - this.childDisposals.set(callId, disposal) } return disposal } @@ -446,6 +543,7 @@ export class WorkerRun implements WorkflowRun { private finishChild(callId: number): void { this.children.delete(callId) this.childDisposals.delete(callId) + this.childCancellations.delete(callId) if (this.children.size === 0) { for (const waiter of this.quiescenceWaiters.splice(0)) waiter() } @@ -469,11 +567,16 @@ export class WorkerRun implements WorkflowRun { /** Drive both cancellation channels for every child already accepted by the host. */ private cancelChildren(reason: string): void { this.controller.abort(reason) - for (const run of this.children.values()) this.cancelChild(run, reason) + for (const [callId, run] of this.children) this.cancelChild(callId, run, reason) } - /** Contain one provider-owned cancel callback so every peer still receives cancellation. */ - private cancelChild(run: SubagentRun, reason?: string): void { + /** Invoke one provider-owned cancel callback at most once and contain its exception. */ + private cancelChild(callId: number, run: SubagentRun, reason?: string): void { + // Host fanout and the worker's FIFO-later ChildCancel relay are two paths + // to the same provider callback. The seam does not require cancel() to be + // idempotent, so claim the callId before invoking arbitrary provider code. + if (this.childCancellations.has(callId)) return + this.childCancellations.add(callId) try { run.cancel(reason) } catch (error: unknown) { @@ -482,12 +585,30 @@ export class WorkerRun implements WorkflowRun { } private onResult(result: WorkflowResult): void { + // The owned worker session sends one Result. Keep a late duplicate or a + // Result queued behind another terminal source completely side-effect-free. + if (this.terminalClaimed) return + // First-wins is decided when the Result message reaches the host. If no + // external cancellation was already in flight, this result won. Reaping a + // stray child below may synchronously reenter cancel() through provider + // callbacks, but that internal post-result cleanup must not retroactively + // rewrite the worker result that arrived first. + const cancellationWasRequested = this.cancelReason !== undefined + // Claim before either settlement-cleanup cancellation channel invokes + // provider code. A provider callback can reenter cancel() synchronously or + // from a queued microtask; once Result won, that losing cancellation must + // have no state, message, child-fanout, or grace-timer side effects. + this.terminalClaimed = true // The worker cancels handles it already received, but a fire-and-forget // child may still be waiting on readiness and therefore have no worker // handle. Drive BOTH provider-permitted channels from the host before the // workflow becomes externally settled. - if (this.cancelReason === undefined) this.cancelChildren('workflow settled') - if (this.cancelReason !== undefined && result.stopReason !== 'cancelled') { + if (!cancellationWasRequested) { + this.cancelChildren('workflow settled') + this.settleResult(result) + return + } + if (result.stopReason !== 'cancelled') { // The script settled while our cancel was crossing the thread boundary // — the seam-visible result had NOT settled when cancellation was // requested, so report cancelled (the vm drive()'s post-settle check, @@ -498,21 +619,39 @@ export class WorkerRun implements WorkflowRun { this.settleResult(result) } - /** An unexpected worker death (or the expected exit after termination). */ - private onWorkerDeath(message: string): void { - // Whatever the worker left behind must not leak — abort + dispose it all. - if (this.children.size > 0) this.reapChildren('workflow worker gone') - // The thread is gone: no more worker-authored agent-ends can arrive — - // pair every stranded start (a start that crossed between the grace - // force-settle and this exit included) before the run settles. - this.endStrandedAgents() - // settleResult no-ops on an already-settled run (the expected exit after - // a dispose's terminate lands here too). - if (this.cancelReason !== undefined) { - this.settleResult(this.cancelledResult(this.hostStarted)) - return + /** Process an error/messageerror/exit signal; `exit` also performs the final disposal sweep. */ + private onWorkerDeath(message: string, isExit: boolean): void { + if (!this.workerDeathObserved) { + // Close message admission BEFORE cleanup callbacks: Node can deliver a + // message queued before the crash after its `error` event. Treating the + // first death signal as a logical barrier prevents that late message + // from creating work or narrating after workflow/end. + this.workerDeathObserved = true + const outcomeWasClaimed = this.terminalClaimed + const cancellationWasRequested = this.cancelReason !== undefined + // When death is itself the terminal source, claim BEFORE child reap or + // synthesized observer callbacks. Either can reenter cancel(); a death + // that arrived first remains an error, while a cancellation already + // accepted before death remains cancelled. If Result/grace already won, + // preserve it while still performing prompt failure-time cleanup. + if (!outcomeWasClaimed) this.terminalClaimed = true + if (this.children.size > 0) this.reapChildren('workflow worker gone') + this.endStrandedAgents() + if (!outcomeWasClaimed) { + if (cancellationWasRequested) { + this.settleResult(this.cancelledResult(this.hostStarted)) + } else { + this.settleResult({ value: null, stopReason: 'error', error: message, agentsStarted: this.hostStarted }) + } + } } - this.settleResult({ value: null, stopReason: 'error', error: message, agentsStarted: this.hostStarted }) + if (!isExit) return + // `error` is not Node's physical delivery barrier: a queued message may + // precede `exit`. Admission is already closed, so this final sweep only + // joins/starts disposal for registry survivors; it deliberately does not + // repeat explicit provider cancellation. + for (const [callId, run] of [...this.children]) void this.disposeChild(callId, run) + this.endStrandedAgents() } /** @@ -531,11 +670,14 @@ export class WorkerRun implements WorkflowRun { /** * Synthesize the missing `agent-end` for every started-but-unpaired agent, * outcome `'cancelled'`: the reap cancels every child, and a real - * settlement racing the force-settle loses to the cancellation — the same - * first-wins override {@link onResult} applies to the run's own result. + * settlement racing the force-settle loses to that already-started external + * cancellation. The atomic terminal boundaries in {@link onResult} and + * {@link onWorkerDeath} deliberately exclude teardown callbacks as contenders. * Called where the worker can no longer speak (the grace force-settle, - * worker death), BEFORE settleResult, so the paired ends reach observers - * before `workflow/end`. + * worker death, physical exit). When grace/death is the terminal source it + * runs before settleResult, so already-known pairs precede `workflow/end`; + * after an earlier Result, exit cleanup may close a survivor afterward. + * The ledger preserves exactly-once pairing in both orders. */ private endStrandedAgents(): void { for (const info of [...this.liveAgents.values()]) { @@ -563,7 +705,11 @@ export class WorkerRun implements WorkflowRun { /** First settle wins; disarms the grace timer and releases the caller signal. */ private settleResult(result: WorkflowResult): void { + // Every current terminal source claims ownership before calling here; keep + // the fallback local so a future caller cannot resolve twice. + /* v8 ignore next -- defensive fallback outside the claimed state machine */ if (this.settled) return + this.terminalClaimed = true this.settled = true this.detachInputSignal() clearTimeout(this.graceTimer) diff --git a/packages/workflow/workflow-workerthread/src/runtime.ts b/packages/workflow/workflow-workerthread/src/runtime.ts index 2add6d537c..9b324c1118 100644 --- a/packages/workflow/workflow-workerthread/src/runtime.ts +++ b/packages/workflow/workflow-workerthread/src/runtime.ts @@ -83,7 +83,9 @@ function defaultLabel(prompt: string): string { /** * One live script execution inside the worker. Constructed per run by the * session; `drive()` is called exactly once and NEVER rejects — every failure - * becomes a {@link WorkflowResult} with a non-`completed` stop reason. + * becomes a {@link WorkflowResult} with a non-`completed` stop reason. After + * the session publishes that result it calls {@link reapAfterResult} exactly + * once to cancel any dropped child work without racing terminal publication. */ export class WorkflowExecution { /** 1-based count of `agent()` calls started (the `agentsStarted` result field). */ @@ -171,7 +173,8 @@ export class WorkflowExecution { * worker. Idempotent; the first reason wins. * @param reason - human-readable cause, carried on the CANCELLED error and * into child cancel RPCs. Required: every caller (the session's cancel - * message, drive()'s settle-reap) has a concrete reason. + * message and its post-result {@link reapAfterResult} call) has a concrete + * reason. */ cancel(reason: string): void { if (this.cancelReason !== undefined) return @@ -185,8 +188,9 @@ export class WorkflowExecution { * Run the script to settlement. Resolves — never rejects — with the run's * {@link WorkflowResult}: the materialized return value on `completed`, the * failure message on `error`, and `cancelled` when the script died of - * cancellation. After settlement, any stray children a script fired without - * awaiting are cancelled (their `agent()` wrappers dispose them via RPC). + * cancellation. This method only chooses the result; the session must publish + * it and then call {@link reapAfterResult}, so the terminal message precedes + * settlement-only child cancellation on the worker-to-host FIFO channel. * @returns the settled outcome — this promise NEVER rejects (the seam's * `result`-never-rejects contract); every failure maps to a variant. */ @@ -214,15 +218,19 @@ export class WorkflowExecution { // cannot throw — drive() resolving is the `result` never-rejects seam // contract. return { value: null, stopReason: 'error', error: renderThrown(error), agentsStarted: this.started } - } finally { - // Reap strays: a script that fired agent() calls without awaiting them - // leaves live children behind after settlement — cancel them all. (The - // per-call wrappers dispose each child; the contain() consumer keeps - // their rejections from going unhandled.) - if (this.cancelReason === undefined) this.cancel('workflow settled') } } + /** + * Reap strays only after the caller publishes the chosen terminal result. + * Aborting the controller synchronously sends child-cancel RPCs, so calling + * this before publication would let a provider callback reenter host + * cancellation and misclassify a result the script had already chosen. + */ + reapAfterResult(): void { + if (this.cancelReason === undefined) this.cancel('workflow settled') + } + /** * Attach a no-op rejection consumer WITHOUT changing what the caller * receives: if the script drops the promise (no await), cancellation cannot diff --git a/packages/workflow/workflow-workerthread/src/session.ts b/packages/workflow/workflow-workerthread/src/session.ts index b159892021..f6b14f0fca 100644 --- a/packages/workflow/workflow-workerthread/src/session.ts +++ b/packages/workflow/workflow-workerthread/src/session.ts @@ -14,6 +14,11 @@ * A `cancel` arriving instead of `go` still releases the gate: `drive()` * sees the cancelled state and settles without running the body. * + * Terminal ordering is Result first, settlement cleanup second. The session + * queues the Result message before asking the execution to reap stray children; + * MessagePort FIFO therefore lets the host atomically claim the result before a + * cleanup ChildCancel can invoke arbitrary provider code. + * * @module @deepseek-ai/dsh-workflow-workerthread/session */ @@ -208,5 +213,12 @@ export async function runWorkerSession(port: MessagePort, init: WorkerInit): Pro post(WorkerToHostType.Ready, {}) await gate.promise const result = await execution.drive() - post(WorkerToHostType.Result, { result }) + try { + // This post is the worker's terminal claim. Queue it BEFORE aborting stray + // children: MessagePort FIFO then guarantees the host claims Result before + // any settlement-only ChildCancel can invoke arbitrary provider callbacks. + post(WorkerToHostType.Result, { result }) + } finally { + execution.reapAfterResult() + } } diff --git a/packages/workflow/workflow-workerthread/tests/session.spec.ts b/packages/workflow/workflow-workerthread/tests/session.spec.ts index 00051ad56a..e2dab8948f 100644 --- a/packages/workflow/workflow-workerthread/tests/session.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/session.spec.ts @@ -281,6 +281,36 @@ describe('runWorkerSession over an in-process MessageChannel', () => { } }) + it('queues Result before settlement-only cancellation of a ready stray', async () => { + const host = fakeHost({ manual: true }) + const session = runWorkerSession(host.port, init(` + agent('ready stray') + return await agent('gate') + `)) + await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart)).toHaveLength(2) }) + const starts = host.ofType(WorkerToHostType.ChildStart) + const stray = starts.find(message => message.request.prompt === 'ready stray')! + const gate = starts.find(message => message.request.prompt === 'gate')! + host.send({ type: HostToWorkerType.ChildStarted, callId: stray.callId, childId: 'stray-child' }) + host.send({ type: HostToWorkerType.ChildStarted, callId: gate.callId, childId: 'gate-child' }) + host.send({ type: HostToWorkerType.ChildSettled, callId: gate.callId, result: text('gate completed') }) + + const result = await host.result() + await session + await vi.waitFor(() => { + expect(host.ofType(WorkerToHostType.ChildCancel).map(message => message.callId)).toContain(stray.callId) + }) + + expect(result).toMatchObject({ value: 'gate completed', stopReason: 'completed', agentsStarted: 2 }) + const resultIndex = host.messages.findIndex(message => message.type === WorkerToHostType.Result) + const strayCancelIndex = host.messages.findIndex(message => + message.type === WorkerToHostType.ChildCancel && message.callId === stray.callId) + expect(resultIndex).toBeGreaterThanOrEqual(0) + expect(strayCancelIndex).toBeGreaterThan(resultIndex) + host.send({ type: HostToWorkerType.ChildSettled, callId: stray.callId, result: { output: [], stopReason: 'aborted' } }) + host.close() + }) + it('an unparseable body settles an error result instead of dying without one (host pre-parse skew guard)', async () => { const host = fakeHost() await runWorkerSession(host.port, init('return (((')) diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 641b26518d..9e37e0bbed 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { fileURLToPath } from 'node:url' +import type { Worker } from 'node:worker_threads' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { AgentId } from '@deepseek-ai/dsh-agent' @@ -8,7 +9,7 @@ import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import type { WorkflowMeta, WorkflowResult, WorkflowResultInfo, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' import * as workerEngineModule from '../src/index.ts' -import WorkerWorkflowEngine, { HostToWorkerType, type Config } from '../src/index.ts' +import WorkerWorkflowEngine, { HostToWorkerType, WorkerToHostType, type Config } from '../src/index.ts' /** A minimal parent stand-in: the engine only threads it through to the provider. */ function fakeParent(): Agent { @@ -74,6 +75,8 @@ class StubProvider implements SubagentProvider { private readonly reply?: (request: SubagentStartRequest, index: number) => SubagentResult, private readonly disposeDelayMs = 0, private readonly deferStart = false, + private readonly onCancel?: (reason: string | undefined, index: number) => void, + private readonly onSignalAbort?: (reason: unknown, index: number) => void, ) {} start(request: SubagentStartRequest): SubagentRun { @@ -91,7 +94,10 @@ class StubProvider implements SubagentProvider { } this.runs.push(controlled) const index = this.runs.length - 1 - request.signal?.addEventListener('abort', () => { terminal.resolve({ output: [], stopReason: 'aborted' }) }, { once: true }) + request.signal?.addEventListener('abort', () => { + this.onSignalAbort?.(request.signal?.reason, index) + terminal.resolve({ output: [], stopReason: 'aborted' }) + }, { once: true }) if (!this.deferStart) readiness.resolve(undefined) if (this.reply) { const reply = this.reply @@ -103,6 +109,7 @@ class StubProvider implements SubagentProvider { result: terminal.promise, cancel: (reason?: string) => { controlled.cancelled = reason ?? 'cancelled' + this.onCancel?.(reason, index) terminal.resolve({ output: [], stopReason: 'aborted' }) }, dispose: () => { @@ -133,6 +140,8 @@ interface SetupOptions { manual?: boolean disposeDelayMs?: number deferStart?: boolean + onChildCancel?: (reason: string | undefined, index: number) => void + onChildSignalAbort?: (reason: unknown, index: number) => void } async function setup(options?: SetupOptions) { @@ -143,6 +152,8 @@ async function setup(options?: SetupOptions) { options?.manual ? undefined : options?.reply ?? (() => text('stub reply')), options?.disposeDelayMs ?? 0, options?.deferStart ?? false, + options?.onChildCancel, + options?.onChildSignalAbort, ) ctx.subagents.registerProvider(provider) // A fixed concurrency ceiling: the auto-resolved default is machine-derived @@ -821,6 +832,153 @@ describe('dsh-workflow-workerthread', () => { expect(provider.runs[0]!.disposeCalls).toBe(1) }) + it('post-result child cleanup cannot reentrantly rewrite a completed workflow as cancelled', async () => { + let cancelCallbacks = 0 + let signalCallbacks = 0 + const { ctx, parent, provider } = await setup({ + manual: true, + deferStart: true, + onChildCancel: () => { + cancelCallbacks += 1 + // The first callback is host cleanup for the already-arrived Result. + // Reentering cancel() here is later than that message and must not + // retroactively win the result race. Its nested child cancel is + // intentionally ignored to keep the adversarial callback finite. + if (cancelCallbacks === 1) handle.cancel('reentrant child cleanup') + }, + onChildSignalAbort: () => { + signalCallbacks += 1 + handle.cancel('reentrant signal cleanup') + }, + }) + const handle = ctx.workflows.start({ + ...scripted(` + agent('readiness-pending stray') + return 'completed first' + `), + parent, + }) + + const result = await handle.result + + expect(result).toMatchObject({ value: 'completed first', stopReason: 'completed', agentsStarted: 1 }) + expect(signalCallbacks).toBe(1) + expect(cancelCallbacks).toBe(1) + // Readiness crossing after Result is a terminal-admission refusal: no + // ChildStarted/lifecycle publication, and host-owned disposal begins. + provider.runs[0]!.publish() + await waitFor(() => { expect(provider.runs[0]!.disposed).toBe(true) }, 1000) + expect(cancelCallbacks).toBe(1) + await handle.dispose() + await ctx.fiber.dispose() + }) + + it('late readiness after completed disposal cannot cancel or dispose the retired child twice', async () => { + let explicitCancels = 0 + const lifecycle: string[] = [] + const { ctx, parent, provider } = await setup({ + manual: true, + deferStart: true, + onChildCancel: () => { explicitCancels += 1 }, + }) + ctx.on('workflow/agent-start', () => { lifecycle.push('start') }) + ctx.on('workflow/agent-end', () => { lifecycle.push('end') }) + const handle = ctx.workflows.start({ + ...scripted("agent('retired readiness')\nreturn 'done'"), + parent, + }) + + await expect(handle.result).resolves.toMatchObject({ stopReason: 'completed' }) + expect(explicitCancels).toBe(1) + await handle.dispose() + expect(provider.runs[0]!.disposed).toBe(true) + expect(provider.runs[0]!.disposeCalls).toBe(1) + + // The Promise may still fulfill after its run left every host ledger. + // Refusal replies once but must not recreate the deleted cancel gate. + provider.runs[0]!.publish() + await Promise.resolve() + await Promise.resolve() + expect(explicitCancels).toBe(1) + expect(provider.runs[0]!.disposeCalls).toBe(1) + expect(lifecycle).toEqual([]) + await ctx.fiber.dispose() + }) + + it.each([ + ['synchronous', (cancel: () => void) => { cancel() }], + ['microtask', (cancel: () => void) => { queueMicrotask(cancel) }], + ])('a ready stray %s cleanup callback cannot beat the earlier worker result claim', async (_mode, reenter) => { + let reentered = false + const explicitCancels = new Map() + const { ctx, parent, provider } = await setup({ + manual: true, + onChildCancel: (_reason, index) => { + explicitCancels.set(index, (explicitCancels.get(index) ?? 0) + 1) + if (index !== 0 || reentered) return + reentered = true + reenter(() => { handle.cancel('reentered from child cleanup') }) + }, + }) + const handle = ctx.workflows.start({ + ...scripted(` + agent('ready stray') + return await agent('gate') + `), + parent, + }) + const cancelChildSpy = vi.spyOn(handle as unknown as { + cancelChild(callId: number, run: SubagentRun, reason?: string): void + }, 'cancelChild') + await waitFor(() => { expect(provider.runs).toHaveLength(2) }) + provider.runs[1]!.settle(text('gate completed')) + + const result = await handle.result + await Promise.resolve() + + expect(result).toMatchObject({ value: 'gate completed', stopReason: 'completed', agentsStarted: 2 }) + expect(reentered).toBe(true) + // The host claim and worker's FIFO-later ChildCancel both reach the + // routing gate, but the provider callback is not an idempotent seam: + // invoke it exactly once for this callId. + await waitFor(() => { + expect(cancelChildSpy.mock.calls.filter(([callId]) => callId === 1)).toHaveLength(2) + }, 1000) + expect(explicitCancels.get(0)).toBe(1) + cancelChildSpy.mockRestore() + await handle.dispose() + await ctx.fiber.dispose() + }) + + it('a duplicate Result after the terminal claim cannot repeat cleanup or rewrite the outcome', async () => { + let explicitCancels = 0 + const { ctx, parent, provider } = await setup({ + manual: true, + onChildCancel: (_reason, index) => { if (index === 0) explicitCancels += 1 }, + }) + const handle = ctx.workflows.start({ + ...scripted("agent('stray')\nawait new Promise(() => {})"), + parent, + }) + await waitFor(() => { expect(provider.runs).toHaveLength(1) }) + const worker = (handle as unknown as { worker: Worker }).worker + + worker.emit('message', { + type: WorkerToHostType.Result, + result: { value: 'first', stopReason: 'completed', agentsStarted: 1 }, + }) + worker.emit('message', { + type: WorkerToHostType.Result, + result: { value: 'late', stopReason: 'completed', agentsStarted: 1 }, + }) + + await expect(handle.result).resolves.toMatchObject({ value: 'first', stopReason: 'completed' }) + expect(explicitCancels).toBe(1) + await handle.dispose() + expect(explicitCancels).toBe(1) + await ctx.fiber.dispose() + }) + it('contains a throwing child cancel and still settles after cancelling peer strays', async () => { const ctx = new Context() await ctx.plugin(SubagentService) @@ -918,6 +1076,198 @@ describe('dsh-workflow-workerthread', () => { await handle.dispose() }, 15_000) + it.each(['fulfills', 'rejects'] as const)('provider.start() reentrant cancellation refuses the run when readiness later %s', async (readinessOutcome) => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const readiness = Promise.withResolvers() + let starts = 0 + let explicitCancels = 0 + let disposals = 0 + let sawAbortedSignal = false + const lifecycle: string[] = [] + const provider: SubagentProvider = { + name: 'start-reentry', + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, + inheritsParentContext: false, + start: (request) => { + starts += 1 + // This arbitrary provider callback runs before onChildStart can put + // the returned run in its registry. Cancellation must be rechecked + // after return instead of trusting the pre-start admission check. + handle.cancel('provider start reentered cancellation') + sawAbortedSignal = request.signal?.aborted === true + return { + id: AgentId('start-reentry-child'), + started: readiness.promise, + result: new Promise(() => { /* refusal owns teardown */ }), + // Deliberately honors only the explicit channel. It must still be + // reached promptly even though the first host fanout saw no run. + cancel: () => { explicitCancels += 1 }, + dispose: () => { + disposals += 1 + return Promise.resolve() + }, + } + }, + } + ctx.subagents.registerProvider(provider) + await ctx.plugin(WorkerWorkflowEngine, { + provider: 'start-reentry', + maxConcurrentAgents: 2, + disposeGraceMs: 30_000, + }) + ctx.on('workflow/agent-start', () => { lifecycle.push('start') }) + ctx.on('workflow/agent-end', () => { lifecycle.push('end') }) + const handle = ctx.workflows.start({ + ...scripted("await agent('reentrant provider')\nreturn 'unreachable'"), + parent: fakeParent(), + }) + + await waitFor(() => { expect(starts).toBe(1) }) + // Either later readiness settlement must not answer the already-refused + // start again or emit a workflow lifecycle pair. + if (readinessOutcome === 'fulfills') readiness.resolve(undefined) + else readiness.reject(new Error('late readiness rejection after refusal')) + let result: WorkflowResult | undefined + void handle.result.then((value) => { result = value }) + await waitFor(() => { + expect(explicitCancels).toBe(1) + expect(disposals).toBe(1) + expect(result?.stopReason).toBe('cancelled') + }, 1000) + expect(sawAbortedSignal).toBe(true) + expect(lifecycle).toEqual([]) + await handle.dispose() + expect(explicitCancels).toBe(1) + expect(disposals).toBe(1) + await ctx.fiber.dispose() + }) + + it('claims workflow and child disposal before a raw provider disposer reenters handle.dispose()', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const terminal = Promise.withResolvers() + const observed: { reentrant?: Promise } = {} + let starts = 0 + let rawDisposeCalls = 0 + const provider: SubagentProvider = { + name: 'dispose-reentry', + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, + inheritsParentContext: false, + start: () => { + starts += 1 + return { + id: AgentId('dispose-reentry-child'), + started: Promise.resolve(), + result: terminal.promise, + cancel: () => { terminal.resolve({ output: [], stopReason: 'aborted' }) }, + dispose: () => { + rawDisposeCalls += 1 + observed.reentrant = handle.dispose() + return Promise.resolve() + }, + } + }, + } + ctx.subagents.registerProvider(provider) + await ctx.plugin(WorkerWorkflowEngine, { provider: 'dispose-reentry', maxConcurrentAgents: 2 }) + const handle = ctx.workflows.start({ + ...scripted("await agent('live child')\nreturn 'unreachable'"), + parent: fakeParent(), + }) + await waitFor(() => { expect(starts).toBe(1) }) + + const disposal = handle.dispose() + + expect(observed.reentrant).toBe(disposal) + await disposal + expect(rawDisposeCalls).toBe(1) + await expect(handle.result).resolves.toMatchObject({ stopReason: 'cancelled' }) + await ctx.fiber.dispose() + }) + + it('claims worker-originated child disposal before its raw disposer reenters holder disposal', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const terminal = Promise.withResolvers() + const observed: { reentrant?: Promise } = {} + let starts = 0 + let rawDisposeCalls = 0 + const provider: SubagentProvider = { + name: 'child-dispose-reentry', + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, + inheritsParentContext: false, + start: () => { + starts += 1 + return { + id: AgentId('child-dispose-reentry-child'), + started: Promise.resolve(), + result: terminal.promise, + cancel: () => { terminal.resolve({ output: [], stopReason: 'aborted' }) }, + dispose: () => { + rawDisposeCalls += 1 + // This begins holder disposal from the worker's ChildDispose + // callback, before any public handle.dispose() call exists. + observed.reentrant = handle.dispose() + return Promise.resolve() + }, + } + }, + } + ctx.subagents.registerProvider(provider) + await ctx.plugin(WorkerWorkflowEngine, { provider: 'child-dispose-reentry', maxConcurrentAgents: 2 }) + const handle = ctx.workflows.start({ + ...scripted("return await agent('settling child')"), + parent: fakeParent(), + }) + const finishChildSpy = vi.spyOn(handle as unknown as { + finishChild(callId: number): void + }, 'finishChild') + await waitFor(() => { expect(starts).toBe(1) }) + + terminal.resolve({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' }) + + await waitFor(() => { expect(observed.reentrant).toBeDefined() }, 1000) + await observed.reentrant + expect(rawDisposeCalls).toBe(1) + expect(finishChildSpy.mock.calls.filter(([callId]) => callId === 1)).toHaveLength(1) + finishChildSpy.mockRestore() + await expect(handle.result).resolves.toMatchObject({ stopReason: 'cancelled' }) + await ctx.fiber.dispose() + }) + + it('a grace-terminated worker reaps its child on exit without waiting for consumer dispose()', async () => { + const { ctx, parent, provider } = await setup({ + manual: true, + config: { provider: 'stub', maxConcurrentAgents: 2, disposeGraceMs: 100 }, + }) + const handle = ctx.workflows.start({ + // Let child-start cross, then make the worker unable to process its + // Cancel message. Grace settles the result and terminates the thread; + // that exit must independently own the host registry's disposal pass. + ...scripted(` + agent('survives until exit reap') + for (let i = 0; i < 20; i++) await null + const end = Date.now() + 1500 + while (Date.now() < end) {} + return 'unreachable' + `), + parent, + }) + await waitFor(() => { expect(provider.runs).toHaveLength(1) }) + + handle.cancel('force termination') + const result = await handle.result + + expect(result.stopReason).toBe('cancelled') + // Deliberately assert before handle.dispose(): host-owned worker exit, + // not consumer courtesy, is responsible for this resource guarantee. + await waitFor(() => { expect(provider.runs[0]!.disposed).toBe(true) }, 1000) + expect(provider.runs[0]!.disposeCalls).toBe(1) + await handle.dispose() + await ctx.fiber.dispose() + }, 15_000) + it('dispose() on a wedged worker host-drives child disposal inside the grace: it returns with the children DISPOSED, not with their teardown still in flight', async () => { const { ctx, parent, provider } = await setup({ manual: true, @@ -1045,23 +1395,71 @@ describe('dsh-workflow-workerthread', () => { }) describe('worker death', () => { + it('the first death signal closes admission to messages Node delivers before exit', async () => { + const { ctx, parent, provider } = await setup({ manual: true }) + const phases: string[] = [] + ctx.on('workflow/phase', (_info, title) => { phases.push(title) }) + const handle = ctx.workflows.start({ + ...scripted('await new Promise(() => {})'), + parent, + }) + const worker = (handle as unknown as { worker: Worker }).worker + + // Node may physically emit error -> queued message -> exit. Reproduce + // that ordering deterministically at the Worker event boundary: the + // late protocol data must not create work, narrate, or rewrite error. + worker.emit('error', new Error('synthetic error-before-message')) + worker.emit('message', { type: WorkerToHostType.Phase, title: 'late phase' }) + worker.emit('message', { + type: WorkerToHostType.ChildStart, + callId: 999, + request: { prompt: 'late child' }, + }) + worker.emit('message', { + type: WorkerToHostType.Result, + result: { value: 'late', stopReason: 'completed', agentsStarted: 1 }, + }) + + const result = await handle.result + expect(result.stopReason).toBe('error') + expect(result.error).toContain('synthetic error-before-message') + expect(provider.runs).toHaveLength(0) + expect(phases).toEqual([]) + await handle.dispose() + await ctx.fiber.dispose() + }) + it('a worker that exits before settling reports an error result and reaps its children', async () => { const ctx = new Context() await ctx.plugin(SubagentService) // The child's dispose() REJECTS on top of the worker death: the reap // must contain it (warn, not crash) while still emptying the registry. const cancelled: string[] = [] + const signalAborts: unknown[] = [] const provider: SubagentProvider = { name: 'doomed', capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, inheritsParentContext: false, - start: () => ({ - id: AgentId('doomed-child'), - started: Promise.resolve(), - result: new Promise(() => { /* never settles; the reap is the teardown */ }), - cancel: (reason?: string) => { cancelled.push(reason ?? 'cancelled') }, - dispose: () => Promise.reject(new Error('dispose exploded during reap')), - }), + start: (request) => { + request.signal?.addEventListener('abort', () => { + signalAborts.push(request.signal?.reason) + // The death claim precedes the shared-signal fanout. This + // synchronous callback cannot turn death into cancellation. + handle.cancel('reentered from worker-death signal cleanup') + }, { once: true }) + return { + id: AgentId('doomed-child'), + started: Promise.resolve(), + result: new Promise(() => { /* never settles; the reap is the teardown */ }), + cancel: (reason?: string) => { + cancelled.push(reason ?? 'cancelled') + // Exercise the later microtask case too: terminal ownership + // remains closed after the death callback returns. + queueMicrotask(() => { handle.cancel('reentered from worker-death child cleanup') }) + }, + dispose: () => Promise.reject(new Error('dispose exploded during reap')), + } + }, } ctx.subagents.registerProvider(provider) await ctx.plugin(WorkerWorkflowEngine, { provider: 'doomed', maxConcurrentAgents: 2 }) @@ -1089,7 +1487,12 @@ describe('dsh-workflow-workerthread', () => { expect(runEnds).toEqual([{ stopReason: 'error', error: result.error, agentsStarted: 1 }]) // Result already settled — this is the reap's promptness, not a // cold-start race; tight explicit bound (see the helper's doc comment). - await waitFor(() => { expect(cancelled.length).toBe(1) }, 1000) + await waitFor(() => { + expect(signalAborts).toEqual(['workflow worker gone']) + expect(cancelled).toEqual(['workflow worker gone']) + }, 1000) + await Promise.resolve() + expect(result.stopReason).toBe('error') await handle.dispose() }, 15_000) From 48067c3a7a2c7ca274af47c6ea4f684e80573a7b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:33:29 +0800 Subject: [PATCH 44/64] fix(subagent): validate depth config at load --- docs/config-catalog.md | 5 +++-- packages/subagent/tool-subagent/README.md | 2 +- packages/subagent/tool-subagent/src/index.ts | 21 ++++++++++++++++--- .../tool-subagent/tests/tool-subagent.spec.ts | 10 +++++++++ 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index dc766ae25f..30a4c8a1a3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -893,8 +893,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 } diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 715fb0b9db..9aed426ac0 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -17,7 +17,7 @@ The tool description and the `prompt` parameter description are DERIVED from the | `agentOptions` | Default per-child `{ model? }` applied to every spawned child. | | `persona` | Per-child persona that shadows the deployment persona; requires the provider's `persona` capability. | | `toolFilter` | Per-child `{ allow?, deny? }` restriction over global tools; requires the provider's `toolFilter` capability. | -| `maxDepth` | Maximum delegation depth; requires the provider's `depthLimit` capability. | +| `maxDepth` | Maximum delegation depth; a non-negative safe integer validated when this plugin loads. Requires the provider's `depthLimit` capability. | ## Lifecycle (synchronous collect) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 4f2a1bc33d..c6dc808068 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -84,8 +84,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 } @@ -115,9 +116,20 @@ export const Config: z = z.object({ allow: z.array(z.string()).default(undefined as unknown as string[]), deny: z.array(z.string()).default(undefined as unknown as string[]), }).default(undefined as unknown as { allow: string[]; deny: string[] }), - maxDepth: z.number(), + maxDepth: z.natural().max(Number.MAX_SAFE_INTEGER), }) +/** Reject a recursion cap that cannot represent an exact delegation depth. */ +function assertMaxDepth(maxDepth: number | undefined): void { + if (maxDepth !== undefined && ( + !Number.isSafeInteger(maxDepth) + || maxDepth < 0 + || Object.is(maxDepth, -0) + )) { + throw new Error('tool-subagent: `maxDepth` must be a non-negative safe integer') + } +} + /** * Flatten a child's final output blocks to text for the tool result. The child * may return non-text blocks; this cut surfaces the text content (the common @@ -188,6 +200,9 @@ export function providerWording(inherits: boolean): { description: string; promp } export function apply(ctx: Context, config: Config): void { + // Keep misconfiguration at plugin load even when a caller invokes apply() + // directly and bypasses Schemastery's natural/max metadata. + assertMaxDepth(config.maxDepth) // Misconfiguration fails loud AT LOAD (the check is self-contained): an // explicit `toolFilter: {}` would otherwise pass the capability gate and // kill every delegation later, in the child-setup `restrict({})` throw. diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 95d26dabdf..194c63997c 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -493,6 +493,16 @@ describe('dsh-tool-subagent', () => { expect(seen?.maxDepth).toBe(2) }) + it.each([ + { label: 'a negative integer', value: -1 }, + { label: 'a fractional number', value: 1.5 }, + { label: 'negative zero', value: -0 }, + { label: 'an unsafe integer', value: Number.MAX_SAFE_INTEGER + 1 }, + ])('rejects maxDepth=$label when the plugin loads', async ({ value }) => { + await expect(setup({ provider: 'mock', maxDepth: value })) + .rejects.toThrow() + }) + it('a partial toolFilter (deny only) does not materialize an empty allow-list (deny-all trap)', async () => { let seen: { toolFilter?: { allow?: string[]; deny?: string[] } } | undefined const ctx = new Context() From 875c3d62d8fff24fb1a2754a99a2b2b946f4be67 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:39:55 +0800 Subject: [PATCH 45/64] fix(workflow): reap children after settled dispose --- .../workflow-workerthread/src/host.ts | 7 +++- .../tests/workflow-workerthread.spec.ts | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index 7e87c14fae..ef5fdcbfac 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -279,7 +279,12 @@ export class WorkerRun implements WorkflowRun { void (async () => { this.detachInputSignal() this.cancel('workflow disposed') - for (const [callId, run] of [...this.children]) void this.disposeChild(callId, run) + // cancel() deliberately becomes a no-op after terminal settlement, but + // disposal still owns every registered child. Reap independently so an + // already-settled workflow cannot wait on child quiescence before it has + // started the surviving children's disposals. On an unsettled run this + // joins the cancel path through the per-call cancellation/disposal gates. + this.reapChildren('workflow disposed') await Promise.race([ (async () => { await this.result diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 9e37e0bbed..ec2f458d45 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -755,6 +755,38 @@ describe('dsh-workflow-workerthread', () => { expect(provider.runs[0]!.disposed).toBe(true) }) + it('dispose() reaps a registered stray after result settlement even when the worker cannot relay disposal', async () => { + const { ctx, parent, provider } = await setup({ + manual: true, + config: { provider: 'stub', disposeGraceMs: 30_000 }, + }) + const handle = ctx.workflows.start({ + ...scripted("agent('stray')\nawait new Promise(() => {})"), + parent, + }) + await waitFor(() => { expect(provider.runs).toHaveLength(1) }) + + // Claim the host result while the real worker remains wedged, so it can + // send neither ChildDispose nor an exit. This leaves the accepted child + // in the host registry when public disposal begins. + const worker = (handle as unknown as { worker: Worker }).worker + worker.emit('message', { + type: WorkerToHostType.Result, + result: { value: 'synthetic completion', stopReason: 'completed', agentsStarted: 1 }, + }) + await expect(handle.result).resolves.toMatchObject({ stopReason: 'completed' }) + expect(provider.runs[0]!.disposed).toBe(false) + + const disposal = handle.dispose() + // A 30-second grace makes this assertion mutation-sensitive: without the + // settled-path host reap, no worker message can start child disposal and + // this bounded wait fails long before the grace fallback. + await waitFor(() => { expect(provider.runs[0]!.disposed).toBe(true) }, 1000) + await disposal + expect(provider.runs[0]!.disposeCalls).toBe(1) + await ctx.fiber.dispose() + }) + it('the settle-reap fires the request signal too: a provider honoring ONLY the signal winds its stray down promptly', async () => { const ctx = new Context() await ctx.plugin(SubagentService) From d427478c44df4dcbd0a85aa8b69069927a02d1a3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:48:20 +0800 Subject: [PATCH 46/64] fix(subagent): validate direct depth boundaries --- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 8 ++--- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 8 ++--- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/src/index.ts | 16 +++++++--- .../tests/subagent-inprocess.spec.ts | 32 +++++++++++++++++++ packages/subagent/subagent/README.md | 1 + packages/subagent/subagent/src/index.ts | 27 ++++++++++++---- packages/subagent/subagent/src/types.ts | 5 +-- .../subagent/subagent/tests/service.spec.ts | 4 +++ packages/subagent/tool-subagent/src/index.ts | 14 ++------ .../tool-subagent/tests/tool-subagent.spec.ts | 3 ++ 13 files changed, 88 insertions(+), 36 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 30a4c8a1a3..8fd8e3379b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -903,7 +903,7 @@ export interface Config { Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) -Source: [`packages/subagent/tool-subagent/src/index.ts:44`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:45`](../packages/subagent/tool-subagent/src/index.ts) ## `@deepseek-ai/dsh-tool-web` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 3e228405ee..d13f88bc2d 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -317,7 +317,7 @@ A started subagent run settled — emitted when SubagentRun.result resolves (any 'subagent/end'(this: Scoped, info: SubagentRunEndInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:115`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:134`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -327,7 +327,7 @@ A provider became resolvable in the SubagentService registry. Consumers that der 'subagent/provider-added'(provider: SubagentProvider): void ``` -Source: [`packages/subagent/subagent/src/index.ts:76`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:95`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -337,7 +337,7 @@ A provider left the registry (its plugin's fiber was disposed — an unload or a 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:87`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:106`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit @@ -347,7 +347,7 @@ A subagent run started — emitted only after SubagentRun.started fulfills, when 'subagent/start'(this: Scoped, info: SubagentRunInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:102`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:121`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 1122137e1a..f5946a7e4d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -250,7 +250,7 @@ list(): string[] start(name: string, request: SubagentStartRequest): SubagentRun ``` -Source: [`packages/subagent/subagent/src/index.ts:161`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:180`](../../packages/subagent/subagent/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index ec867aa709..b8bcde4c9a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -31,10 +31,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:96`](../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:132`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | | `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:138`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:115`](../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:76`](../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:87`](../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:102`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:134`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:95`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:106`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:121`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:46`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:56`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:176`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 1e812ce60d..b96cc811ad 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -8,7 +8,7 @@ The shared **in-process subagent run driver**. A library with no provider or imp Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`): -1. reads every public request and seed field once before asynchronous owner setup: the parent and signal remain identity capabilities, while tool filter, seed, agent options, output schema, and prompt are each materialized by the shared one-pass lossless-JSON snapshot. It computes child depth = `depthOf(parent) + 1`, rejects `request.maxDepth` overflow with `SubagentDepthError`, reports an invalid schema as `OutputSchemaError`, and derives both the child prefix and `seedLength` from the same detached seed; +1. reads every public request and seed field once before asynchronous owner setup: the parent and signal remain identity capabilities, while tool filter, seed, agent options, output schema, and prompt are each materialized by the shared one-pass lossless-JSON snapshot. It rejects a malformed `request.maxDepth`, validates the parent's `subagentDepth`, computes child depth = `depthOf(parent) + 1`, rejects cap overflow with `SubagentDepthError`, reports an invalid schema as `OutputSchemaError`, and derives both the child prefix and `seedLength` from the same detached seed; 2. first installs provider ownership, then attaches the request abort listener and creates one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves no child or orphaned listener. Async child creation goes through that fiber's `ctx.agents` service with fresh IDs, lineage/seed, inherited model, and an unpublished setup transaction for persona, tool restriction, and structured output. Parent teardown, provider teardown, manual `run.dispose()`, and cancellation before readiness all dispose this exact node, preventing publication after it becomes inactive and sharing the same quiescence boundary. `startInProcessRun` still returns its `SubagentRun` immediately: `run.started` resolves only after `ctx.agents.create()` has published the child and rejects when pre-readiness cancellation rolls the transaction back; 3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts); there is deliberately NO re-prompt for a structured child that finished cleanly without calling `structured_output` — the shortfall maps to an `error` result for the parent; 4. reads the result, scoped to the child's OWN events (everything at or after `seedLength`, so a seeded child that produced no message of its own never returns the seeded parent's last message): the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. A structured run surfaces the captured value as `result.structured`; a structured child that finished cleanly WITHOUT ever capturing settles `error` (a clean finish without the demanded result is a failure, not a success with a missing field). diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 511b686c13..e0ad53c486 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -21,6 +21,7 @@ import { AgentId, type Agent, type AgentHandle, type AgentOptions } from '@deeps import { SessionId, snapshotJsonValue, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools' +import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' import { attachStructuredRuntime, @@ -42,20 +43,26 @@ declare module '@deepseek-ai/dsh-agent' { * (config/ACP-created) agent, parent depth + 1 for a subagent. Set by the * in-process backends on every child they create so a nested spawn reads its * parent's depth from `parent.options.subagentDepth` and the `depthLimit` - * capability can cap the tree. Merge-extensible field (the seam owns it; the - * loop neither sets nor reads it). + * capability can cap the tree. When present it is a non-negative safe + * integer. Merge-extensible field (the seam owns it; the loop neither sets + * nor reads it). */ subagentDepth?: number } } /** - * Read an agent's delegation depth (absent ⇒ a top-level agent, depth 0). + * Read an agent's delegation depth (absent ⇒ a top-level agent, depth 0), + * rejecting a malformed stored value instead of letting it disable comparison. * @param agent - the agent whose options may carry `subagentDepth`. * @returns 0 for a top-level agent, its parent's depth + 1 for a subagent. */ export function depthOf(agent: Agent): number { - return agent.options.subagentDepth ?? 0 + const depth = agent.options.subagentDepth ?? 0 + if (!Number.isSafeInteger(depth) || depth < 0 || Object.is(depth, -0)) { + throw new TypeError('agent subagentDepth must be a non-negative safe integer') + } + return depth } /** Thrown when a spawn would exceed the request's `maxDepth` cap. */ @@ -139,6 +146,7 @@ export function startInProcessRun( const inputPrompt = request.prompt const inputAgentOptions = request.agentOptions const inputSeed = options.seed + assertSubagentMaxDepth(inputMaxDepth) const toolFilter = inputToolFilter === undefined ? undefined : snapshotJsonValue(inputToolFilter) if (inputToolFilter !== undefined && toolFilter === undefined) { throw new TypeError('subagent tool filter must be losslessly JSON-serializable') diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index cb394d8d95..db37c974b3 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -46,9 +46,41 @@ describe('depthOf', () => { const withDepth = { options: { subagentDepth: 3 } } as unknown as Agent expect(depthOf(withDepth)).toBe(3) }) + + it.each([ + { label: 'a string', value: '1' as unknown as number }, + { label: 'NaN', value: Number.NaN }, + { label: 'positive infinity', value: Number.POSITIVE_INFINITY }, + { label: 'negative infinity', value: Number.NEGATIVE_INFINITY }, + { label: 'a fraction', value: 1.5 }, + { label: 'a negative integer', value: -1 }, + { label: 'negative zero', value: -0 }, + { label: 'an unsafe integer', value: Number.MAX_SAFE_INTEGER + 1 }, + ])('rejects subagentDepth=$label', ({ value }) => { + const agent = { options: { subagentDepth: value } } as unknown as Agent + expect(() => depthOf(agent)).toThrow('agent subagentDepth must be a non-negative safe integer') + }) }) describe('startInProcessRun', () => { + it.each([ + { label: 'a string', value: '1' as unknown as number }, + { label: 'NaN', value: Number.NaN }, + { label: 'positive infinity', value: Number.POSITIVE_INFINITY }, + { label: 'negative infinity', value: Number.NEGATIVE_INFINITY }, + { label: 'a fraction', value: 1.5 }, + { label: 'a negative integer', value: -1 }, + { label: 'negative zero', value: -0 }, + { label: 'an unsafe integer', value: Number.MAX_SAFE_INTEGER + 1 }, + ])('rejects maxDepth=$label before acquiring run ownership', async ({ value }) => { + const { ctx, parent } = await setup([]) + expect(() => startInProcessRun(ctx, { + prompt: [{ type: 'text', text: 'must never start' }], + parent, + maxDepth: value, + }, {})).toThrow('subagent maxDepth must be a non-negative safe integer') + }) + it('rejects a non-JSON prompt before acquiring any run ownership', async () => { const { ctx, parent } = await setup([]) expect(() => startInProcessRun(ctx, { diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index e29706bd8b..e98d8cd8de 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -19,6 +19,7 @@ Unlike the bash seam (one executor per context, second load throws), **multiple | Member | Semantics | |---|---| | `registerProvider(provider)` | Read and validate the name, capability object and four boolean flags, `inheritsParentContext`, and `start` callback exactly once, then register a frozen acceptance snapshot under the accepted name. Malformed fixed fields fail loud before registration; later caller mutation cannot change registry behavior or HMR cleanup, while `start` stays bound to the original provider receiver. Throws `SubagentError('DUPLICATE_PROVIDER')` on a name clash. Effect-scoped (HMR-safe); returns the disposer. | +| `assertSubagentMaxDepth(value)` | Shared runtime boundary for recursion caps. Accepts absence or a non-negative safe integer; rejects fractions, non-finite numbers, negative values, negative zero, and unsafe integers. The service, direct in-process driver, and model-facing config adapter all use it. | | `getProvider(name)` | Look up the frozen registry snapshot (`undefined` if absent). | | `list()` | Registered provider names (insertion order). | | `start(name, request)` | Resolve the provider (`NO_PROVIDER` if absent), read every caller field once into one acceptance snapshot, validate every requested START-TIME capability and scalar value before any child is created, and materialize prompt/schema/options/filter data through a single-pass lossless-JSON snapshot before delegating to `provider.start`. Acquire the provider run's disposer before reading the rest of its handle, then return a frozen service-owned wrapper whose fields are captured once, whose methods remain bound to the provider handle, and whose `result` is one detached, deeply frozen normalization shared by the caller and telemetry. The wrapper claims its shared disposal promise before invoking raw provider code, so synchronous reentry and ordinary repeats join one provider call; a raw disposer that directly returns that same reentrant wrapper promise is rejected as a cyclic provider contract instead of hanging forever. Malformed handle access/binding starts rollback before the synchronous fault escapes; malformed terminal data rejects only after rollback reaches quiescence. Emit `subagent/start` only after `run.started` fulfills and the paired `subagent/end` after that started run settles; a pre-publication readiness rejection emits neither. | diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 74b7df171b..ba44354358 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -58,6 +58,25 @@ export type { SubagentStopReasonMap, } from './types.ts' +/** + * Reject a recursion cap that cannot represent an exact delegation depth. + * Undefined means the caller did not request a cap and is accepted. The + * service, direct in-process driver, and model-facing config adapter share this + * boundary so no entry path can turn a fractional or non-finite value into an + * ineffective limit. + * @param maxDepth - the optional runtime value to validate. + */ +export function assertSubagentMaxDepth(maxDepth: unknown): void { + if (maxDepth !== undefined && ( + typeof maxDepth !== 'number' + || !Number.isSafeInteger(maxDepth) + || maxDepth < 0 + || Object.is(maxDepth, -0) + )) { + throw new TypeError('subagent maxDepth must be a non-negative safe integer') + } +} + declare module 'cordis' { interface Context { subagents: SubagentService @@ -309,13 +328,7 @@ export class SubagentService extends Service { const input = this.snapshotStartRequest(request) const parent = input.parent this.assertCapabilities(provider, input) - if (input.maxDepth !== undefined && ( - !Number.isSafeInteger(input.maxDepth) - || input.maxDepth < 0 - || Object.is(input.maxDepth, -0) - )) { - throw new TypeError('subagent maxDepth must be a non-negative safe integer') - } + assertSubagentMaxDepth(input.maxDepth) if (input.persona !== undefined && typeof input.persona !== 'string') { throw new TypeError('subagent persona must be a string') } diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 003d6a6a3d..97efd4661f 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -69,8 +69,9 @@ export interface SubagentStartRequest { */ outputSchema?: StructuredOutputSchema /** - * Optional recursion cap (max delegation depth below this child). Requires - * {@link SubagentCapabilities.depthLimit}; rejected at start otherwise. + * Optional recursion cap (max delegation depth below this child). Must be a + * non-negative safe integer. Requires {@link SubagentCapabilities.depthLimit}; + * rejected at start otherwise. */ maxDepth?: number /** diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index e9b66ce158..faf4bb00b3 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -431,10 +431,14 @@ describe('SubagentService', () => { }) it.each([ + { label: 'a string', value: '1' as unknown as number }, { label: 'NaN', value: Number.NaN }, + { label: 'positive infinity', value: Number.POSITIVE_INFINITY }, + { label: 'negative infinity', value: Number.NEGATIVE_INFINITY }, { label: 'a fraction', value: 1.5 }, { label: 'a negative integer', value: -1 }, { label: 'negative zero', value: -0 }, + { label: 'an unsafe integer', value: Number.MAX_SAFE_INTEGER + 1 }, ])('rejects maxDepth=$label before the provider starts', async ({ value }) => { const ctx = new Context() await ctx.plugin(SubagentService) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index c6dc808068..ca7f20d23d 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -35,6 +35,7 @@ import z from 'schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' import type { AgentOptions } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent' import type { SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' export const name = 'tool-subagent' @@ -119,17 +120,6 @@ export const Config: z = z.object({ maxDepth: z.natural().max(Number.MAX_SAFE_INTEGER), }) -/** Reject a recursion cap that cannot represent an exact delegation depth. */ -function assertMaxDepth(maxDepth: number | undefined): void { - if (maxDepth !== undefined && ( - !Number.isSafeInteger(maxDepth) - || maxDepth < 0 - || Object.is(maxDepth, -0) - )) { - throw new Error('tool-subagent: `maxDepth` must be a non-negative safe integer') - } -} - /** * Flatten a child's final output blocks to text for the tool result. The child * may return non-text blocks; this cut surfaces the text content (the common @@ -202,7 +192,7 @@ export function providerWording(inherits: boolean): { description: string; promp export function apply(ctx: Context, config: Config): void { // Keep misconfiguration at plugin load even when a caller invokes apply() // directly and bypasses Schemastery's natural/max metadata. - assertMaxDepth(config.maxDepth) + assertSubagentMaxDepth(config.maxDepth) // Misconfiguration fails loud AT LOAD (the check is self-contained): an // explicit `toolFilter: {}` would otherwise pass the capability gate and // kill every delegation later, in the child-setup `restrict({})` throw. diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 194c63997c..4e125d24e1 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -494,6 +494,9 @@ describe('dsh-tool-subagent', () => { }) it.each([ + { label: 'NaN', value: Number.NaN }, + { label: 'positive infinity', value: Number.POSITIVE_INFINITY }, + { label: 'negative infinity', value: Number.NEGATIVE_INFINITY }, { label: 'a negative integer', value: -1 }, { label: 'a fractional number', value: 1.5 }, { label: 'negative zero', value: -0 }, From cb03c8c2844790230a2ee1de24f72c7cf4bac742 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:17:57 +0800 Subject: [PATCH 47/64] fix(scope): align trust and input boundaries Rewrite the agent-scope RFC with executable examples and an explicit security non-goal. Harden subagent scalar and depth validation, and pin live tool-filter semantics across code, tests, and generated docs. --- CONTEXT.md | 8 +- docs/config-catalog.md | 10 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 4 +- docs/core-data-structures/subagent.md | 6 +- docs/core-data-structures/tools.md | 2 +- ...t-variables-and-tool-guidance-ownership.md | 4 +- .../2026-07-08-agent-scope-contexts.md | 403 ++++++++++++++++-- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/core/scope/README.md | 4 +- packages/core/scope/src/index.ts | 10 +- packages/core/system-prompt/README.md | 4 +- packages/core/system-prompt/src/index.ts | 12 +- packages/core/tools/README.md | 2 +- packages/core/tools/src/index.ts | 26 +- packages/core/tools/tests/scoped.spec.ts | 27 +- packages/subagent/subagent-fork/README.md | 2 +- .../subagent/subagent-inprocess/README.md | 8 +- .../subagent/subagent-inprocess/src/index.ts | 12 +- .../tests/structured.spec.ts | 2 +- .../tests/subagent-inprocess.spec.ts | 23 + packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/src/types.ts | 13 +- .../subagent/subagent/tests/service.spec.ts | 3 +- packages/subagent/tool-subagent/README.md | 8 +- packages/subagent/tool-subagent/src/index.ts | 21 +- .../tool-subagent/tests/tool-subagent.spec.ts | 20 +- packages/support/subagent-mock/README.md | 2 +- packages/support/subagent-mock/src/index.ts | 8 +- 29 files changed, 545 insertions(+), 105 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 7fd11ae59e..2b3584aff4 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -4,12 +4,12 @@ Domain vocabulary for the DeepSeek Harness SDK — one canonical term per concep ## agent-scope -- **scope** — the unit of per-agent registration: a contribution (tool, prompt section, variable, restriction, listener) is either *global* (visible to every agent) or *scoped* (owned by exactly one [[scope-key]]). Two levels, flat: nothing inherits down to subagents; subtree behavior is expressed with [[lineage]] data, never structure. +- **scope** — the unit of per-agent registration: a contribution (tool, prompt section, variable, restriction, listener) is either *global* (visible to every agent) or *scoped* (owned by exactly one [[scope-key]]). Two levels, flat: scoped registrations do not inherit down to subagents; subtree behavior is expressed with [[lineage]] data, never scope structure. - **scope key** — the opaque identity a scope is keyed by, compared by object identity. The harness convention: a live agent is the key of its own scope. -- **agent context (`agent.ctx`)** — the agent's scoped context; registrations through it are scope-visible AND scope-lifetime (one fact drives both), and listeners on it hear only that agent's dispatches. +- **agent context (`agent.ctx`)** — the agent's scoped context; registrations through it are scope-visible AND scope-lifetime (one fact drives both), and listeners on it participate in that agent's scope-filtered dispatches. Registry-subject events may remain deliberately unfiltered under their own event contracts. - **scope carrier** — the `thisArg` a scope-filtered dispatch carries (built by `scopeTarget`); its filter admits untagged listeners plus the subject's own. A *subject-less* carrier (no key) admits untagged listeners only. - **scoped dispatch** — the rule: an event about one agent's activity dispatches with that agent's carrier. Events about a registry itself (a tool was added) are *registry-subject* and stay unfiltered. - **shadowing** — most-specific-wins name resolution: a scoped tool/section/variable replaces its same-named global twin for that scope alone. The per-agent persona and per-agent tool-variant mechanism. -- **restriction / grant** — a restriction (`tools.restrict`) masks the GLOBAL tool surface for one scope (compose by intersection); a scoped registration is an explicit grant that bypasses restrictions. A restricted-away tool is absent from the prompt AND refuses execution, indistinguishably from a nonexistent one. -- **setup window** — the creation slot where a creator composes an agent's scoped world (`CreateAgentOptions.setup`): after the scope exists and the agent is registered, before `agent/session-start` and the first prompt assembly. Setup registers; it never drives the agent. +- **restriction / scope-local registration** — a restriction (`tools.restrict`) filters the GLOBAL tool surface for one scope (compose by intersection); scope-local registrations are merged after that filter. A filtered-away global tool is absent from the prompt AND refuses execution, indistinguishably from a nonexistent one. +- **setup window** — the creation slot where a creator composes an agent's scoped world (`CreateAgentOptions.setup`): after the scope and agent object exist but before the agent or session is published, `agent/session-start` fires, or the first prompt is assembled. Setup registers; it never drives the agent. - **lineage** — parent/child facts carried as data (`parentSession`, `subagentDepth`); never affects visibility. diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8fd8e3379b..57f8bc961a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -712,9 +712,11 @@ export interface Config { /** Which start-time capabilities to advertise (default: all `true`). */ capabilities?: Partial /** - * The context contract to declare ({@link SubagentProvider.inheritsParentContext}); - * default `false` (spawn-like). Set `true` to exercise the fork-shaped tool - * wording in consumer tests. + * The conversation-history descriptor to declare + * ({@link SubagentProvider.inheritsParentContext}); default `false` (fresh + * conversation). Set `true` to exercise seeded/fork wording in consumer + * tests. This flag says nothing about tool, service, scope, or authority + * inheritance. */ inheritsParentContext?: boolean /** @@ -903,7 +905,7 @@ export interface Config { Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) -Source: [`packages/subagent/tool-subagent/src/index.ts:45`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:47`](../packages/subagent/tool-subagent/src/index.ts) ## `@deepseek-ai/dsh-tool-web` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index d13f88bc2d..d59bc617d0 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -385,7 +385,7 @@ Source: [`packages/core/tools/src/index.ts:176`](../../packages/core/tools/src/i ### `tools/execute` — waterfall -Around-dispatch waterfall wrapping the registry's core tool dispatch, between the `tools/pre-execute` gate and the `tools/post-execute` seam. A listener receives `(exec, next)`: call `next()` to delegate to dispatch (returning its ToolExecutionResult, optionally wrapped), or return a replacement result without calling `next()` to short-circuit dispatch. The base `next()` IS the dispatch-with-normalization thunk — a thrown tool (or unknown tool) is already normalized to an `isError` result by the time a listener's `await next()` returns, so a wrapper never sees a raw throw from the tool body. This is the seam a timeout/retry/metrics plugin wraps: it can set or replace the one mutable field, `exec.signal` (e.g. with a per-call deadline), BEFORE `next()`, restore/delete it afterward, and inspect the result AFTER. Call identity (`token`, `callId`, `name`, `arguments`, `agent`, and `parent`) is immutable throughout the pipeline so a wrapper cannot change which capability or scope was authorized. (Cordis `next()` ignores passed arguments and re-invokes downstream with the shared payload, so a wrapper changes `exec.signal` in place rather than passing a new object to `next()`.) Multiple listeners compose by registration order — an outer one wraps the inner ones plus dispatch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by `exec.agent` — a listener registered through `agent.ctx` wraps only that agent's calls; a plain plugin listener wraps every call (including agent-less ones, which dispatch subject-less). +Around-dispatch waterfall wrapping the registry's core tool dispatch, between the `tools/pre-execute` gate and the `tools/post-execute` seam. A listener receives `(exec, next)`: call `next()` to delegate to dispatch (returning its ToolExecutionResult, optionally wrapped), or return a replacement result without calling `next()` to short-circuit dispatch. The base `next()` IS the dispatch-with-normalization thunk — a thrown tool (or unknown tool) is already normalized to an `isError` result by the time a listener's `await next()` returns, so a wrapper never sees a raw throw from the tool body. This is the seam a timeout/retry/metrics plugin wraps: it can set or replace the one mutable field, `exec.signal` (e.g. with a per-call deadline), BEFORE `next()`, restore/delete it afterward, and inspect the result AFTER. Call identity (`token`, `callId`, `name`, `arguments`, `agent`, and `parent`) is immutable throughout the pipeline so a wrapper cannot change which tool and scope the pipeline accepted. (Cordis `next()` ignores passed arguments and re-invokes downstream with the shared payload, so a wrapper changes `exec.signal` in place rather than passing a new object to `next()`.) Multiple listeners compose by registration order — an outer one wraps the inner ones plus dispatch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by `exec.agent` — a listener registered through `agent.ctx` wraps only that agent's calls; a plain plugin listener wraps every call (including agent-less ones, which dispatch subject-less). ```ts cordis-catalog 'tools/execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index f5946a7e4d..877c10e080 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -254,7 +254,7 @@ Source: [`packages/subagent/subagent/src/index.ts:180`](../../packages/subagent/ ## `ctx.systemPrompt` — `SystemPrompt` -Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, named prompt variables, and authoritative contribution protections; the agent loop calls `assemble(context)` once per step. Registers the harness-owned `harness:identity` and `deployment:persona` sections itself (see Config.persona). +Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, named prompt variables, and owner-final contribution protections; the agent loop calls `assemble(context)` once per step. Registers the harness-owned `harness:identity` and `deployment:persona` sections itself (see Config.persona). ```ts cordis-catalog section(section: PromptSection): () => Promise | void @@ -285,7 +285,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:481`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:484`](../../packages/core/tools/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 3de6b5861e..2f3e089a22 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -78,7 +78,7 @@ interface SubagentRun { ## The provider seam: `SubagentProvider` -One transport for running a child agent. Implementations register under a unique name via `SubagentService.registerProvider`; multiple coexist in one context. The service validates every requested start-time capability before calling `start`, so an implementation may assume e.g. `request.maxDepth` is honorable when present. `inheritsParentContext` is a DESCRIPTIVE fact beside the capabilities (nothing validates against it): whether a child sees the parent conversation (`fork`: true, `spawn`/`acp`: false) — the model-facing consumer derives truthful tool wording from it. +One transport for running a child agent. Implementations register under a unique name via `SubagentService.registerProvider`; multiple coexist in one context. The service validates every requested start-time capability before calling `start`, so an implementation may assume e.g. `request.maxDepth` is honorable when present. `inheritsParentContext` is a DESCRIPTIVE fact beside the capabilities (nothing validates against it): whether a child sees the parent conversation (`fork`: true, `spawn`/`acp`: false) — the model-facing consumer derives truthful tool wording from it. It describes conversation history only, not tool registrations, injected services, or authority inheritance. ```ts type-equiv interface SubagentProvider { @@ -93,7 +93,7 @@ The service (`ctx.subagents`) emits `subagent/start` only after `run.started` fu ## In-process backends: depth and seed -The two in-process backends ([dsh-subagent-spawn](../../packages/subagent/subagent-spawn) fresh, [dsh-subagent-fork](../../packages/subagent/subagent-fork) seeded) run the child as a child `Agent` on the same application. They synchronously snapshot caller-owned data, install provider ownership before attaching the abort listener, create one run-owner fiber under `parent.ctx`, and invoke the factory through that fiber: parent teardown, provider teardown, and manual run disposal share the same pre-publication ownership and quiescence boundary, while the child still receives a flat new scope rather than inheriting the parent's capabilities. Their `started` promise projects the factory's successful publication and the result driver awaits that same promise before sending the prompt. Two pieces of vocabulary ride on the existing agent/session types rather than new core types: +The two in-process backends ([dsh-subagent-spawn](../../packages/subagent/subagent-spawn) fresh, [dsh-subagent-fork](../../packages/subagent/subagent-fork) seeded) run the child as a child `Agent` on the same application. They synchronously snapshot caller-owned data, install provider ownership before attaching the abort listener, create one run-owner fiber under `parent.ctx`, and invoke the factory through that fiber: parent teardown, provider teardown, and manual run disposal share the same pre-publication ownership and quiescence boundary, while the child still receives a flat new scope rather than inheriting the parent's registrations. Their `started` promise projects the factory's successful publication and the result driver awaits that same promise before sending the prompt. Two pieces of vocabulary ride on the existing agent/session types rather than new core types: -- **Delegation depth** is a merge-extensible `AgentOptions.subagentDepth` field (`0` for a top-level agent, parent + 1 for a child). The seam owns it — the loop neither sets nor reads it — so a nested spawn reads its parent's depth from `parent.options.subagentDepth` and the `depthLimit` capability caps the tree by refusing a child whose depth would exceed `request.maxDepth`. +- **Delegation depth** is a merge-extensible `AgentOptions.subagentDepth` field (`0` for a top-level agent, parent + 1 for a child). Only `undefined` means top level; every stored present value must be a non-negative safe integer. The seam owns it — the loop neither sets nor reads it — so a nested spawn validates its parent's stored depth, rejects a derived child depth outside the safe-integer domain, and applies a defined absolute `request.maxDepth` cap to that child. - **Fork seeding** uses `CreateAgentOptions.seed` (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `resume` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/support/invariants) replay accepts it (the in-flight, unbalanced turn is excluded). diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 5c11f1a07c..1766b4c9ce 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -118,7 +118,7 @@ interface ToolExecution extends ToolExecutionInput { } ``` -`ToolExecutionToken` is a compile-time opaque type and a frozen, property-free object at runtime; identity comparison is its only operation. Before policy runs, `ctx.tools.execute()` reads each caller-owned field once, materializes `arguments` as detached lossless JSON in one recursive pass, assigns a fresh token, and deep-freezes the accepted arguments. A mutable exotic such as `Map` is rejected and normalized to an error before policy; one-pass materialization prevents a stateful getter from supplying different values to validation and storage. `token`, `callId`, `name`, `arguments`, `agent`, and the optional `parent` token are non-writable throughout all waterfalls, so a listener cannot change which capability or scope was authorized or reach a live enclosing execution; an around-dispatch wrapper may add, replace, or remove only optional `signal`. After the complete pipeline the registry freezes the execution and exposes its stable identity to `tools/result` observers, where the execution remains usable as a `WeakMap` key without mutation races. +`ToolExecutionToken` is a compile-time opaque type and a frozen, property-free object at runtime; identity comparison is its only operation. Before policy runs, `ctx.tools.execute()` reads each caller-owned field once, materializes `arguments` as detached lossless JSON in one recursive pass, assigns a fresh token, and deep-freezes the accepted arguments. A mutable exotic such as `Map` is rejected and normalized to an error before policy; one-pass materialization prevents a stateful getter from supplying different values to validation and storage. `token`, `callId`, `name`, `arguments`, `agent`, and the optional `parent` token are non-writable throughout all waterfalls, so a listener cannot change which tool or scope the pipeline accepted or reach a live enclosing execution; an around-dispatch wrapper may add, replace, or remove only optional `signal`. After the complete pipeline the registry freezes the execution and exposes its stable identity to `tools/result` observers, where the execution remains usable as a `WeakMap` key without mutation races. A `ToolGuard` is scope-aware final pre-dispatch policy. Its shape deliberately has no allow result: `undefined` preserves the waterfall decision, while a returned reason can only reduce permission, so a later listener cannot undo it. diff --git a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index 24a525307e..53d3e63cdb 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md +++ b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -36,9 +36,9 @@ Plugins contribute named values via `ctx.systemPrompt.variable(name, provider)`; Per-tool semantics and when-to-use live in tool DESCRIPTIONS, which already ship in every request — the YAML prose was ~fully redundant with them. Sections carry only the cross-call habits a single call's description cannot: `dsh-tool-bash` contributes `tool:bash` (order 105) — check the `[exit code: N]` marker on every result; `dsh-tool-fs`'s read section gains the "not shell commands like cat" contrast. `todo_write` and the subagent tools need NO section — their descriptions already carry the whole contract. The leaf personas shrink to identity + behavior (verify your work; keep answers brief), and the welcome banner stops enumerating tools. -### The subagent context contract +### The subagent conversation-history descriptor -`SubagentProvider` gains `readonly inheritsParentContext: boolean` — a DESCRIPTIVE fact beside `capabilities`, not in it (capabilities are start-time validation; nothing validates against this flag). Spawn and ACP declare `false`, fork declares `true`. `dsh-tool-subagent` derives both the tool description and the `prompt` parameter description from the flag (`providerWording`): the fork instance now tells the model the child inherits the conversation's completed turns (not the in-flight turn) and that its prompt should state only what is new. Deriving the description from a provider that arrives on its own fiber is what forced the provider-lifecycle events and the tool's reactive registration — that mechanism, its Loader-concurrency rationale, and its rejected alternatives are recorded in [the provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md). +`SubagentProvider` gains `readonly inheritsParentContext: boolean` — a DESCRIPTIVE conversation-history fact beside `capabilities`, not in it (capabilities are start-time validation; nothing validates against this flag). Spawn and ACP declare `false`, fork declares `true`. The name refers only to conversation seeding, not Cordis scope, services, tools, or authority. `dsh-tool-subagent` derives both the tool description and the `prompt` parameter description from the flag (`providerWording`): the fork instance now tells the model the child is seeded with the conversation's completed turns (not the in-flight turn) and that its prompt should state only what is new. Deriving the description from a provider that arrives on its own fiber is what forced the provider-lifecycle events and the tool's reactive registration — that mechanism, its Loader-concurrency rationale, and its rejected alternatives are recorded in [the provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md). ## Alternatives considered diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md index df6433dd26..9162273c72 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -6,26 +6,48 @@ Status: implemented One application can run many agents that share infrastructure but must not share every capability or policy. A child agent may need a different persona, fewer tools, its own structured-result schema, and listeners that govern only its work, while still using the deployment's model adapters, persistence backend, tool implementations, and user interface. -This is a composition problem, not an application-isolation problem. Starting a separate service graph for every child would isolate too much; putting every registration in one global graph isolates too little. +This is a composition problem, not an application-isolation or security-confinement problem. Starting a separate service graph for every child would isolate too much; putting every registration in one global graph isolates too little. | Surface | What varies by agent | Failure when it is only global | |---|---|---| -| Tools | Available capabilities, a child-only tool, or a scoped replacement for one implementation | The model receives excess authority, or a child-specific tool leaks into every prompt | +| Tools | Available capabilities, a child-only tool, or a scoped replacement for one implementation | The model receives the wrong tool view, or a child-specific tool leaks into every prompt | | Prompt state | Persona, instructions, variables, and [Code Mode](../feature/2026-06-15-code-mode.md) SDK declarations | Every agent receives the same instructions or runtime facts | | Live policy | Hooks, execution guards, result observers, and continuation rules | A listener intended for one agent can alter another agent's work | | Lifetime | Cleanup when the agent fails, is cancelled, is disposed, or loses its owner | Registrations outlive the agent or disappear before its final work settles | -Two consistency requirements make the problem deeper than filtering a list. First, the model-visible and executable views must agree: a hidden tool must not remain callable, and an advertised tool must not fail merely because execution used a different registry view. This agreement must also cover Code Mode bindings and UI presentation. +Three consistency requirements make the problem deeper than filtering a list. First, the model-visible and executable views must agree: a hidden tool must not remain callable, and an advertised tool must not fail merely because execution used a different registry view. This agreement must also cover Code Mode bindings and UI presentation. Second, some rules are invariants rather than cooperative extensions. An ordinary middleware listener may replace a prompt assembly, turn an allow into a deny, rewrite a result, force another model step, or short-circuit listeners registered after it. Structured output therefore cannot rely on being “first” or “last” in an extensible listener chain; the owning service needs a final boundary for rules that later listeners must not undo. Third, accepting a value must transfer ownership of the exact value that was checked. TypeScript `readonly` annotations disappear at runtime, callers and providers may expose stateful accessors, and a validation pass followed by a clone reads mutable input twice. Identity fields, schemas, session data, requests, and results therefore need runtime boundaries that capture each caller-owned field once, materialize data once, and expose only owner-controlled snapshots. Otherwise the checked, executed, logged, and observed views can diverge even when scope resolution itself is correct. +The failure does not require TypeScript or threads. One JavaScript getter is enough to make a two-read boundary validate one name and store another: + +```js +let reads = 0 +const input = { + get name() { + reads += 1 + return reads === 1 ? 'safe_tool' : 'different_tool' + }, +} + +// Wrong: validation and storage observe different values. +validateName(input.name) +storeName(input.name) + +// Right: capture once, then validate and store that capture. +reads = 0 +const acceptedName = input.name +validateName(acceptedName) +storeName(acceptedName) +``` + The subagent API makes these requirements concrete. Two concurrent children can request different personas, tool filters, and output schemas. Those requests are honest only when each child receives an independently owned view and when its terminal-output protocol survives unrelated plugins. ## Decision -Each live agent owns a registration context named `agent.ctx`, and services expose narrow owner-final policy boundaries where ordinary middleware ordering is not strong enough. Together these choices make one agent's world composable with normal plugin APIs while keeping authority, observation, and cleanup aligned. +Each live agent owns a registration context named `agent.ctx`, and services expose narrow owner-final policy boundaries where ordinary middleware ordering is not strong enough. Together these choices make one agent's registration view composable with normal plugin APIs while keeping visibility, observation, and cleanup aligned. The design has five parts: @@ -37,11 +59,90 @@ The design has five parts: | Owner-final policy | Prompt protection, tool guards, final tool-result observation, and terminal turn stopping run at service-owned boundaries | Invariants do not depend on listener registration order | | Boundary ownership | Services capture fixed fields once, materialize lossless-JSON data once, and publish owner-controlled views | Validation, execution, persistence, and telemetry cannot observe different values from one call | +### One public call shows the composition model + +The common case uses ordinary registration APIs through the setup context. Code blocks in this RFC are focused examples and start from a fresh initialized application unless one explicitly continues another. Here `ctx` is a plugin's service context, `setup(agentCtx)` receives the unpublished agent's scoped context, and helpers such as `AgentId`, `SessionId`, and `CallId` construct opaque IDs. Assume the deployment already registered global `read` and `bash` tools; this creates a reviewer whose persona, global-tool filter, and extra reporting tool exist only for that agent and disappear with its handle: + +```js +const reviewSummaryTool = { + name: 'review_summary', + description: 'Return the review summary.', + parameters: { type: 'object', properties: {} }, + async execute() { + return [{ type: 'text', text: 'review complete' }] + }, +} + +const handle = await ctx.agents.create({ + agentId: AgentId('reviewer'), + sessionId: SessionId('reviewer-session'), + agentOptions: { model: 'model-name' }, + setup(agentCtx) { + agentCtx.systemPrompt.section({ + name: 'deployment:persona', + order: 0, + text: 'Review code, but do not modify files.', + }) + agentCtx.tools.restrict({ allow: ['read'] }) + agentCtx.tools.register(reviewSummaryTool) + }, +}) + +const reviewer = handle.agent +ctx.tools.get('read', reviewer) // global tool, visible +ctx.tools.get('bash', reviewer) // undefined: filtered global tool +ctx.tools.get('review_summary') // undefined: not global +ctx.tools.get('review_summary', reviewer) // child-only definition + +await handle.dispose() +ctx.tools.get('review_summary', reviewer) // undefined: scope was unwound +``` + +The rest of this RFC explains why the short setup above needs scope-aware resolution, unpublished construction, ordered teardown, and owner-final policy. + +### Security and authority are explicit non-goals + +Scoped contexts are trusted in-process registration composition, not a sandbox, authorization ledger, or parent-to-child authority lattice. A plugin with a Cordis context executes in the same process and can call the services injected into that context. Scope filtering decides which registered contribution participates in one operation and who cleans it up; it does not prove that a child can do no more than its parent. + +#### Flat scopes do not enforce a parent-to-child subset + +Flat lookup makes that boundary visible. Parent lifetime ownership does not cause the child to inherit the parent's restriction, and a child-local registration is merged after the global filter: + +```text +global tools = { read, bash } +parent restriction = allow { read } +parent scoped registrations = { delegate } +child restriction = none +child scoped registrations = { deploy } + +visible(parent) = { read, delegate } +visible(child) = { read, bash, deploy } +``` + +Through `delegate`, a parent can deliberately start this child and indirectly obtain work performed with `bash` or `deploy`. This RFC neither prevents nor blesses that arrangement; deployments that need a non-escalation guarantee require a separate authority design and enforcement boundary. + +#### Restrictions are live views, not grant snapshots + +Restrictions also resolve against a live global registry rather than an immutable authorization snapshot. A deny-list names removals, while an allow-list names the complete retained global set: + +```text +at time 0: + global tools = { read, bash } + deny { bash } view = { read } + allow { read } view = { read } + +after registering global tool web: + deny { bash } view = { read, web } + allow { read } view = { read } +``` + +Scope-local tools are merged after either filter. There is no separate authority-versus-visibility ledger, frozen creation-time grant snapshot, parent-subset rule, future-tool grant API, or generic capability/output/terminal tag system here. `run_code` and `structured_output` have explicit protocol-owned treatment described below; they do not imply a general security taxonomy. Those questions are separate design work rather than hidden promises of scoped contexts. + Three domain terms recur below. A **Session** is one agent run's append-only event log, from which model history and durable replay are derived. **Lossless JSON** means JSON primitives plus dense arrays and plain objects that can be copied without changing meaning; the boundary rejects sparse arrays, cycles, exotic prototypes, non-finite numbers, negative zero, `undefined`, `bigint`, functions, and symbols instead of coercing or erasing them. **Code Mode** presents the model with a generated software-development-kit interface and a reserved `run_code` transport, rather than advertising every end-capability as a native tool. Ownership stays with the component that can enforce each fact. The scope package owns scope tags and carrier construction; each registry owns acceptance snapshots and resolution; the caller owns the programmatic agent lifetime it requested; the concrete agent factory owns identity reservation, setup, publication, and structural invalidation of agents that still depend on it; the session owns accepted history; the tool and subagent services own their pipeline records; and each workflow run captures its holder-bound dependencies and owns its cancellation after the engine returns it. A caller never validates a value that another component later rereads from the caller's mutable object. -The scope is flat. An agent resolves the deployment-global layer plus its own layer; a child does not inherit registrations from its parent's scope. Parent/child lineage remains explicit session data, and parent-owned disposal links lifetimes without silently inheriting authority. +The scope is flat. An agent resolves the deployment-global layer plus its own layer; a child does not inherit registrations from its parent's scope. Parent/child lineage remains explicit session data, and parent-owned disposal links lifetimes without inheriting registrations. The core implementation lives in [`dsh-scope`](../../../../packages/core/scope/README.md), [`dsh-agent`](../../../../packages/core/agent/README.md), [`dsh-agent-loop`](../../../../packages/core/agent-loop/README.md), [`dsh-session`](../../../../packages/core/session/README.md), [`dsh-system-prompt`](../../../../packages/core/system-prompt/README.md), and [`dsh-tools`](../../../../packages/core/tools/README.md). The composition example spans [`dsh-subagent`](../../../../packages/subagent/subagent/README.md), [`dsh-subagent-inprocess`](../../../../packages/subagent/subagent-inprocess/README.md), and [`dsh-workflow-workerthread`](../../../../packages/workflow/workflow-workerthread/README.md). The [generated Cordis event catalog](../../../cordis-catalog/events.md) is the exhaustive event-signature reference; this RFC explains why the contracts have their current shape. @@ -53,10 +154,29 @@ The design relies on four framework ideas: contexts, effects, waterfall events, A Cordis `Context` is the object through which a plugin reaches services such as `ctx.tools`, `ctx.systemPrompt`, and `ctx.sessions`. A service method can recover the context through which it was accessed, so the service can tell whether a call came from an ordinary plugin context or from an agent's scoped context without adding a `scope` parameter to every registration API. -A context also carries a capability view. A derived context reaches the services injected into the plugin that created it. Handing out `agent.ctx` therefore hands out the agent loop's injected service surface; it is not an ambient root context. +A context also carries an injected dependency view. A derived context reaches the services injected into the plugin that created it. Handing out `agent.ctx` therefore hands out the agent loop's injected service surface; it is not an ambient root context or a security confinement boundary. Factory delegation uses two contexts whose jobs must remain separate. The registry derives a caller-bound context carrying the fiber and scope from which `ctx.agents.create()` or `resume()` was called and passes it explicitly as `ownerCtx`; those facts identify the fiber and optional parent agent that own the requested lifetime. When the registered factory is itself a Cordis service, the registry also invokes it through a traced receiver, which preserves the factory's own injected dependency origin. A plain object that merely implements the factory methods receives the same explicit `ownerCtx` without depending on Cordis tracing. Conflating these roles would either attach the agent to the factory registrant instead of the caller or make the concrete loop resolve dependencies from the wrong service view. +The object before a service or event method selects the registration origin. The method itself does not need an extra agent parameter: + +```js +ctx.tools.register(globalTool) +agent.ctx.tools.register(agentOnlyTool) + +ctx.on('tools/result', globalObserver) +agent.ctx.on('tools/result', agentObserver) +``` + +Factory calls preserve caller ownership and factory dependency lookup as separate values: + +```text +callerCtx.agents.create(options) + ownerCtx = context carrying callerCtx's fiber and scope + factoryThis = concrete factory traced through ownerCtx + Reflect.apply(capturedCreateAgent, factoryThis, [ownerCtx, options]) +``` + ### Effects give registrations an owner A Cordis effect is work whose cleanup belongs to a runtime unit called a fiber. Tool registration, prompt contribution, and event subscription are effects, so disposing their fiber unwinds them on normal teardown, failure, or hot reload. @@ -65,12 +185,39 @@ Ownership must exist before effect setup can call arbitrary code. The vendored F `dsh-scope` mounts a no-op plugin fiber for each scope. The plugin contributes no behavior; its fiber is the ownership bucket for everything registered through the scoped context. +In its simplest form, an effect is a setup function that returns its cleanup. Cordis also supports generator effects that compose child effects in a chosen order. In either form Cordis records the wrapper before calling setup, so even setup-triggered reentrant teardown can find and await it: + +```js +ctx.effect(() => { + const resource = openResource() + return async () => { + await resource.close() + } +}) +``` + ### A waterfall is ordered around-middleware A Cordis waterfall is an extensible middleware chain. A listener calls `next()` to delegate, can inspect or replace the downstream result, and can return without calling `next()` to short-circuit everything inside it. This flexibility is useful for cooperative transformations, but registration order is not an invariant boundary. A later plugin can prepend another listener, a wrapper can replace the downstream result after `next()` returns, and a short-circuit can prevent inner listeners from running at all. +The code shape is ordinary around-middleware. Calling `next()` includes downstream work; returning directly skips that listener's downstream listeners and base implementation: + +```js +ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { + const downstream = await next() + return { + ...downstream, + sections: [...downstream.sections, extraSection], + } +}) + +ctx.on('system-prompt/assemble', async () => replacementAssembly) +// This listener skips its downstream/base. An outer listener that already +// awaited next() still resumes around replacementAssembly. +``` + ### The dispatch receiver selects scoped listeners Cordis filters event listeners using the dispatch receiver, the object exposed as `this` inside a function-style listener. `dsh-scope` supplies a receiver carrying the operation's scope key, so the event system can admit global listeners plus listeners registered for that key and reject listeners belonging to other agents. @@ -118,6 +265,18 @@ The scope key is an opaque object compared by identity. The harness uses the liv The property is deliberately not treated as the authoritative scope tag. A nested scope can install a nearer scope key while still inheriting the original `ctx.agent` association, so lower-level services resolve layers with `scopeOf(context)`. In normal agent composition the two point at the same live agent; the separation keeps the generic scope primitive independent of the agent package. +The distinction appears when a plugin deliberately nests another scope: + +```js +const auditKey = {} +const auditScope = createScope(agent.ctx, auditKey) + +auditScope.ctx.agent === agent // true: inherited ergonomic association +scopeOf(auditScope.ctx) === auditKey // true: authoritative nearest scope tag + +await auditScope.dispose() +``` + ### The scope primitive has separate public and composite disposal forms `dsh-scope` exposes the minimum operations needed to create a layer, read it, target events, and dispose it. “Quiescent” here means that every asynchronous cleanup registered in the scope has settled and no teardown work remains in flight. @@ -190,15 +349,32 @@ registerTool(context, definition): The reserved Code Mode transport uses the same frozen-definition contract even though it lives outside the ordinary layers. -### Tool restrictions reduce end capabilities without removing transport +### Tool restrictions filter the global view without removing transport -A tool restriction masks the global end-capability layer for one agent, while tools registered in that agent's own layer are explicit grants. Multiple restrictions intersect, so separately installed policies can only reduce the global surface. +A tool restriction masks the global end-capability layer for one agent, while tools registered in that agent's own layer are merged afterward. Multiple restrictions intersect, so separately installed filters can only reduce the global part of the view; they do not filter scope-local registrations. The restriction reads `allow` and `deny` once, snapshots those exact values, rejects an empty filter, and validates named tools against the pre-restriction capability universe. The same captured arrays are then enforced, so a stateful accessor cannot pass one policy through validation and install another. A restricted-away tool behaves like an unknown tool at execution, avoiding disclosure of a hidden global implementation. [Code Mode](../feature/2026-06-15-code-mode.md)'s `run_code` is not an end capability. It is a reserved presentation transport that carries calls to the visible end capabilities, so the registry keeps it outside both global and scoped registration layers: restrictions cannot remove it, a scoped tool cannot shadow it, and configuration cannot explicitly allow or deny it. Without this exception, a restriction could leave the generated SDK in the prompt but remove the only way to invoke it. -The registry still uses one executable visibility view. It first resolves restricted global capabilities plus scoped grants, then appends the reserved transport in non-native modes; registry-owned prompt schemas, lookup, execution, Code Mode SDK bindings, timeout lookup, inspection, and UI presentation all consume that view. +The registry still uses one executable visibility view. It first resolves filtered global capabilities plus scope-local registrations, then appends the reserved transport in non-native modes; registry-owned prompt schemas, lookup, execution, Code Mode SDK bindings, timeout lookup, inspection, and UI presentation all consume that view. + +The public lookup API exposes the exact same resolution used for prompt schemas and execution: + +```js +ctx.tools.register(readTool) +ctx.tools.register(bashTool) + +agent.ctx.tools.restrict({ allow: ['read'] }) +agent.ctx.tools.register(reviewSummaryTool) + +ctx.tools.get('read', agent) // visible global definition +ctx.tools.get('bash', agent) // undefined: filtered global definition +ctx.tools.get('review_summary', agent) // visible scope-local definition +ctx.tools.get('review_summary') // undefined: absent from global view +``` + +Executing `bash` for this agent follows the same lookup and produces the ordinary unknown-tool error; it does not bypass the filter through a separate execution registry. The [security non-goal](#security-and-authority-are-explicit-non-goals) explains why later global registrations and scope-local registrations are not an authorization snapshot. The guarantee covers the tool registry's contribution. A plugin can deliberately use the lower-level `systemPrompt.tools()` API or assembly waterfall to add an unrelated wire schema; that plugin owns the matching executable behavior and any ordering it introduces. Owner protection preserves reserved named infrastructure without turning the system-prompt service into a validator for unrelated contributions. @@ -227,6 +403,24 @@ The operation being described determines the key; callers cannot attach an unrel | `session/created`, `session/disposed`, `session/event`, `session/flush` | The owner scope captured when the session enters the store | | `subagent/start`, `subagent/end` | The delegating parent agent | +The observable rule is global plus matching, not global plus every scoped listener. This example drives a real tool execution so routing and notification use the same accepted `agent` subject: + +```js +const seen = [] +ctx.tools.register(readTool) +ctx.on('tools/result', () => seen.push('global')) +agentA.ctx.on('tools/result', () => seen.push('A')) +agentB.ctx.on('tools/result', () => seen.push('B')) + +await ctx.tools.execute({ + callId: CallId('read-1'), + name: 'read', + arguments: {}, + agent: agentA, +}) +seen // ['global', 'A'] +``` + Approval requests cross an asynchronous answer boundary, so the service snapshots the accepted record synchronously. It preserves the exact agent and abort-signal identities but copies the scalar fields, captures the agent's session once, and uses that one snapshot for `approval/asked`, scoped dispatch, cancellation, policy, and `approval/decided`. Mutating the caller-owned record after `request()` returns therefore cannot split the audit pair or redirect the question to another agent's listeners. The dispatch rule can be read independently of Cordis internals: @@ -252,16 +446,65 @@ Function-style listeners receive the carrier as `this`, and agent event APIs all Binding matters for classes with JavaScript private fields: a method called with the proxy itself as receiver would fail the runtime private-field identity check. The carrier therefore uses a dedicated surrogate proxy target with its own immutable composed-filter slot, while ordinary property access, writes, own-key visibility, methods, invocation, and construction delegate to the real subject; callable carriers also preserve whether the subject is constructable. -The composed filter is an authorization boundary, not an ordinary exposed callback. It invokes a subject's pre-existing filter with stable references to the built-in `Reflect.apply` and `Function.prototype.call` operations, pins its own `.call` to that captured built-in, and freezes the callable. Code holding the subject or carrier therefore cannot replace either `.call` property to turn a scoped predicate into an always-allow predicate. Keeping the filter on the surrogate also means a filter property pinned on the subject before, during, or after carrier construction cannot trigger a Proxy invariant that silently replaces scope isolation with the subject's raw filter. +A minimal JavaScript example shows why method binding is observable rather than a TypeScript detail: + +```js +class Subject { + #count = 0 + increment() { this.#count += 1 } +} + +const subject = new Subject() +new Proxy(subject, {}).increment() // TypeError: proxy lacks Subject's private identity + +const carrier = scopeTarget(subject, subject) +carrier.increment() // works: method is bound to subject +carrier === subject // false: dispatch carrier has distinct identity +``` + +The composed filter is a listener-selection correctness boundary, not an ordinary exposed callback. It invokes a subject's pre-existing filter with stable references to the built-in `Reflect.apply` and `Function.prototype.call` operations, pins its own `.call` to that captured built-in, and freezes the callable. Code holding the subject or carrier therefore cannot accidentally replace either `.call` property and turn the scoped predicate into an always-admit predicate. Keeping the filter on the surrogate also means a filter property pinned on the subject before, during, or after carrier construction cannot trigger a Proxy invariant that silently replaces scope filtering with the subject's raw filter. The surrogate must remain extensible so its reported own-key view can follow the subject. For non-overlay properties owned by the subject, descriptor queries preserve values and flags except that `configurable` is reported as `true`, which is the only Proxy-safe description of a property the extensible surrogate does not itself own. For the same reason, defining a property through the carrier is supported only when the descriptor explicitly says `configurable: true`; an omitted or false flag is rejected before the subject is touched. The carrier is intentionally not identity-equal to the subject; event arguments carry the real object whenever identity matters. -`Scoped` is a TypeScript-only marker that requires this carrier at declared scoped dispatch sites. It improves authoring but adds no runtime security, so runtime marks and development invariants check the same contract for JavaScript, casts, and hand-written dispatches. +`Scoped` is a TypeScript-only marker that requires this carrier at declared scoped dispatch sites. It improves authoring but adds no runtime enforcement by itself, so runtime marks and development invariants check the same contract for JavaScript, casts, and hand-written dispatches. These checks detect routing mistakes; they do not confine a hostile in-process plugin. ## Agent creation and teardown An agent's scope, session, registry entry, and driver form one transaction with two ownership edges. The caller context owns the work it requested and receives the only consumer-facing teardown capability; the concrete `AgentLoop` provider is a structural co-owner because the live agent continues to use the provider's injected services. Either edge deactivates the transaction and converges on the same ordered, memoized quiescence boundary. Setup finishes before publication, and publication is synchronous and rollback-covered rather than magically atomic. +The public contract is simple: setup may await while both identities remain absent from their registries; fulfillment publishes the complete agent; disposal removes it again. + +```js +const setupGate = Promise.withResolvers() +const agentId = AgentId('reviewer') +const sessionId = SessionId('reviewer-session') +const creating = ctx.agents.create({ + agentId, + sessionId, + agentOptions: { model: 'model-name' }, + async setup(agentCtx) { + await setupGate.promise + agentCtx.systemPrompt.section({ + name: 'deployment:persona', + order: 0, + text: 'Review the change.', + }) + }, +}) + +ctx.agents.get(agentId) // undefined while setup is pending +ctx.sessions.get(sessionId) // undefined while setup is pending +setupGate.resolve() + +const handle = await creating +ctx.agents.get(agentId) === handle.agent // true after publication +ctx.sessions.get(sessionId) === handle.agent.session // true after publication + +await handle.dispose() +ctx.agents.get(agentId) // undefined after quiescent teardown +ctx.sessions.get(sessionId) // undefined after quiescent teardown +``` + ### Create and resume reserve identities before asynchronous work Programmatic create and resume reserve both the agent ID and session ID before work that can await. Create prepares a fresh or seeded session; resume first loads and reconstructs the persisted session. Both paths then construct the agent, mint `agent.ctx`, and install the complete teardown skeleton before awaiting setup. @@ -421,7 +664,7 @@ For the concrete AgentLoop transaction, `agent/disposed` runs after the driver i Provider co-ownership is specific to resources that remain structurally dependent on their provider. An AgentLoop-created agent continues to resolve the loop's injected services, so loop unload must stop it. A worker workflow run instead captures its holder-bound `SubagentService` handle synchronously at `start()` and stores that independent dependency on the run; unloading `WorkerWorkflowEngine` removes the ability to start new runs but does not revoke an already returned run or prevent its later worker message from starting a child. The two lifetimes differ by dependency shape, not by a blanket rule that every service must own every value it creates. -Parent-owned subagents use explicit ownership rather than capability inheritance. The driver creates one run-owner fiber under `parent.ctx` and invokes the child factory through that fiber, so lifecycle ownership exists before setup or publication begins; disposing a parent reaches its descendants even if a delegating tool never reaches its own `finally`. The child still receives a newly minted scope and resolves only global plus child-scoped capabilities. +Parent-owned subagents use explicit ownership rather than registration inheritance. The driver creates one run-owner fiber under `parent.ctx` and invokes the child factory through that fiber, so lifecycle ownership exists before setup or publication begins; disposing a parent reaches its descendants even if a delegating tool never reaches its own `finally`. The child still receives a newly minted scope and resolves only global plus child-scoped registrations. ## Owner-final policy boundaries @@ -475,7 +718,7 @@ The registry materializes `arguments` in one lossless-JSON traversal and deep-fr The registry assigns each pipeline trip a frozen, property-free `ToolExecutionToken`; callers cannot choose that token. The execution is identity-stable, not fully immutable, while the pipeline runs: its `token`, `callId`, `name`, `agent`, optional opaque `parent` token, and detached `arguments` are non-writable and non-configurable from the first policy listener onward. `signal` is the only operational field; an around-dispatch wrapper may add, replace, or remove it. The registry freezes the complete execution before outcome observation. -Stable identity prevents a listener from changing which capability or scope was authorized after policy ran. It also gives commit-style observers a safe `WeakMap` key even when an adapter reuses a model call ID. +Stable identity prevents a listener from changing which tool or scope the pipeline accepted after policy ran. It also gives commit-style observers a safe `WeakMap` key even when an adapter reuses a model call ID. For a nested transport dispatch, `parent` carries only the enclosing execution's opaque token rather than its live object. Code Mode sets an SDK sub-call's `parent` to the outer `run_code` execution's `token`, so an observer can correlate the two outcomes without receiving a reference that could mutate the still-running outer wrapper. @@ -512,6 +755,24 @@ prepareExecution(input): This one-way result makes the boundary monotonic. Pre-execution hooks can still compose ordinary allow, deny, and ask decisions; an ask resolves through the optional `ctx.approval` seam, where only `allowed-once` becomes allow and an absent channel or any non-grant becomes deny before guards run. No listener ordering can convert a guard denial back into dispatched work. A denied call still continues through result transformation and final observation as an error outcome. +The two APIs have deliberately different strength. A waterfall listener may return an allow decision, but the later guard has no corresponding allow result: + +```js +agent.ctx.on( + 'tools/pre-execute', + async () => ({ kind: 'allow' }), + { prepend: true }, +) + +agent.ctx.tools.guard(execution => + execution.name === 'bash' + ? 'reviewer agents are read-only' + : undefined, +) +``` + +Even a later prepended allow listener cannot bypass this guard because the registry evaluates guards after the complete waterfall. + ### `tools/result` observes the authoritative live outcome The complete live pipeline is `tools/pre-execute` → monotonic guards → `tools/execute` → `tools/post-execute` → `tools/result`. The first three named events are transformable waterfalls; `tools/result` is an awaited, observe-only notification after all transforms and the registry's outer error normalization. At each untrusted result boundary, the registry captures every top-level field once and materializes the complete authoritative outcome as detached lossless JSON. Immediately before observation it materializes that owned outcome again and deep-freezes the shared listener snapshot. An invalid tool or listener result becomes a normal JSON-safe `isError` outcome instead of reaching observers as apparent success and failing later at the session log. @@ -520,7 +781,7 @@ Every `tools/result` listener receives the same frozen execution and deep-frozen `tools/result` is not the durable session event `tool/result`. The live notification belongs to the registry and also fires for direct programmatic executions; the agent loop subsequently appends `tool/result` to the session log for replay, UI reconstruction, and model history. A policy that needs the final in-process verdict uses the former, while a consumer that needs persisted transcript state uses the latter. -The entire registry method reads like one authority ladder: +The entire registry method reads like one execution-decision pipeline: ```text execute(input): @@ -575,7 +836,7 @@ Ordinary continuation remains extensible. The loop computes a default, runs the The scoped serial `agent/turn-stop` checkpoint runs after that folding. Its strict serial helper consults listeners in order until one returns a non-`undefined` value; a listener returns `{ action: 'stop' }` or abstains with `undefined`. The dedicated helper exists because ordinary Cordis serial dispatch treats `null` and `false` as framework abstentions, while this public contract has exactly one abstention value. A stop is terminal, so later listeners and pending steering cannot restore continuation. A malformed result, including `null` or `false`, or a throwing policy closes the current turn with an error while leaving the driver available for later work. -Terminal stop deliberately discards steering while preserving ordinary queued prompts. Its terminal state remains in force through `turn/end` and the durability flush, so steering added by continuation, turn-close, or flush listeners cannot escape through the loop's late-steering fallback into another step or turn. This is the explicit exception to the normal rule that leftover steering becomes input for another turn. The authority is reserved for protocols, such as a completed structured child, where further model work would violate the result contract. +Terminal stop deliberately discards steering while preserving ordinary queued prompts. Its terminal state remains in force through `turn/end` and the durability flush, so steering added by continuation, turn-close, or flush listeners cannot escape through the loop's late-steering fallback into another step or turn. This is the explicit exception to the normal rule that leftover steering becomes input for another turn. The stronger terminal control is reserved for protocols, such as a completed structured child, where further model work would violate the result contract. ```text afterSuccessfulStep(turn): @@ -605,13 +866,67 @@ The queued-prompt FIFO is separate and is never drained by terminal stop. In-process subagents demonstrate how the scope, lifecycle, and final-policy pieces compose. A provider builds the child's world during unpublished setup, then lets the ordinary agent lifecycle own it. +The caller-facing seam separates acceptance, readiness, result settlement, cancellation, and disposal. This example assumes the spawn backend is loaded under its configurable default provider name, `spawn`, `parent` is top-level (depth 0), and a global `read` tool has already been registered. A caller observes readiness before treating the child as live and always disposes the run: + +```js +const run = ctx.subagents.start('spawn', { + parent, + prompt: [{ type: 'text', text: 'Review this change.' }], + persona: 'You are a careful code reviewer.', + toolFilter: { allow: ['read'] }, + maxDepth: 2, + outputSchema: { + type: 'object', + properties: { summary: { type: 'string' } }, + required: ['summary'], + additionalProperties: false, + }, +}) + +try { + await run.started + const result = await run.result + // result.structured exists only after a successful committed capture. +} finally { + await run.dispose() +} +``` + ### Inputs and ownership are fixed before asynchronous creation +This section follows a child from provider/request acceptance through the service wrapper and then through the workflow bridge. Each layer captures its boundary before arbitrary asynchronous work and owns the cleanup it may need to start. + +#### Provider and request acceptance own the child boundary + Provider registration first freezes an acceptance snapshot of the provider name, capability flags, parent-context descriptor, and `start` callback; the callback is bound to the original provider object so its intentional internal state stays live. Lookup, validation, model-facing wording, dispatch, lifecycle notifications, and hot-reload cleanup all use that snapshot. Mutating or reusing the caller's provider object later therefore cannot rename a live entry, change its advertised powers, replace its callback, or make its disposer delete the wrong key. Starting a run reads every top-level request field once before capability validation, then snapshots every accepted field before asynchronous owner setup. This order makes checked and delegated capabilities identical even for a JavaScript caller with stateful accessors. Fixed scalars are checked at the same boundary: `maxDepth` must be a non-negative safe integer and `persona` must be a string. The parent and abort signal are retained as identity capabilities but never reread from the mutable request record; tool filters, seed events, agent options, output schema, and prompt are detached through the one-pass lossless-JSON materializer. The exported in-process driver repeats this boundary for direct callers before it awaits run-owner activation, including taking one seed snapshot from which it derives both the child prefix and `seedLength`. Later caller mutation therefore cannot change lifecycle scope, configuration, the schema enforced by the capture tool, or the prompt eventually logged and sent. -The driver first installs provider ownership. Only after that succeeds does it attach the request's abort listener and create one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves neither a child nor an orphaned listener. Calling `runOwner.ctx.agents.create()` gives the child factory an explicit `ownerCtx` carrying the run-owner fiber and scope, while the registry's traced factory receiver preserves AgentLoop's injected dependency origin. Parent teardown, provider teardown, and manual run disposal all dispose this same run-owner node; moving it out of the active state synchronously prevents an unpublished setup from publishing afterward, while all three paths follow one quiescence promise. This structured ownership does not change the child's flat capability view. +Depth validation is intentionally repeated at every public entry path, while one seam-owned helper keeps the accepted domain identical: + +```text +tool-subagent plugin load: + schema requires natural <= Number.MAX_SAFE_INTEGER + assertSubagentMaxDepth(config.maxDepth) + +SubagentService.start(request): + capture request.maxDepth once + assertSubagentMaxDepth(captured maxDepth) + +startInProcessRun(request): + capture request.maxDepth once + assertSubagentMaxDepth(captured maxDepth) + parentDepth = depthOf(parent) # also a non-negative safe integer + childDepth = parentDepth + 1 + if childDepth is not a safe integer: throw RangeError + if maxDepth is defined and childDepth > maxDepth: throw SubagentDepthError +``` + +The derived-value check is separate from validating either input: `Number.MAX_SAFE_INTEGER` is a valid stored parent depth, but adding one cannot produce a contract-valid child depth. The driver rejects that overflow even when no request-level `maxDepth` cap was supplied. + +The driver first installs provider ownership. Only after that succeeds does it attach the request's abort listener and create one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves neither a child nor an orphaned listener. Calling `runOwner.ctx.agents.create()` gives the child factory an explicit `ownerCtx` carrying the run-owner fiber and scope, while the registry's traced factory receiver preserves AgentLoop's injected dependency origin. Parent teardown, provider teardown, and manual run disposal all dispose this same run-owner node; moving it out of the active state synchronously prevents an unpublished setup from publishing afterward, while all three paths follow one quiescence promise. This structured ownership does not change the child's flat registration view. + +#### The service wrapper orders readiness, results, and lifecycle The provider's run separates acceptance from publication with `started: Promise`, but the service does not expose that caller-owned handle directly. It captures `id`, `started`, `result`, and each method once, binds methods to the provider-owned run handle, and returns a frozen service-owned wrapper. Capturing `dispose` first also preserves a rollback capability if a later accessor or method check reveals a malformed handle. The wrapper installs its shared disposal promise before invoking the raw provider callback, so synchronous reentry through the returned wrapper and ordinary repeat calls join one provider disposal rather than slipping through a not-yet-assigned memo. If the raw disposer directly returns that same reentrant wrapper promise, the service rejects the cyclic provider contract instead of awaiting a promise that depends on itself forever. @@ -654,7 +969,13 @@ SubagentService.start(...): on fulfillment, emit subagent/start and then buffered or eventual subagent/end on rejection, discard buffered lifecycle telemetry return serviceRun immediately +``` +#### The workflow bridge closes readiness and settlement races + +Every downstream protocol that announces a subagent must honor the same boundary. The workflow worker bridge therefore registers the returned run before waiting, observes and snapshots `result` immediately, and sends `ChildStarted` only after `started` fulfills while admission remains open. A readiness rejection is refused and host-disposed; `ChildStartError` is sent while worker-message admission remains open, and an already-retired exact run is not cleaned twice. Provider `start()` is itself arbitrary code and may synchronously reenter workflow cancellation before its returned run reaches that registry. The bridge attaches both promise observers, re-checks terminal admission immediately after `start()` returns and again at readiness, and turns a closed boundary into identity-guarded cancellation, disposal, and refusal rather than late worker admission or lifecycle announcement. An arbitrary provider may still fulfill its own `started` promise after the workflow boundary; the bridge refuses and cleans up that attempt instead of claiming it can undo provider-side publication. + +```text Workflow worker bridge after receiving returnedRun: register the run so cancellation can reach pre-publication work attach result settlement handlers immediately and snapshot the outcome @@ -700,8 +1021,6 @@ Host at physical worker exit: do not repeat explicit child cancellation ``` -Every downstream protocol that announces a subagent must honor the same boundary. The workflow worker bridge therefore registers the returned run before waiting, observes and snapshots `result` immediately, and sends `ChildStarted` only after `started` fulfills while admission remains open. A readiness rejection is refused and host-disposed; `ChildStartError` is sent while worker-message admission remains open, and an already-retired exact run is not cleaned twice. Provider `start()` is itself arbitrary code and may synchronously reenter workflow cancellation before its returned run reaches that registry. The bridge attaches both promise observers, re-checks terminal admission immediately after `start()` returns and again at readiness, and turns a closed boundary into identity-guarded cancellation, disposal, and refusal rather than late worker admission or lifecycle announcement. An arbitrary provider may still fulfill its own `started` promise after the workflow boundary; the bridge refuses and cleans up that attempt instead of claiming it can undo provider-side publication. - Cancellation before readiness is a publication decision, not merely a flag for later result mapping. The in-process run synchronously deactivates its owner fiber. If cancellation lands before publication, the factory's liveness check prevents either creation edge. If it begins synchronously inside `session/created`, `agent/created`, or `agent/session-start`, the publication barrier lets the current notification phase unwind without revoking its world, the next liveness check prevents every later phase and driver start, and rollback pairs every creation edge that already began. In either case `started` rejects, no `subagent/start` or `subagent/end` is emitted, and the run result settles as `aborted`. Receipt of the worker's `Result` message is the workflow host's atomic first-wins boundary. The worker queues that message before its own settlement-reap `ChildCancel` messages, so same-port FIFO prevents an internal child callback from masquerading as earlier run cancellation. Each contender records its claim before its own callback fanout: external `cancel()` records its reason first, while Result receipt snapshots any earlier cancellation and claims the resulting terminal outcome before invoking settlement-cleanup provider code. A caller, signal, or dispose cancellation already in flight therefore overrides a non-cancelled worker report, while the report wins otherwise. Before exposing that chosen result, the host drives both permitted child-cancellation channels by aborting the shared request signal and calling every registered run's `cancel()`, including runs still waiting on readiness. Those calls are settlement-only cleanup, and the terminal claim makes a reentrant `WorkerRun.cancel()` a side-effect-free loser rather than merely repairing its result afterward. Host fanout and the worker's FIFO-later `ChildCancel` can both reach the explicit channel, so a per-call gate invokes each provider `cancel()` at most once; the seam does not require that callback to be idempotent. Explicit child cancel callbacks are contained independently so one throwing callback cannot starve peers or alter settlement. @@ -716,7 +1035,26 @@ Parent teardown reaches `runOwner` by nesting; the provider and returned run han A child persona is a scoped `deployment:persona` section that shadows the deployment-wide section. A child tool filter is a scoped restriction over global end capabilities. Omitted filters remain omitted; a materialized empty `allow` list means “allow nothing” and is not confused with absence. -The child's persona, filter, and structured runtime are installed inside factory setup. The common run-owner fiber gives structured-concurrency-style teardown without importing the parent's capability layer into the child. +The child's persona, filter, and structured runtime are installed inside factory setup. Persona and filtering use public registration methods directly. The package-internal structured helper groups the public tool, prompt, protection, guard, and listener registrations that form one terminal protocol: + +```js +let structured +const setup = childCtx => { + if (persona !== undefined) { + childCtx.systemPrompt.section({ + name: 'deployment:persona', + order: 0, + text: persona, + }) + } + if (toolFilter !== undefined) childCtx.tools.restrict(toolFilter) + if (schema !== undefined) { + structured = attachStructuredRuntime(childCtx, schema) + } +} +``` + +The common run-owner fiber gives structured-concurrency-style teardown without importing the parent's registration layer into the child. The filter affects the child's global tool view; it is not a parent-derived authority ceiling. ### Structured output is a child-owned terminal protocol @@ -784,6 +1122,17 @@ Scope mistakes are fail-open if they merely omit a carrier, so the implementatio `agentEvents(context, agent)` couples the dispatch carrier to the agent argument, `assembleContextFor(agent)` couples prompt facts to the scope selector, and `SessionStore.flush(session)` owns lookup of the carrier captured when the session entered the store. These helpers make a mismatched subject harder to express than the correct spelling. +Their essential construction makes the coupling explicit: + +```text +assembleContextFor(agent): + return { agent, scope: agent } + +agentEvents(context, agent): + carrier = scopeTarget(agent, agent) + return dispatcher that always injects agent as the event subject +``` + ### Type markers cover every scoped event declaration Scoped agent, approval, tool, prompt, session, and subagent lifecycle events declare a `Scoped` receiver. TypeScript therefore rejects a bare subject at typed dispatch sites, including the `subagent/start` and `subagent/end` paths whose scope is the delegating parent. @@ -814,9 +1163,9 @@ Service isolation chooses one registry instance for a context, while agent compo Isolation remains appropriate for independent applications. It is too coarse for collaborating agents inside one deployment. -### Inherit the parent's scope into a child +### Inherit the parent's registrations into a child -Hierarchical capability inheritance makes lifetime convenient but silently grants every child the parent's scoped tools and policies. A flat view plus an explicit parent-owned disposer separates the two questions: the parent owns the child without conferring its authority. +Hierarchical registration inheritance makes lifetime convenient but silently copies every parent-scoped tool and policy into each child. A flat view plus an explicit parent-owned disposer separates lifetime from registration composition: the parent owns the child without importing the parent's layer. As the [security non-goal](#security-and-authority-are-explicit-non-goals) states, flat lookup does not by itself impose a child-within-parent authority relationship. ### Publish the agent before running setup @@ -832,7 +1181,7 @@ Awaited setup makes the transaction explicit and keeps the first assembly behind ### Enforce invariants with prepended waterfall listeners -A prepended listener is not necessarily outermost: another plugin can prepend later, a short-circuit can skip inner work, and an outer wrapper can replace the result after delegation. The same issue appears in prompt assembly, tool authorization, result commit, and turn continuation. +A prepended listener is not necessarily outermost: another plugin can prepend later, a short-circuit can skip inner work, and an outer wrapper can replace the result after delegation. The same issue appears in prompt assembly, tool decisions, result commit, and turn continuation. The owner-final APIs express the actual strength required by each rule: restore named canonical data, deny monotonically, observe the immutable final outcome, or stop after all ordinary continuation inputs are folded. @@ -861,16 +1210,16 @@ The main benefit is one composition model across data, behavior, and lifetime: r ### Costs and constraints -The costs are concentrated in dispatch discipline, per-scope registry state, and explicit authority boundaries that are intentionally stronger than ordinary middleware. +The costs are concentrated in dispatch discipline, per-scope registry state, and owner-final decision boundaries that are intentionally stronger than ordinary middleware. - Every scoped event dispatcher must carry the correct receiver; fused helpers, type markers, invariants, and gates exist because omission would otherwise deliver only to global listeners. -- `agent.ctx` is capability-bearing. Its available services come from the agent loop's injected context, so holders receive that deliberate service surface. +- `agent.ctx` is service-bearing. Its available services come from the agent loop's injected context, so holders receive that deliberate dependency surface; this is not confinement. - Registries maintain per-scope maps and perform a global-plus-one-layer merge for the agent lifetime. - The dispatch carrier is proxy-shaped and not identity-equal to its subject, even though method calls and property access behave like the subject. Its composed filter is frozen, and defining a property through the carrier requires an explicitly configurable descriptor because the extensible surrogate cannot truthfully expose a new non-configurable subject property. -- Flat scopes do not inherit parent capabilities; a desired child capability must be global or explicitly registered for the child. +- Flat scopes do not inherit parent registrations; a desired child-local contribution must be global or explicitly registered for the child. - `run_code` is protected transport infrastructure rather than a filterable end capability, so a policy that must forbid programs denies execution at the tool-policy layer instead of removing the transport from a Code Mode prompt. - Prompt protection restores named canonical contributions and their anchor placement, not the entire assembly; unprotected output remains extensible, while a globally protected section name is deliberately unavailable for scoped shadowing. -- Terminal turn stopping has authority to discard pending steering. That power is appropriate for owner-enforced terminal protocols and too strong for ordinary cooperative continuation policy. +- Terminal turn stopping can discard pending steering. That control is appropriate for owner-enforced terminal protocols and too strong for ordinary cooperative continuation policy. - Programmatic `ctx.agents.create()` and `ctx.agents.resume()` are asynchronous because they await setup. The direct no-setup `ctx.agentLoop.create()` path, used by configuration and programmatic callers that already have complete options, remains synchronous. - A programmatic agent is caller-owned but also structurally owned by its concrete AgentLoop provider. Reloading that provider tears the agent down even if a consumer still holds its handle, because the handle cannot keep the provider's dependency surface valid. - Ordered composition requires exact raw effect identities plus shared public quiescence promises; the dual surfaces and lifecycle-long owner sentinels reflect distinct Cordis nesting and repeated-caller requirements. @@ -878,3 +1227,5 @@ The costs are concentrated in dispatch discipline, per-scope registry state, and ### Deliberate boundaries The scope primitive is generic, but this decision applies it only where one agent needs a coherent registration view: tools, prompt state, scoped events, sessions, and in-process subagent composition. `agent.ctx` does not automatically scope every service call; filesystem policy, LLM interception, background subagent state, and other registries retain their existing seams until their own designs explicitly adopt the context rule. + +Security hardening remains separate design work; the [security and authority non-goals](#security-and-authority-are-explicit-non-goals) define this RFC's trust boundary without turning registration scope into an authorization model. diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 942ec4efb4..88b89f5d3b 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -189,7 +189,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'systemPrompt', - summary: 'Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, named prompt variables, and authoritative contribution protections; the agent loop calls `assemble(context)` once per step.', + summary: 'Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, named prompt variables, and owner-final contribution protections; the agent loop calls `assemble(context)` once per step.', methods: [ 'section(section: PromptSection): () => Promise | void', 'tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise | void', diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md index 1813ff5071..06d06ba59e 100644 --- a/packages/core/scope/README.md +++ b/packages/core/scope/README.md @@ -16,6 +16,6 @@ Scoped-context registration primitive. `createScope(ctx, key)` mints a Cordis co ## Design contract -Ownership and visibility derive from ONE fact — which context a registration went through. An explicit `{ scope }` registration parameter could express "visible to X, disposed with Y", which is almost always a bug; the scoped context makes it unrepresentable. Rationale and alternatives: [the agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md). +Ownership and visibility derive from ONE fact — which context a registration went through. An explicit `{ scope }` registration parameter could express "visible to X, disposed with Y", which is almost always a bug; the scoped context makes it unrepresentable. This is trusted registration and listener routing, not sandboxing or an authority hierarchy: a same-process plugin is not confined, and a child scope need not be a subset of its parent's view. Rationale, alternatives, and the security non-goal: [the agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals). -Handing out a scoped context hands out the minting plugin's service-resolution capability (resolution walks the minting fiber's dependency chain, not the holder's) — mint scopes from a plugin whose `inject` surface is what scope holders should reach. +Handing out a scoped context hands out the minting plugin's service-resolution surface (resolution walks the minting fiber's dependency chain, not the holder's) — mint it from the plugin whose dependencies the scoped registrations need to resolve. diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index 158d6d37d8..526dffbb51 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -27,8 +27,8 @@ import { Context as CordisContext } from 'cordis' // Capture the invocation primordials once. A carrier holder can reach the // composed Context.filter function, so neither that function's mutable -// property surface nor a base filter's own `.call` may choose how isolation -// predicates are invoked. +// property surface nor a base filter's own `.call` may choose how listener- +// selection predicates are invoked. const reflectApply = Reflect.apply // eslint-disable-next-line @typescript-eslint/unbound-method const functionCall = Function.prototype.call @@ -131,7 +131,7 @@ function scope(): void {} * Service resolution through the scoped context flows through the minting * plugin's dependency chain (the fiber walk), regardless of what the eventual * holder's own fiber injected — handing out the scoped context hands out that - * capability; see `Agent.ctx` in `@deepseek-ai/dsh-agent` for the harness's + * dependency surface; see `Agent.ctx` in `@deepseek-ai/dsh-agent` for the harness's * contract. * @param ctx - the context to mount the scope under; its fiber must be active * (a disposing owner throws Cordis's INACTIVE_EFFECT), and its plugin's @@ -201,7 +201,7 @@ function isConstructable(value: (...args: unknown[]) => unknown): boolean { * compatibility default: plain plugin listeners see every subject), or * - its tag IS `key` (a scoped listener seeing exactly its own subject), * - * AND `base`'s own filter (a Cordis `Service`'s isolation check) also admits + * AND `base`'s own filter (a Cordis `Service`'s listener-filter check) also admits * it. Both the captured base filter and the composed filter are invoked * through captured JavaScript primordials, so mutating either function's * public `.call` property cannot bypass either predicate. Dispatching with @@ -263,7 +263,7 @@ export function scopeTarget(base: T, key: ScopeKey | undefined // construction, a base-target proxy would therefore silently replace the // composed scope predicate with the caller's filter. The surrogate owns the // two immutable overlay slots, so later descriptor changes on `base` cannot - // affect isolation. It shares the base prototype and delegates ordinary + // affect listener selection. It shares the base prototype and delegates ordinary // reads/writes/keys to preserve the supported transparent shape. Callable // targets use native bound built-ins so V8 contributes no user-code surface; // the chosen built-in matches whether `base` has [[Construct]], and the traps diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index dd395b3f88..06d735e271 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -1,6 +1,6 @@ # dsh-system-prompt -System prompt assembly registry. Plugins contribute ordered text sections, tool-schema providers, named prompt variables, and authoritative named protections; the agent loop calls `assemble(context)` once per step, and `renderPrompt(assembly)` is the full system prompt the model sees. The plugin registers the harness-owned openers itself — the static `harness:identity` section and the deployment's `deployment:persona` section — so they exist for every agent regardless of which loop plugin drives it. +System prompt assembly registry. Plugins contribute ordered text sections, tool-schema providers, named prompt variables, and owner-final named protections; the agent loop calls `assemble(context)` once per step, and `renderPrompt(assembly)` is the full system prompt the model sees. The plugin registers the harness-owned openers itself — the static `harness:identity` section and the deployment's `deployment:persona` section — so they exist for every agent regardless of which loop plugin drives it. ## Config @@ -16,7 +16,7 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool- - `ctx.systemPrompt.section(section: PromptSection): () => Promise | void` Contribute a section. The registry reads `name`, `order`, and the text value/callback once, validates their fixed string/finite-number/string-or-function types, and stores only that accepted record; later caller-object mutation cannot rename or reshape it. The layer is the CALLING context's scope: `agent.ctx` contributes to that agent alone, SHADOWING a same-named global section there (the per-agent persona mechanism — a scoped `deployment:persona`). Duplicate names within one layer throw, and a globally protected section name cannot be shadowed. Disposed with the calling fiber. - `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise | void` Contribute tool schemas, evaluated at each assembly with that assembly's context; a non-function provider rejects before effect storage. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set for `context.scope`; `knownNames` (defaulting to the same captured schemas' names) is the pre-restriction universe `toolOrder` validates against. Assembly reads the result, each schema field, and the optional known-name list once before detaching them, rejects non-string schema names/descriptions or known names, and uses those same accepted strings for validation and the model-visible collection. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber. - `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => Promise | void` Contribute a prompt variable, referenced from section text as `{{name}}`. The fixed string name and function provider types reject before effect storage. Scoped variables (via `agent.ctx`) shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber. -- `ctx.systemPrompt.protect(protection: PromptProtection): () => Promise | void` Make named section/tool contributions authoritative after the assembly waterfall. Protection restores canonical registry/provider presence and definition; restored entries keep canonical order with one another and anchor before their first surviving later unprotected canonical neighbor (or at the end), without undoing listener reordering of unprotected entries. Canonical absence is authoritative too, so a mode-hidden tool cannot be fabricated by a listener. Calling through `agent.ctx` protects only that agent's assemblies. A global section protection additionally reserves its name against scoped shadows; registering either side of that conflict fails loudly instead of treating the shadow as canonical. Each optional field and array slot is read once, non-array fields or non-string names reject before effect storage, and the accepted arrays are deduplicated and frozen. Finalization materializes each waterfall-produced entry name once, so a stateful getter cannot evade canonical replacement. Empty protections throw, and disposal removes the protection. +- `ctx.systemPrompt.protect(protection: PromptProtection): () => Promise | void` Make named section/tool contributions owner-final after the assembly waterfall. Protection restores canonical registry/provider presence and definition; restored entries keep canonical order with one another and anchor before their first surviving later unprotected canonical neighbor (or at the end), without undoing listener reordering of unprotected entries. Canonical absence is owner-final too, so a mode-hidden tool cannot be fabricated by a listener. Calling through `agent.ctx` protects only that agent's assemblies. A global section protection additionally reserves its name against scoped shadows; registering either side of that conflict fails loudly instead of treating the shadow as canonical. Each optional field and array slot is read once, non-array fields or non-string names reject before effect storage, and the accepted arrays are deduplicated and frozen. Finalization materializes each waterfall-produced entry name once, so a stateful getter cannot evade canonical replacement. Empty protections throw, and disposal removes the protection. - `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer (scoped shadows global). Provider output becomes one coherent detached snapshot before `toolOrder` validation. Runs through the scope-filtered `system-prompt/assemble` waterfall, then restores protected contributions from the pre-waterfall canonical assembly. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe (a restricted-away KNOWN tool is a normal absence), or when a provider returns the reserved rest-entry name. ### Live events diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index b810d07559..40eddc7ba1 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -1,6 +1,6 @@ /** * System prompt assembly registry. Plugins contribute ordered text sections, - * tool schema providers, named prompt variables, and authoritative named + * tool schema providers, named prompt variables, and owner-final named * protections; `assemble(context)` collates them through a waterfall that * runs once per step, restores protected contributions, and `renderPrompt` * interpolates `{{variable}}` references into the final text. @@ -138,9 +138,9 @@ export interface ToolProviderResult { * off the wire in Code Mode). */ export interface PromptProtection { - /** Section names whose canonical registry output is authoritative. */ + /** Section names whose canonical presence and definition are restored after the waterfall. */ sections?: readonly string[] - /** Tool names whose canonical provider output is authoritative. */ + /** Tool names whose canonical presence and definition are restored after the waterfall. */ tools?: readonly string[] } @@ -422,7 +422,7 @@ function interpolate(section: AssembledSection, variables: Record Promise | void { @@ -736,7 +736,7 @@ export class SystemPrompt extends Service { return dispose } - /** Resolve the authoritative names registered for one assembly scope. */ + /** Resolve the owner-final names registered for one assembly scope. */ private protectedNames(scope: ScopeKey | undefined): { sections: Set; tools: Set } { const records = [ ...this.protections, diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 74f1ff14c4..39874a41bd 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -16,7 +16,7 @@ tools: ### Public API - `ctx.tools.register(definition: ToolDefinition): () => Promise | void` Register a tool as a frozen snapshot. Every top-level caller field is read once into one coherent acceptance record; `name`/`description` must be strings and `timeoutMs`, when present, must be positive and finite before the snapshot can own them. Parameters are validated and detached by one recursive lossless-JSON traversal, so a stateful getter cannot show one value to a check and another to a prototype-erasing clone. Execute/presentation callbacks are bound once to the original definition as their method receiver, so later caller mutation cannot change the executable definition. The layer is the CALLING context's scope (`dsh-scope`): a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, SHADOWING a same-named global tool there (per-agent tool variants). Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Disposed with the calling fiber (= the agent, for scoped registrations). -- `ctx.tools.restrict(filter: ToolRestriction): () => Promise | void` Scoped-only (throws on a plain context): mask the GLOBAL end-capability surface for the calling agent — `allow` keeps only the listed tools, `deny` removes them; multiple restrictions intersect; scoped registrations bypass restriction as explicit grants. The registry reads `allow`/`deny` once, so the values checked for an empty filter and unknown names are exactly the values enforced. The reserved `run_code` transport remains available automatically and cannot be named explicitly. Snapshot-at-registration, loud unknown-name validation, `restrict({})` rejects (the materialized-empty-config trap). +- `ctx.tools.restrict(filter: ToolRestriction): () => Promise | void` Scoped-only (throws on a plain context): mask the GLOBAL end-capability surface for the calling agent — `allow` keeps only the listed tools, `deny` removes them; multiple restrictions intersect; scope-local registrations are merged after the global filter. The registry reads `allow`/`deny` once, so the values checked for an empty filter and unknown names are exactly the values enforced. A deny-list admits a later global tool unless it names that tool; an allow-list excludes later names; neither filters a later scope-local registration. The reserved `run_code` transport remains available automatically and cannot be named explicitly. Filter-value snapshot at registration, loud unknown-name validation, `restrict({})` rejects (the materialized-empty-config trap). This is live registration composition, not a parent-derived authority ceiling; see the [agent-scope security non-goal](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals). - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. Returned definitions are the registry's frozen snapshots. - `ctx.tools.visible(scope?: ScopeKey): ToolDefinition[]` The canonical executable view — restricted global layer ∪ the scope's own layer, plus the reserved transport in non-native modes — feeding prompt assembly, `get`, and `execute`, so presentation and dispatch resolve the same frozen definitions. - `ctx.tools.knownNames(scope?: ScopeKey): string[]` The PRE-restriction end-capability name universe `restrict` validates against: a typo fails loud while a restricted-away tool stays a normal absence. Presentation providers add reserved transport names separately when validating `toolOrder`. diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index d757f6cffa..4dd59efb8c 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -115,8 +115,8 @@ declare module 'cordis' { * set or replace the one mutable field, `exec.signal` (e.g. with a per-call * deadline), BEFORE `next()`, restore/delete it afterward, and inspect the result AFTER. Call identity * (`token`, `callId`, `name`, `arguments`, `agent`, and `parent`) is immutable throughout the - * pipeline so a wrapper cannot change which capability or scope was - * authorized. (Cordis `next()` ignores passed arguments and re-invokes + * pipeline so a wrapper cannot change which tool and scope the pipeline + * accepted. (Cordis `next()` ignores passed arguments and re-invokes * downstream with the shared payload, so a wrapper changes `exec.signal` in * place rather than passing a new object to `next()`.) * Multiple listeners compose by registration order — an outer one wraps the @@ -429,8 +429,11 @@ export interface Config { * {@link ToolRegistry.restrict}. `allow` keeps only the listed global tools; * `deny` removes the listed ones; both present = allow first, then deny. * Restrictions never touch scoped registrations — a tool registered through - * the same scope is an explicit grant that bypasses them (which is what keeps - * e.g. a structured-output capture tool alive under an allow-list). The + * the same scope is merged after the global filter (which is what keeps e.g. a + * structured-output capture tool alive under an allow-list). The filter values + * are snapshotted at registration, but resolution uses the live global registry: + * a later global name passes a deny-only filter unless explicitly denied and + * fails an allow-list unless explicitly allowed. The * reserved `run_code` presentation transport is likewise outside capability * filtering, and naming it explicitly is rejected. Multiple restrictions on * one scope compose by intersection: every one must admit. @@ -705,11 +708,14 @@ export class ToolRegistry extends Service { * this). A non-native mode's reserved `run_code` presentation transport is * not a filterable capability; naming it explicitly throws, while omitting * it from an allow-list cannot remove it. `allow` and `deny` are each read - * once, then the filter is SNAPSHOT at registration: the values checked are - * the values enforced, and later caller mutation of the arrays changes nothing. - * Multiple restrictions compose by intersection. Scoped registrations - * bypass restrictions (explicit grants win). Disposed with the calling - * fiber (revocable independently); emits `tools/change`. + * once, then the filter VALUES are snapshotted at registration: the values + * checked are the values enforced, and later caller mutation of the arrays + * changes nothing. Resolution still uses the live global registry, so a later + * global name passes a deny-only filter unless named and fails an allow-list + * unless named. Multiple restrictions compose by intersection. Scoped + * registrations are merged after restrictions and therefore remain visible. + * Disposed with the calling fiber (revocable independently); emits + * `tools/change`. * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove). * @returns the disposer that lifts this restriction. The exact * Cordis effect disposer (single-shot): composite (generator) effects may @@ -863,7 +869,7 @@ export class ToolRegistry extends Service { if (this.admits(scope, name)) result.set(name, definition) } // Scoped layer second: same-name entries REPLACE (shadow) the global ones, - // and grants bypass restrictions by construction (never filtered above). + // and scope-local registrations are never part of the global filter above. for (const [name, definition] of layer ?? []) result.set(name, definition) // Presentation infrastructure is resolved last and outside capability // filtering. Registration rejects this reserved name, so this set is an diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index ea4ef57ddd..40671ae6fc 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -101,7 +101,7 @@ describe('scoped tool registration', () => { }) describe('restrict()', () => { - it('masks global tools for the scope; grants bypass; assembly and execute agree', async () => { + it('masks global tools, merges scope-local tools afterward, and keeps assembly with execution', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'a') ctx.tools.register(tool('read')) @@ -109,7 +109,7 @@ describe('restrict()', () => { scope.ctx.tools.register(tool('capture')) scope.ctx.tools.restrict({ allow: ['read'] }) - // The scoped grant survives the allow-list; the unlisted global is gone. + // The scope-local registration survives the allow-list; the unlisted global is gone. expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['capture', 'read']) expect(await run(ctx, 'bash', key)).toBe('Error: unknown tool "bash"') expect(await run(ctx, 'read', key)).toBe('ran:read') @@ -118,6 +118,29 @@ describe('restrict()', () => { expect(ctx.tools.schemas().map(t => t.name).sort()).toEqual(['bash', 'read']) }) + it('applies snapshotted filters to the live global registry before merging later scope-local tools', async () => { + const ctx = await mount() + const denied = await mintAgentScope(ctx, 'denied') + const allowed = await mintAgentScope(ctx, 'allowed') + ctx.tools.register(tool('read')) + ctx.tools.register(tool('bash')) + denied.scope.ctx.tools.restrict({ deny: ['bash'] }) + allowed.scope.ctx.tools.restrict({ allow: ['read'] }) + + ctx.tools.register(tool('web')) + denied.scope.ctx.tools.register(tool('denied-local')) + allowed.scope.ctx.tools.register(tool('allowed-local')) + + expect(ctx.tools.schemas(denied.key).map(t => t.name).sort()) + .toEqual(['denied-local', 'read', 'web']) + expect(ctx.tools.schemas(allowed.key).map(t => t.name).sort()) + .toEqual(['allowed-local', 'read']) + expect(await run(ctx, 'web', denied.key)).toBe('ran:web') + expect(await run(ctx, 'web', allowed.key)).toBe('Error: unknown tool "web"') + expect(await run(ctx, 'denied-local', denied.key)).toBe('ran:denied-local') + expect(await run(ctx, 'allowed-local', allowed.key)).toBe('ran:allowed-local') + }) + it('composes multiple restrictions by intersection and lifts each independently', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'a') diff --git a/packages/subagent/subagent-fork/README.md b/packages/subagent/subagent-fork/README.md index 1434601c8e..2aef651839 100644 --- a/packages/subagent/subagent-fork/README.md +++ b/packages/subagent/subagent-fork/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-subagent-fork -The in-process **fork** subagent backend: a [`SubagentProvider`](../subagent/README.md) that runs each child as a child [`Agent`](../../core/agent) **seeded with a prefix of the parent's session log** — so the child inherits the parent's conversation context instead of starting fresh. Shares the run driver (`startInProcessRun`) with [`dsh-subagent-spawn`](../subagent-spawn/README.md); the only difference is the seed. The shared `run.started` boundary resolves only after the seeded child is published, so `subagent/start` observers see a live registry entry. +The in-process **fork** subagent backend: a [`SubagentProvider`](../subagent/README.md) that runs each child as a child [`Agent`](../../core/agent) **seeded with a prefix of the parent's session log** instead of starting with an empty conversation. The seed affects conversation history only. Tool registrations and restrictions follow the child's fresh flat scope; no parent/child authority relation is defined. Fork shares the run driver (`startInProcessRun`) with [`dsh-subagent-spawn`](../subagent-spawn/README.md); the only difference is the seed. The shared `run.started` boundary resolves only after the seeded child is published, so `subagent/start` observers see a live registry entry. ## The seed boundary (the crux) diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index b96cc811ad..0980d85aa9 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -8,13 +8,15 @@ The shared **in-process subagent run driver**. A library with no provider or imp Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`): -1. reads every public request and seed field once before asynchronous owner setup: the parent and signal remain identity capabilities, while tool filter, seed, agent options, output schema, and prompt are each materialized by the shared one-pass lossless-JSON snapshot. It rejects a malformed `request.maxDepth`, validates the parent's `subagentDepth`, computes child depth = `depthOf(parent) + 1`, rejects cap overflow with `SubagentDepthError`, reports an invalid schema as `OutputSchemaError`, and derives both the child prefix and `seedLength` from the same detached seed; +1. reads every public request and seed field once before asynchronous owner setup: the parent and signal remain identity capabilities, while tool filter, seed, agent options, output schema, and prompt are each materialized by the shared one-pass lossless-JSON snapshot. It rejects malformed `request.maxDepth` and `persona` values, validates the parent's `subagentDepth`, computes child depth = `depthOf(parent) + 1`, rejects a child depth outside the safe-integer domain with `RangeError`, rejects a defined `maxDepth` cap breach with `SubagentDepthError`, reports an invalid schema as `OutputSchemaError`, and derives both the child prefix and `seedLength` from the same detached seed; 2. first installs provider ownership, then attaches the request abort listener and creates one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves no child or orphaned listener. Async child creation goes through that fiber's `ctx.agents` service with fresh IDs, lineage/seed, inherited model, and an unpublished setup transaction for persona, tool restriction, and structured output. Parent teardown, provider teardown, manual `run.dispose()`, and cancellation before readiness all dispose this exact node, preventing publication after it becomes inactive and sharing the same quiescence boundary. `startInProcessRun` still returns its `SubagentRun` immediately: `run.started` resolves only after `ctx.agents.create()` has published the child and rejects when pre-readiness cancellation rolls the transaction back; 3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts); there is deliberately NO re-prompt for a structured child that finished cleanly without calling `structured_output` — the shortfall maps to an `error` result for the parent; 4. reads the result, scoped to the child's OWN events (everything at or after `seedLength`, so a seeded child that produced no message of its own never returns the seeded parent's last message): the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. A structured run surfaces the captured value as `result.structured`; a structured child that finished cleanly WITHOUT ever capturing settles `error` (a clean finish without the demanded result is a failure, not a success with a missing field). `SubagentService` waits for `run.started` before emitting `subagent/start`, so a synchronous start observer can resolve the published child with `ctx.agents.get(run.id)`; the result driver awaits the same boundary before sending the prompt. An attempt that never publishes rejects readiness and emits no false start/end pair; its result reports a deliberate cancel/dispose as `aborted` and propagates an infrastructure fault. `dispose()` awaits creation or rollback and then delegates to `AgentHandle.dispose()` (stop and drain → remove agent → detach session → unwind scope). Before readiness, `cancel()` deactivates the creation owner: before creation notification begins, no agent/session lifecycle edge escapes; if cancellation is triggered synchronously by a creation observer, every begun edge is paired by rollback and the driver never unlocks or starts. After readiness, cancellation reaches the live child immediately. Either path records the cancellation, so a cancel landing before any `turn/end` settles `aborted`, honoring the cancel contract rather than the generic no-turn `error`. +The child receives a fresh flat registration scope. Its `toolFilter` masks the live global tool layer and scope-local registrations are merged afterward; parent ownership does not import the parent's tool restrictions or establish an authority subset. The [agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals) owns that explicit non-goal. + ### `InProcessRunOptions` `{ seed?: SessionEvent[] }` — the optional child-session seed: absent for spawn, or the parent's balanced completed-turn prefix for fork. @@ -32,8 +34,8 @@ Runs a child as a child [`Agent`](../../core/agent) on the same cordis context ( ### `depthOf(agent): number` -Delegation depth rides on a merge-extensible `AgentOptions.subagentDepth` field (0 for a top-level agent, parent + 1 for a child), so a nested spawn reads its parent's depth from `parent.options.subagentDepth`. `depthOf` reads it (absent ⇒ 0). +Delegation depth rides on a merge-extensible `AgentOptions.subagentDepth` field (0 for a top-level agent, parent + 1 for a child), so a nested spawn reads its parent's depth from `parent.options.subagentDepth`. `depthOf` treats only `undefined` as absent (top-level depth 0) and rejects every malformed present value instead of letting it disable the cap comparison. ### `SubagentDepthError` -Thrown by `startInProcessRun` when a spawn would exceed the request's `maxDepth` cap; carries `attemptedDepth` and `maxDepth`. +Thrown by `startInProcessRun` when a spawn would exceed the request's defined `maxDepth` cap; carries `attemptedDepth` and `maxDepth`. A valid parent at `Number.MAX_SAFE_INTEGER` instead produces `RangeError`, because its child depth cannot be represented within the stored safe-integer domain even when `maxDepth` is omitted. diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index e0ad53c486..3ccb08fda9 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -58,7 +58,8 @@ declare module '@deepseek-ai/dsh-agent' { * @returns 0 for a top-level agent, its parent's depth + 1 for a subagent. */ export function depthOf(agent: Agent): number { - const depth = agent.options.subagentDepth ?? 0 + const depth = agent.options.subagentDepth + if (depth === undefined) return 0 if (!Number.isSafeInteger(depth) || depth < 0 || Object.is(depth, -0)) { throw new TypeError('agent subagentDepth must be a non-negative safe integer') } @@ -123,7 +124,8 @@ async function quiesceFiber(fiber: Fiber): Promise { * resolves `aborted`. * * Throws {@link SubagentDepthError} before creating anything when the child's - * depth (parent depth + 1) would exceed `request.maxDepth`. + * depth (parent depth + 1) would exceed `request.maxDepth`, and throws a + * `RangeError` when a valid parent depth has no safe-integer successor. * @param ctx - the provider context that owns the live run as a second * structured-concurrency boundary alongside the parent agent. * @param request - the start request (prompt, parent, signal, per-child options). @@ -147,6 +149,9 @@ export function startInProcessRun( const inputAgentOptions = request.agentOptions const inputSeed = options.seed assertSubagentMaxDepth(inputMaxDepth) + if (persona !== undefined && typeof persona !== 'string') { + throw new TypeError('subagent persona must be a string') + } const toolFilter = inputToolFilter === undefined ? undefined : snapshotJsonValue(inputToolFilter) if (inputToolFilter !== undefined && toolFilter === undefined) { throw new TypeError('subagent tool filter must be losslessly JSON-serializable') @@ -156,6 +161,9 @@ export function startInProcessRun( throw new TypeError('subagent seed must be losslessly JSON-serializable') } const childDepth = depthOf(parent) + 1 + if (!Number.isSafeInteger(childDepth)) { + throw new RangeError('subagent child depth exceeds the safe-integer range') + } if (inputMaxDepth !== undefined && childDepth > inputMaxDepth) { throw new SubagentDepthError(childDepth, inputMaxDepth) } diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 1e63617154..54ac968f4b 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -36,7 +36,7 @@ const SCHEMA: StructuredOutputSchema = { } /** - * Real loop + scripted mock model + an INLINE spawn-shaped provider over the + * Real loop + scripted mock model + an INLINE fresh-conversation provider over the * shared driver. The concrete backend plugins are deliberately NOT loaded — * they would devDep-cycle this package (spawn/fork already depend on the * driver), and the runtime under test is the driver's; plugin-level structured diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index db37c974b3..8f710237ab 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -48,6 +48,7 @@ describe('depthOf', () => { }) it.each([ + { label: 'null', value: null as unknown as number }, { label: 'a string', value: '1' as unknown as number }, { label: 'NaN', value: Number.NaN }, { label: 'positive infinity', value: Number.POSITIVE_INFINITY }, @@ -64,6 +65,7 @@ describe('depthOf', () => { describe('startInProcessRun', () => { it.each([ + { label: 'null', value: null as unknown as number }, { label: 'a string', value: '1' as unknown as number }, { label: 'NaN', value: Number.NaN }, { label: 'positive infinity', value: Number.POSITIVE_INFINITY }, @@ -81,6 +83,27 @@ describe('startInProcessRun', () => { }, {})).toThrow('subagent maxDepth must be a non-negative safe integer') }) + it('rejects a non-string persona before acquiring run ownership', async () => { + const { ctx, parent } = await setup([]) + expect(() => startInProcessRun(ctx, { + prompt: [{ type: 'text', text: 'must never start' }], + parent, + persona: 42 as unknown as string, + }, {})).toThrow('subagent persona must be a string') + }) + + it('rejects a child depth with no safe-integer representation before acquiring run ownership', async () => { + const { ctx } = await setup([]) + const parent = { + options: { subagentDepth: Number.MAX_SAFE_INTEGER }, + } as unknown as Agent + + expect(() => startInProcessRun(ctx, { + prompt: [{ type: 'text', text: 'must never start' }], + parent, + }, {})).toThrow(RangeError) + }) + it('rejects a non-JSON prompt before acquiring any run ownership', async () => { const { ctx, parent } = await setup([]) expect(() => startInProcessRun(ctx, { diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index e98d8cd8de..6470b64ee5 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -29,7 +29,7 @@ Unlike the bash seam (one executor per context, second load throws), **multiple - **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter/persona`) are a static `provider.capabilities` descriptor, checked by the service BEFORE a run exists. A request that needs one the provider lacks is **rejected loud** (`UNSUPPORTED_CAPABILITY`), never accepted-then-ignored. - **Runtime features** (steering, resume) are **optional methods** on `SubagentRun` (`sendMessage?`, `resume?`). The method's presence IS the capability; TS narrowing is the discovery mechanism — a consumer cannot call an absent method without narrowing first, so there is no silent degradation path. -Beside `capabilities` sits one DESCRIPTIVE fact: `provider.inheritsParentContext` — whether a child sees the parent conversation (`fork`: true — seeded with the completed-turn prefix; `spawn`/`acp`: false). The service validates that the descriptor is a boolean but does not interpret or enforce its meaning; the model-facing consumer (`dsh-tool-subagent`) derives truthful tool wording from it. +Beside `capabilities` sits one DESCRIPTIVE fact: `provider.inheritsParentContext` — whether a child sees the parent conversation (`fork`: true — seeded with the completed-turn prefix; `spawn`/`acp`: false). “Context” here means conversation history only; it says nothing about tool registrations, injected services, or authority inheritance. The service validates that the descriptor is a boolean but does not interpret or enforce its meaning; the model-facing consumer (`dsh-tool-subagent`) derives truthful tool wording from it. ## Run lifecycle diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 97efd4661f..42f55b300d 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -69,9 +69,10 @@ export interface SubagentStartRequest { */ outputSchema?: StructuredOutputSchema /** - * Optional recursion cap (max delegation depth below this child). Must be a - * non-negative safe integer. Requires {@link SubagentCapabilities.depthLimit}; - * rejected at start otherwise. + * Optional absolute delegation-depth cap for the child being started: its + * computed depth must be less than or equal to this non-negative safe + * integer. Requires {@link SubagentCapabilities.depthLimit}; rejected at + * start otherwise. */ maxDepth?: number /** @@ -195,13 +196,15 @@ export interface SubagentProvider { /** The start-time features this provider supports (see {@link SubagentCapabilities}). */ readonly capabilities: SubagentCapabilities /** - * The provider's context contract: `true` when a child SEES the parent + * The provider's conversation-history descriptor: `true` when a child SEES the parent * conversation (fork — the child is seeded with the parent's completed-turn * prefix), `false` when it starts fresh (spawn, ACP). A DESCRIPTIVE fact, * not a start-time capability: the service validates nothing against it — * the model-facing consumer (`dsh-tool-subagent`) derives truthful tool * wording from it, so a tool bound to a fork provider stops telling the - * model the child "does not see this conversation". + * model the child "does not see this conversation". This descriptor concerns + * conversation history only; it says nothing about tool registrations, + * injected services, or authority inheritance. */ readonly inheritsParentContext: boolean /** diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index faf4bb00b3..182ee7ac8b 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -226,7 +226,7 @@ describe('SubagentService', () => { patch: { capabilities: { ...NO_CAPS, persona: 'yes' } }, message: 'capability "persona" must be a boolean', }, - { label: 'a non-boolean context descriptor', patch: { inheritsParentContext: 'yes' }, message: 'inheritsParentContext must be a boolean' }, + { label: 'a non-boolean conversation-history descriptor', patch: { inheritsParentContext: 'yes' }, message: 'inheritsParentContext must be a boolean' }, { label: 'a non-callable start field', patch: { start: 42 }, message: 'start must be a function' }, ])('rejects a provider registration with $label before entering the registry', async ({ patch, message }) => { const ctx = new Context() @@ -431,6 +431,7 @@ describe('SubagentService', () => { }) it.each([ + { label: 'null', value: null as unknown as number }, { label: 'a string', value: '1' as unknown as number }, { label: 'NaN', value: Number.NaN }, { label: 'positive infinity', value: Number.POSITIVE_INFINITY }, diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 9aed426ac0..2163d4c884 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -6,9 +6,9 @@ The model-facing `subagent` tool: delegate a self-contained task to a child agen This plugin binds to **exactly one** provider (`Config.provider`). The model sees only `{ description, prompt }` — there is no provider/type parameter in the schema. To expose more than one transport, load the plugin more than once, each bound to a different provider **and a distinct `toolName`** (the tool registry rejects a duplicate name, so a second load that kept the default `subagent` name would throw). Keeping selection in config (not the schema) is the deliberate split: the *service* holds a multi-provider registry; the *tool* picks one. -## The description states the provider's context contract +## The description states the provider's conversation-history descriptor -The tool description and the `prompt` parameter description are DERIVED from the bound provider's `inheritsParentContext` (`providerWording`): a fresh-context provider (spawn, ACP) gets the standalone-prompt wording ("it does not see this conversation"), an inheriting provider (fork) tells the model the child already sees the conversation's completed turns and its prompt should state only what is new. Because the description is fixed at tool registration, the tool **mirrors the provider's lifecycle** (`subagent/provider-added`/`-removed`): it registers when the bound provider is (or becomes) available and unregisters when the provider goes away — no load-order requirement (the cordis Loader starts sibling entries concurrently, so "listed first" never guaranteed "registered first"), and an HMR reload of the backend re-derives the wording from the fresh provider. While the provider is absent the tool simply does not exist (a `ctx.logger` note records the wait; a typo'd provider name shows up as a tool that never materializes). +The tool description and the `prompt` parameter description are DERIVED from the bound provider's `inheritsParentContext` (`providerWording`): a fresh-conversation provider (spawn, ACP) gets the standalone-prompt wording ("it does not see this conversation"), while fork tells the model the child is seeded with the conversation's completed turns and its prompt should state only what is new. The descriptor concerns conversation history only, not tool or authority inheritance. Because the description is fixed at tool registration, the tool **mirrors the provider's lifecycle** (`subagent/provider-added`/`-removed`): it registers when the bound provider is (or becomes) available and unregisters when the provider goes away — no load-order requirement (the cordis Loader starts sibling plugins concurrently, so "listed first" never guaranteed "registered first"), and an HMR reload of the backend re-derives the wording from the fresh provider. While the provider is absent the tool simply does not exist (a `ctx.logger` note records the wait; a typo'd provider name shows up as a tool that never materializes). | Config key | Meaning | |---|---| @@ -17,7 +17,9 @@ The tool description and the `prompt` parameter description are DERIVED from the | `agentOptions` | Default per-child `{ model? }` applied to every spawned child. | | `persona` | Per-child persona that shadows the deployment persona; requires the provider's `persona` capability. | | `toolFilter` | Per-child `{ allow?, deny? }` restriction over global tools; requires the provider's `toolFilter` capability. | -| `maxDepth` | Maximum delegation depth; a non-negative safe integer validated when this plugin loads. Requires the provider's `depthLimit` capability. | +| `maxDepth` | Maximum absolute delegation-tree depth for every child this tool starts; a non-negative safe integer validated when this plugin loads. Requires the provider's `depthLimit` capability. | + +`toolFilter` uses [`ToolRegistry.restrict()`](../../core/tools/README.md)'s live global-view semantics and is not a parent-derived authority ceiling; see the [agent-scope security non-goal](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals). ## Lifecycle (synchronous collect) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index ca7f20d23d..25bd5e3acf 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -11,10 +11,12 @@ * — there is no provider/type parameter in the model-facing schema. The model * sees only `{ description, prompt }`. * - * The tool DESCRIPTION is derived from the bound provider's context contract - * ({@link providerWording}): a fresh-context provider (spawn, ACP) gets the - * standalone-prompt wording, an inheriting provider (fork) tells the model the - * child already sees the conversation's completed turns. The tool MIRRORS the + * The tool DESCRIPTION is derived from the bound provider's conversation-history + * descriptor ({@link providerWording}): a fresh-conversation provider (spawn, + * ACP) gets the standalone-prompt wording, while a seeded-conversation provider + * (fork) tells the model the child already sees the conversation's completed + * turns. This descriptor says nothing about Cordis scope, services, tools, or + * authority. The tool MIRRORS the * provider's lifecycle via `subagent/provider-added`/`-removed` — it registers * when the provider is (or becomes) available and unregisters when the * provider goes away — so no load-order requirement exists and an HMR reload @@ -154,16 +156,19 @@ function stopReasonError(result: SubagentResult): string | undefined { } /** - * Model-facing wording per context contract ({@link SubagentProvider.inheritsParentContext}). + * Model-facing wording from the provider's conversation-history descriptor + * ({@link SubagentProvider.inheritsParentContext}). * A fresh child needs a standalone prompt; a forked child already sees the * conversation's completed turns — telling the model to restate everything * (or, worse, that the child "does not see this conversation") would be false * for a fork. Exported for tests. - * @param inherits - the bound provider's context contract. + * @param inheritsConversation - whether the child's conversation is seeded + * with the parent's completed turns; this says nothing about tool, service, + * scope, or authority inheritance. * @returns the tool `description` and the `prompt` parameter description. */ -export function providerWording(inherits: boolean): { description: string; promptDescription: string } { - if (inherits) { +export function providerWording(inheritsConversation: boolean): { description: string; promptDescription: string } { + if (inheritsConversation) { return { description: 'Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all ' diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 4e125d24e1..701a4dc667 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -220,7 +220,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(SubagentService) - const backend = await ctx.plugin(mock, { name: 'mock' }) // spawn-shaped (inherits: false) + const backend = await ctx.plugin(mock, { name: 'mock' }) // fresh conversation (descriptor: false) await ctx.plugin(tool, { provider: 'mock' }) expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('does not see this conversation') @@ -228,7 +228,7 @@ describe('dsh-tool-subagent', () => { await backend.dispose() expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(false) - // Backend reloads with a DIFFERENT contract: the wording is re-derived + // Backend reloads with a DIFFERENT conversation-history descriptor: the wording is re-derived // from the fresh provider, not served stale from the first mount. await ctx.plugin(mock, { name: 'mock', inheritsParentContext: true }) expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('INHERITS this conversation') @@ -273,7 +273,7 @@ describe('dsh-tool-subagent', () => { expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(true) }) - it('derives spawn-shaped wording from a fresh-context provider (default mock)', async () => { + it('derives spawn-shaped wording from a fresh-conversation provider (default mock)', async () => { const ctx = await setup({ provider: 'mock' }) const schema = ctx.tools.schemas().find(s => s.name === 'subagent')! expect(schema.description).toContain('does not see this conversation') @@ -281,7 +281,7 @@ describe('dsh-tool-subagent', () => { expect(props['prompt']!.description).toContain('include everything it needs') }) - it('derives fork-shaped wording from an inheriting provider (the description stops lying)', async () => { + it('derives fork-shaped wording from a seeded-conversation provider (the description stops lying)', async () => { const ctx = await setup({ provider: 'mock', toolName: 'subagent' }, { inheritsParentContext: true }) const schema = ctx.tools.schemas().find(s => s.name === 'subagent')! expect(schema.description).toContain('INHERITS this conversation') @@ -494,6 +494,8 @@ describe('dsh-tool-subagent', () => { }) it.each([ + { label: 'null', value: null as unknown as number }, + { label: 'a string', value: '1' as unknown as number }, { label: 'NaN', value: Number.NaN }, { label: 'positive infinity', value: Number.POSITIVE_INFINITY }, { label: 'negative infinity', value: Number.NEGATIVE_INFINITY }, @@ -506,6 +508,16 @@ describe('dsh-tool-subagent', () => { .rejects.toThrow() }) + it('validates maxDepth when apply() is invoked directly without Schemastery', () => { + const ctx = new Context() + expect(() => { + tool.apply(ctx, { + provider: 'unused', + maxDepth: Number.NaN, + }) + }).toThrow('subagent maxDepth must be a non-negative safe integer') + }) + it('a partial toolFilter (deny only) does not materialize an empty allow-list (deny-all trap)', async () => { let seen: { toolFilter?: { allow?: string[]; deny?: string[] } } | undefined const ctx = new Context() diff --git a/packages/support/subagent-mock/README.md b/packages/support/subagent-mock/README.md index af169ed4c2..6114bdf2c9 100644 --- a/packages/support/subagent-mock/README.md +++ b/packages/support/subagent-mock/README.md @@ -14,7 +14,7 @@ Load it as a plugin (functional shape: `name`/`inject`/`Config`/`apply`, no defa | `reply` | `mock subagent reply` | The scripted child's final answer text. | | `stopReason` | `completed` | The stop reason `result` settles with. | | `capabilities` | all `true` | Which start-time capabilities (`outputSchema`/`depthLimit`/`toolFilter`) the provider advertises. | -| `inheritsParentContext` | `false` | The context contract to declare; `true` exercises the fork-shaped tool wording in consumer tests. | +| `inheritsParentContext` | `false` | Conversation-history descriptor: `false` means fresh, while `true` exercises seeded/fork wording. It says nothing about tool, service, scope, or authority inheritance. | | `structured` | `{ reply }` | Structured value surfaced when a request carries an `outputSchema` and the capability is on. | A `cancel()` issued before `result` settles flips the stop reason to `aborted`, so the cancellation path is observable. diff --git a/packages/support/subagent-mock/src/index.ts b/packages/support/subagent-mock/src/index.ts index 715c7451d7..554889db71 100644 --- a/packages/support/subagent-mock/src/index.ts +++ b/packages/support/subagent-mock/src/index.ts @@ -94,9 +94,11 @@ export interface Config { /** Which start-time capabilities to advertise (default: all `true`). */ capabilities?: Partial /** - * The context contract to declare ({@link SubagentProvider.inheritsParentContext}); - * default `false` (spawn-like). Set `true` to exercise the fork-shaped tool - * wording in consumer tests. + * The conversation-history descriptor to declare + * ({@link SubagentProvider.inheritsParentContext}); default `false` (fresh + * conversation). Set `true` to exercise seeded/fork wording in consumer + * tests. This flag says nothing about tool, service, scope, or authority + * inheritance. */ inheritsParentContext?: boolean /** From c6d012109f00a01cd91887159b15e1747f04e30f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:22:19 +0800 Subject: [PATCH 48/64] docs(scope): restructure RFC top-down --- .../2026-07-08-agent-scope-contexts.md | 1522 ++++++++--------- 1 file changed, 734 insertions(+), 788 deletions(-) diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md index 9162273c72..9707d550f2 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -4,64 +4,41 @@ Status: implemented ## Problem -One application can run many agents that share infrastructure but must not share every capability or policy. A child agent may need a different persona, fewer tools, its own structured-result schema, and listeners that govern only its work, while still using the deployment's model adapters, persistence backend, tool implementations, and user interface. +One application needs to share infrastructure across many agents while giving each agent a coherent local world. Model adapters, persistence, user interfaces, and most tool implementations belong to the deployment; personas, visible tools, live policy, and cleanup often belong to one agent. -This is a composition problem, not an application-isolation or security-confinement problem. Starting a separate service graph for every child would isolate too much; putting every registration in one global graph isolates too little. +This is a composition problem, not an application-isolation problem. A separate service graph per agent duplicates too much shared infrastructure, while one global registration graph lets agent-specific contributions leak across agents. -| Surface | What varies by agent | Failure when it is only global | +| Question | Required behavior | Failure without it | |---|---|---| -| Tools | Available capabilities, a child-only tool, or a scoped replacement for one implementation | The model receives the wrong tool view, or a child-specific tool leaks into every prompt | -| Prompt state | Persona, instructions, variables, and [Code Mode](../feature/2026-06-15-code-mode.md) SDK declarations | Every agent receives the same instructions or runtime facts | -| Live policy | Hooks, execution guards, result observers, and continuation rules | A listener intended for one agent can alter another agent's work | -| Lifetime | Cleanup when the agent fails, is cancelled, is disposed, or loses its owner | Registrations outlive the agent or disappear before its final work settles | +| What participates? | Each operation sees deployment-global contributions plus the contributions for its agent | A child-only tool, prompt, or listener affects unrelated agents | +| When does that world exist? | The complete agent world appears only after setup and remains until work and cleanup reach quiescence | Observers see partial setup, or final work loses its scoped policy | +| Which value is authoritative? | Validation, execution, logging, and observation use the same accepted data | Mutable inputs pass one check and produce different behavior later | +| What may extensions override? | Ordinary middleware stays extensible, while a few protocol invariants finish at owner-controlled boundaries | Listener order removes required prompt state, re-allows denied work, commits a failed result, or forces an extra model step | -Three consistency requirements make the problem deeper than filtering a list. First, the model-visible and executable views must agree: a hidden tool must not remain callable, and an advertised tool must not fail merely because execution used a different registry view. This agreement must also cover Code Mode bindings and UI presentation. - -Second, some rules are invariants rather than cooperative extensions. An ordinary middleware listener may replace a prompt assembly, turn an allow into a deny, rewrite a result, force another model step, or short-circuit listeners registered after it. Structured output therefore cannot rely on being “first” or “last” in an extensible listener chain; the owning service needs a final boundary for rules that later listeners must not undo. - -Third, accepting a value must transfer ownership of the exact value that was checked. TypeScript `readonly` annotations disappear at runtime, callers and providers may expose stateful accessors, and a validation pass followed by a clone reads mutable input twice. Identity fields, schemas, session data, requests, and results therefore need runtime boundaries that capture each caller-owned field once, materialize data once, and expose only owner-controlled snapshots. Otherwise the checked, executed, logged, and observed views can diverge even when scope resolution itself is correct. - -The failure does not require TypeScript or threads. One JavaScript getter is enough to make a two-read boundary validate one name and store another: - -```js -let reads = 0 -const input = { - get name() { - reads += 1 - return reads === 1 ? 'safe_tool' : 'different_tool' - }, -} - -// Wrong: validation and storage observe different values. -validateName(input.name) -storeName(input.name) - -// Right: capture once, then validate and store that capture. -reads = 0 -const acceptedName = input.name -validateName(acceptedName) -storeName(acceptedName) -``` - -The subagent API makes these requirements concrete. Two concurrent children can request different personas, tool filters, and output schemas. Those requests are honest only when each child receives an independently owned view and when its terminal-output protocol survives unrelated plugins. +In-process subagents expose all four requirements at once. Two concurrent children can request different personas, tool filters, and structured-result schemas; each child must receive its own complete view, publish only after that view exists, preserve the exact accepted request, and keep terminal structured-output rules stronger than unrelated middleware. ## Decision -Each live agent owns a registration context named `agent.ctx`, and services expose narrow owner-final policy boundaries where ordinary middleware ordering is not strong enough. Together these choices make one agent's registration view composable with normal plugin APIs while keeping visibility, observation, and cleanup aligned. +Every live agent owns one flat registration layer through `agent.ctx`. Four matching rules make that layer coherent: registration and dispatch select the agent view, lifecycle publishes and revokes the view transactionally, acceptance transfers caller data into owner-controlled records, and four narrow owner-final checkpoints preserve invariants after extensible middleware. -The design has five parts: - -| Part | Rule | Purpose | +| Governing question | Decision | Guarantee | |---|---|---| -| Registration scope | A registration through a plain plugin context is global; the same registration through `agent.ctx` belongs to that agent | Reuse existing APIs for per-agent tools, prompt state, and listeners | -| Lifecycle transaction | Caller and factory ownership cover create and resume from reservation or load through scoped setup, ordered publication, and teardown | No observer sees a partially composed agent, and caller or provider loss cannot orphan work | -| Lifecycle foundation | Effects become owner-visible before setup, child fibers become parent-owned before publication, and unloading fibers reject late effects | Reentrant HMR cannot strand a half-built or cleanup-time registration outside the unload snapshot | -| Owner-final policy | Prompt protection, tool guards, final tool-result observation, and terminal turn stopping run at service-owned boundaries | Invariants do not depend on listener registration order | -| Boundary ownership | Services capture fixed fields once, materialize lossless-JSON data once, and publish owner-controlled views | Validation, execution, persistence, and telemetry cannot observe different values from one call | +| What participates? | Resolve deployment globals plus exactly one agent layer; route scoped events by the operation's real agent | Data and behavior use the same flat agent view | +| When does it exist? | Treat scope, session, registry entry, and driver as one caller- and agent-factory-owned transaction | Setup is unpublished; teardown drains before revocation | +| Which value is authoritative? | Read caller-owned fields once, validate that capture, and retain only owner-controlled identities or snapshots | Checked, executed, logged, and observed values cannot diverge | +| What may extensions override? | Keep waterfalls for cooperation, then place prompt protection, monotonic guards, final result observation, and terminal turn stopping at service-owned boundaries | Extension ordering cannot undo protocol invariants | -### One public call shows the composition model +The scope is deliberately flat. An agent resolves deployment-global registrations plus its own registrations; it never traverses parent or sibling scopes. Parent ownership links lifetimes without importing the parent's registration layer. -The common case uses ordinary registration APIs through the setup context. Code blocks in this RFC are focused examples and start from a fresh initialized application unless one explicitly continues another. Here `ctx` is a plugin's service context, `setup(agentCtx)` receives the unpublished agent's scoped context, and helpers such as `AgentId`, `SessionId`, and `CallId` construct opaque IDs. Assume the deployment already registered global `read` and `bash` tools; this creates a reviewer whose persona, global-tool filter, and extra reporting tool exist only for that agent and disappear with its handle: +This is a composition boundary, not an authority boundary. Agent scopes compose trusted in-process registrations; they do not sandbox plugins or define a parent-to-child authority lattice. A plugin holding a Cordis context runs in the same process and can call the services injected into that context. Scope selection answers which registered contribution participates and who cleans it up, not whether a child can do no more than its parent. + +The detailed consequences for tool filters, future global registrations, and child-local tools appear under [the tool-view contract](#the-tool-view-is-live-and-executable). Security hardening requires a separate authority representation and enforcement boundary. + +### Worked example: one agent-local reviewer + +Agent setup uses ordinary registration methods through `agent.ctx`; the context determines visibility and cleanup together. In these focused examples, `ctx` is a plugin service context, `setup(agentCtx)` receives the unpublished agent's scoped context, and helpers such as `AgentId`, `SessionId`, and `CallId` construct opaque IDs. + +Assume the deployment already registered global `read` and `bash` tools. This creates a reviewer whose persona, filtered global tools, and reporting tool exist only for that agent and disappear with its handle: ```js const reviewSummaryTool = { @@ -92,21 +69,214 @@ const reviewer = handle.agent ctx.tools.get('read', reviewer) // global tool, visible ctx.tools.get('bash', reviewer) // undefined: filtered global tool ctx.tools.get('review_summary') // undefined: not global -ctx.tools.get('review_summary', reviewer) // child-only definition +ctx.tools.get('review_summary', reviewer) // reviewer-only definition await handle.dispose() ctx.tools.get('review_summary', reviewer) // undefined: scope was unwound ``` -The rest of this RFC explains why the short setup above needs scope-aware resolution, unpublished construction, ordered teardown, and owner-final policy. +The remaining sections descend from this contract. -### Security and authority are explicit non-goals +## Reader model: domain terms and Cordis mechanics -Scoped contexts are trusted in-process registration composition, not a sandbox, authorization ledger, or parent-to-child authority lattice. A plugin with a Cordis context executes in the same process and can call the services injected into that context. Scope filtering decides which registered contribution participates in one operation and who cleans it up; it does not prove that a child can do no more than its parent. +Readers need four domain terms and four Cordis mechanics to follow the implementation. Readers already familiar with this codebase and Cordis can skim this section. -#### Flat scopes do not enforce a parent-to-child subset +### Recurring domain terms -Flat lookup makes that boundary visible. Parent lifetime ownership does not cause the child to inherit the parent's restriction, and a child-local registration is merged after the global filter: +Four domain terms keep the rest of the RFC compact. A **Session** is an agent run's append-only event log, from which model history and durable replay are derived. **Lossless JSON** is the JSON subset that can be copied without changing meaning: primitives, dense arrays, and plain objects; cycles, sparse arrays, exotic prototypes, non-finite numbers, negative zero, `undefined`, `bigint`, functions, and symbols are rejected. An **end capability** is an actual callable tool implementation, whether the model sees it as a native schema or a Code Mode binding. **Code Mode** gives the model a generated SDK and a reserved `run_code` transport instead of advertising every end capability as a native wire tool. + +### Four Cordis mechanics + +Contexts select service access and registration origin, fibers own effects, waterfalls provide cooperative transformation, and dispatch receivers select listeners. + +| Cordis concept | Meaning in this RFC | +|---|---| +| Context | The object through which a plugin reaches services and registers contributions; a derived context can carry a different registration scope | +| Fiber and effect | The runtime owner and one owned piece of setup/cleanup; disposing the fiber unwinds its effects | +| Waterfall | Ordered around-middleware whose listener calls `next()` to include downstream work and may transform or short-circuit the result | +| Dispatch receiver | The `this` object used by Cordis listener filtering; a scope carrier encodes the operation's agent key | + +#### Context selects both service access and registration origin + +A Cordis `Context` is the object through which code calls services such as `ctx.tools`, `ctx.systemPrompt`, and `ctx.sessions`. A service can recover the context through which it was accessed, so the same method can register globally from a plain plugin context or locally from `agent.ctx` without adding an `agent` option to every registration API. Cordis implements contextual service access with a **traced receiver**: a proxy that carries the accessing context while forwarding calls to the concrete service object. + +```js +ctx.tools.register(globalTool) +agent.ctx.tools.register(agentOnlyTool) + +ctx.on('tools/result', globalObserver) +agent.ctx.on('tools/result', agentObserver) +``` + +A context also exposes the dependency view injected into the plugin that minted it. `agent.ctx` therefore carries the agent loop's deliberate service surface; it is not an ambient root context or a security boundary. + +#### Effects make cleanup follow ownership + +An effect is setup whose cleanup belongs to a fiber. Tool registration, prompt contribution, event subscription, and an agent scope are effects, so normal disposal, failure, and hot module reload all follow the same ownership graph. + +```js +ctx.effect(() => { + const resource = openResource() + return async () => { + await resource.close() + } +}) +``` + +Cordis also supports generator effects that nest child effects in a chosen teardown order. The lifecycle section explains why construction must become owner-visible before arbitrary callbacks run. + +#### Waterfalls remain cooperative extension points + +A waterfall listener wraps downstream work. Calling `next()` includes the remaining listeners and base implementation; returning directly skips that downstream portion. + +```js +ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { + const downstream = await next() + return { + ...downstream, + sections: [...downstream.sections, extraSection], + } +}) + +ctx.on('system-prompt/assemble', async () => replacementAssembly) +// The direct return skips this listener's downstream/base. An outer listener +// that already awaited next() still resumes around replacementAssembly. +``` + +This flexibility is intentional for ordinary policy, but it cannot express a fact that must remain true after every wrapper and short-circuit. [Owner-final policy](#owner-final-policy-four-narrow-boundaries) adds only the four final checkpoints that need stronger semantics. + +#### Dispatch receivers select scoped listeners + +Cordis filters listeners using the dispatch receiver, the object visible as `this` inside a function-style listener. `dsh-scope` builds a receiver carrying the operation's scope key, allowing global listeners plus listeners registered for that exact key while rejecting other agents' listeners. + +The receiver is live coordination state, not durable session data. For example, `tools/result` is a live final-outcome notification, while `tool/result` is an append-only session event used for replay and model history. + +## Registration and delivery: global plus exactly one agent layer + +One scope key controls both registered data and registered behavior. Reads combine the deployment-global layer with exactly one agent layer, while scoped event dispatch admits global listeners plus the listeners for that same agent. + +Scope keys are opaque objects compared by identity; a live `Agent` is its own registration key. There is no name-based equality or parent traversal. + +### Scope mechanism: context, key, and lifetime + +The registration context selects the layer, the scope primitive binds that layer to cleanup, and the nearest scope tag—not an inherited convenience property—selects the key. + +#### The calling context selects visibility and cleanup + +A contribution made through a plain plugin context is visible to every agent and disposed with that plugin. A contribution made through `agent.ctx` is visible only to that agent and disposed with its scope. + +| Registration origin | Visible to | Disposed with | +|---|---|---| +| Plain plugin context | Every agent | Registering plugin | +| `agent.ctx` | That agent only | Agent scope | + +The table describes ordinary registrations. Cordis listeners alone have an explicit `{ global: true }` bypass: it suppresses contextual filtering, so a listener registered through `agent.ctx` can receive other agents' and subjectless dispatches while its cleanup still belongs to that agent scope. Cross-scope observation must opt into this bypass deliberately. + +Named scoped contributions shadow same-named global contributions. This is how a child persona replaces `deployment:persona` and how one agent can use a different implementation under the same tool name. Duplicate names within one layer still fail loudly. + +```text +resolveLayer(agentA): + visible = copy(global registrations) + visible.overlay(registrations from agentA.ctx) + return visible +``` + +There is no ancestor loop. Resolving for agent A never reads parent or sibling layers. + +#### The scope primitive keeps layer and owner together + +`dsh-scope` exposes only the operations needed to mint a tagged ownership layer, read its key, target dispatch, and reach quiescent cleanup. A separate `{ scope }` option on each registry could express “visible to A, disposed with B”; the scoped context makes that mismatch unrepresentable. + +| Operation | Responsibility | +|---|---| +| `createScope(context, key)` | Mount an ownership fiber and return its tagged context | +| `scopeOf(context)` | Read the nearest inherited scope key | +| `scopeTarget(subject, key)` | Build the receiver for scope-filtered dispatch | +| `Scope.dispose()` | Return one shared idempotent promise that reaches cleanup quiescence | +| `Scope.rawDispose` | Expose the exact Cordis disposer for ordered generator composition | + +`Scope.dispose()` and `rawDispose` serve different callers. Cordis raw disposers are single-shot, so a repeated raw call need not wait for an earlier asynchronous teardown; the public method follows the backing fiber's in-flight cleanup and gives racing callers the same completion promise. Generator lifecycles use `rawDispose` because Cordis recognizes nested ownership by exact disposer identity. + +The primitive has one essential shape: + +```text +createScope(parentContext, key): + fiber = mount no-op plugin under parentContext + scopedContext = derive fiber.context with nearest-scope-tag = key + + rawDispose = fiber's exact disposer + dispose = memoized operation that: + invoke rawDispose if teardown has not started + follow fiber's in-flight cleanup until quiescent + + return { ctx: scopedContext, rawDispose, dispose } +``` + +Derived contexts inherit the nearest tag. Mounting a plugin under `agent.ctx` preserves the agent scope; deliberately creating another scope replaces the tag below it. + +#### `ctx.agent` is an association; `scopeOf()` selects the layer + +`agent.ctx.agent` gives setup code convenient access to the associated agent, but the nearest scope tag remains authoritative for resolution. A nested scope can inherit the ergonomic `agent` property while replacing the registration key. + +```js +const auditKey = {} +const auditScope = createScope(agent.ctx, auditKey) + +auditScope.ctx.agent === agent // true: inherited association +scopeOf(auditScope.ctx) === auditKey // true: nearest registration key + +await auditScope.dispose() +``` + +This separation keeps the generic scope package independent of the agent package. + +### Resolution contracts preserve domain semantics + +The shared scope selects two layers, but each registry retains its own merge rules and must keep presentation, lookup, and execution coherent within the view it owns. + +#### Registries retain domain-specific merge rules + +The shared primitive answers “which layer?” and “who owns cleanup?”; each service still defines how its values combine. Prompt sections, variables, and tools use scoped-over-global shadowing by name. Tool-schema providers are additive. Tool lookup and execution receive an agent or scope explicitly, while prompt assembly receives an `AssembleContext` whose `scope` selects the layer. + +Calling a read method through `agent.ctx` does not silently choose an agent subject. For example, `agent.ctx.systemPrompt.assemble()` without an assembly scope still requests the global view. Registration origin and operation subject remain explicit, allowing one shared service to act for any agent. + +#### The tool view is live and executable + +Within `ToolRegistry`'s contribution, presentation, lookup, execution, Code Mode bindings, timeouts, inspection, and UI rendering all consume one resolved view. The registry filters the live global layer, overlays scope-local tools, and then adds reserved presentation transport when the configured mode requires it. + +```js +ctx.tools.register(readTool) +ctx.tools.register(bashTool) + +agent.ctx.tools.restrict({ allow: ['read'] }) +agent.ctx.tools.register(reviewSummaryTool) + +ctx.tools.get('read', agent) // visible global definition +ctx.tools.get('bash', agent) // undefined: filtered global definition +ctx.tools.get('review_summary', agent) // visible scope-local definition +ctx.tools.get('review_summary') // undefined: absent globally +``` + +Executing `bash` for this agent follows the same lookup and returns the ordinary unknown-tool error. A hidden global implementation therefore cannot remain callable through a second registry. + +Final prompt assembly remains extensible beyond `ToolRegistry`. A lower-level `systemPrompt.tools()` provider or assembly listener may add an unrelated wire schema; that extension then owns the matching executable behavior and ordering. The one-view guarantee covers the registry-owned schemas, SDK bindings, lookup, execution, and presentation—not arbitrary schemas contributed elsewhere. + +A restriction filters only the global end-capability layer. `allow` keeps named global tools, `deny` removes named global tools, multiple restrictions intersect, and scope-local tools are merged afterward. The filter values are captured when registered, but resolution uses the live global registry: + +Filter presence is explicit: omitting a filter installs no restriction, `restrict({})` rejects as ambiguous, and `allow: []` deliberately hides every global end capability. + +```text +at time 0: + global tools = { read, bash } + deny { bash } view = { read } + allow { read } view = { read } + +after registering global tool web: + deny { bash } view = { read, web } + allow { read } view = { read } +``` + +The flat child relationship follows directly: ```text global tools = { read, bash } @@ -119,291 +289,30 @@ visible(parent) = { read, delegate } visible(child) = { read, bash, deploy } ``` -Through `delegate`, a parent can deliberately start this child and indirectly obtain work performed with `bash` or `deploy`. This RFC neither prevents nor blesses that arrangement; deployments that need a non-escalation guarantee require a separate authority design and enforcement boundary. +Through `delegate`, the parent can ask the child to perform work with `bash` or `deploy`. This is why registration scope is not an authority ceiling. A deployment that needs parent-to-child non-escalation requires a separate authorization model, including authority representation, propagation, and execution checks. -#### Restrictions are live views, not grant snapshots +`run_code` is a reserved presentation transport rather than an end capability. Restrictions cannot remove it, scope-local tools cannot shadow it, and configuration cannot explicitly allow or deny it. In Code Mode the transport remains available while its generated SDK contains only the end capabilities visible to the agent. Without that exception, a filter could leave SDK declarations in the prompt but remove the only invocation path. -Restrictions also resolve against a live global registry rather than an immutable authorization snapshot. A deny-list names removals, while an allow-list names the complete retained global set: +Two similarly named checks use different universes. `ToolRegistry.knownNames()` exposes the pre-restriction end-capability set so a misspelled restriction fails loudly. The system-prompt provider validates `toolOrder` against a mode-specific set: native mode accepts end capabilities, both mode accepts end capabilities plus `run_code`, and code mode accepts only `run_code`. Filtering one agent's view does not turn a valid deployment-wide order into a configuration error. -```text -at time 0: - global tools = { read, bash } - deny { bash } view = { read } - allow { read } view = { read } +### Dispatch contract follows the operation subject -after registering global tool web: - deny { bash } view = { read, web } - allow { read } view = { read } -``` +The operation supplies the scope key, and a carrier composes that key with the subject's existing dispatch behavior. Callers cannot provide an independent routing value that might disagree with the payload. -Scope-local tools are merged after either filter. There is no separate authority-versus-visibility ledger, frozen creation-time grant snapshot, parent-subset rule, future-tool grant API, or generic capability/output/terminal tag system here. `run_code` and `structured_output` have explicit protocol-owned treatment described below; they do not imply a general security taxonomy. Those questions are separate design work rather than hidden promises of scoped contexts. +#### The operation subject selects the listener set -Three domain terms recur below. A **Session** is one agent run's append-only event log, from which model history and durable replay are derived. **Lossless JSON** means JSON primitives plus dense arrays and plain objects that can be copied without changing meaning; the boundary rejects sparse arrays, cycles, exotic prototypes, non-finite numbers, negative zero, `undefined`, `bigint`, functions, and symbols instead of coercing or erasing them. **Code Mode** presents the model with a generated software-development-kit interface and a reserved `run_code` transport, rather than advertising every end-capability as a native tool. - -Ownership stays with the component that can enforce each fact. The scope package owns scope tags and carrier construction; each registry owns acceptance snapshots and resolution; the caller owns the programmatic agent lifetime it requested; the concrete agent factory owns identity reservation, setup, publication, and structural invalidation of agents that still depend on it; the session owns accepted history; the tool and subagent services own their pipeline records; and each workflow run captures its holder-bound dependencies and owns its cancellation after the engine returns it. A caller never validates a value that another component later rereads from the caller's mutable object. - -The scope is flat. An agent resolves the deployment-global layer plus its own layer; a child does not inherit registrations from its parent's scope. Parent/child lineage remains explicit session data, and parent-owned disposal links lifetimes without inheriting registrations. - -The core implementation lives in [`dsh-scope`](../../../../packages/core/scope/README.md), [`dsh-agent`](../../../../packages/core/agent/README.md), [`dsh-agent-loop`](../../../../packages/core/agent-loop/README.md), [`dsh-session`](../../../../packages/core/session/README.md), [`dsh-system-prompt`](../../../../packages/core/system-prompt/README.md), and [`dsh-tools`](../../../../packages/core/tools/README.md). The composition example spans [`dsh-subagent`](../../../../packages/subagent/subagent/README.md), [`dsh-subagent-inprocess`](../../../../packages/subagent/subagent-inprocess/README.md), and [`dsh-workflow-workerthread`](../../../../packages/workflow/workflow-workerthread/README.md). The [generated Cordis event catalog](../../../cordis-catalog/events.md) is the exhaustive event-signature reference; this RFC explains why the contracts have their current shape. - -## Background: the small Cordis vocabulary used here - -The design relies on four framework ideas: contexts, effects, waterfall events, and dispatch receivers. This section gives the complete mental model needed for the rest of the RFC; the [Cordis primer](../../../cordis-primer.md) covers the framework more broadly. - -### A context is both a service view and a registration origin - -A Cordis `Context` is the object through which a plugin reaches services such as `ctx.tools`, `ctx.systemPrompt`, and `ctx.sessions`. A service method can recover the context through which it was accessed, so the service can tell whether a call came from an ordinary plugin context or from an agent's scoped context without adding a `scope` parameter to every registration API. - -A context also carries an injected dependency view. A derived context reaches the services injected into the plugin that created it. Handing out `agent.ctx` therefore hands out the agent loop's injected service surface; it is not an ambient root context or a security confinement boundary. - -Factory delegation uses two contexts whose jobs must remain separate. The registry derives a caller-bound context carrying the fiber and scope from which `ctx.agents.create()` or `resume()` was called and passes it explicitly as `ownerCtx`; those facts identify the fiber and optional parent agent that own the requested lifetime. When the registered factory is itself a Cordis service, the registry also invokes it through a traced receiver, which preserves the factory's own injected dependency origin. A plain object that merely implements the factory methods receives the same explicit `ownerCtx` without depending on Cordis tracing. Conflating these roles would either attach the agent to the factory registrant instead of the caller or make the concrete loop resolve dependencies from the wrong service view. - -The object before a service or event method selects the registration origin. The method itself does not need an extra agent parameter: - -```js -ctx.tools.register(globalTool) -agent.ctx.tools.register(agentOnlyTool) - -ctx.on('tools/result', globalObserver) -agent.ctx.on('tools/result', agentObserver) -``` - -Factory calls preserve caller ownership and factory dependency lookup as separate values: - -```text -callerCtx.agents.create(options) - ownerCtx = context carrying callerCtx's fiber and scope - factoryThis = concrete factory traced through ownerCtx - Reflect.apply(capturedCreateAgent, factoryThis, [ownerCtx, options]) -``` - -### Effects give registrations an owner - -A Cordis effect is work whose cleanup belongs to a runtime unit called a fiber. Tool registration, prompt contribution, and event subscription are effects, so disposing their fiber unwinds them on normal teardown, failure, or hot reload. - -Ownership must exist before effect setup can call arbitrary code. The vendored Fiber implementation therefore places an effect's cleanup wrapper in the owner list before running its setup body; a reentrant unload sees that in-construction effect and waits for setup plus every cleanup it collected. A child fiber likewise receives its parent-owned disposer before `internal/plugin` announces the child. Teardown delivers that notification with per-observer failure containment so one callback cannot starve peers or interrupt cleanup. Effects remain legal while a fiber is pending or loading, because setup needs them, but a fiber already unloading rejects new effects: its cleanup snapshot has been taken, so accepting another registration would strand it in the old epoch. - -`dsh-scope` mounts a no-op plugin fiber for each scope. The plugin contributes no behavior; its fiber is the ownership bucket for everything registered through the scoped context. - -In its simplest form, an effect is a setup function that returns its cleanup. Cordis also supports generator effects that compose child effects in a chosen order. In either form Cordis records the wrapper before calling setup, so even setup-triggered reentrant teardown can find and await it: - -```js -ctx.effect(() => { - const resource = openResource() - return async () => { - await resource.close() - } -}) -``` - -### A waterfall is ordered around-middleware - -A Cordis waterfall is an extensible middleware chain. A listener calls `next()` to delegate, can inspect or replace the downstream result, and can return without calling `next()` to short-circuit everything inside it. - -This flexibility is useful for cooperative transformations, but registration order is not an invariant boundary. A later plugin can prepend another listener, a wrapper can replace the downstream result after `next()` returns, and a short-circuit can prevent inner listeners from running at all. - -The code shape is ordinary around-middleware. Calling `next()` includes downstream work; returning directly skips that listener's downstream listeners and base implementation: - -```js -ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { - const downstream = await next() - return { - ...downstream, - sections: [...downstream.sections, extraSection], - } -}) - -ctx.on('system-prompt/assemble', async () => replacementAssembly) -// This listener skips its downstream/base. An outer listener that already -// awaited next() still resumes around replacementAssembly. -``` - -### The dispatch receiver selects scoped listeners - -Cordis filters event listeners using the dispatch receiver, the object exposed as `this` inside a function-style listener. `dsh-scope` supplies a receiver carrying the operation's scope key, so the event system can admit global listeners plus listeners registered for that key and reject listeners belonging to other agents. - -This receiver is live coordination state, not a durable session fact. The distinction matters later: `tools/result` is a live final-outcome notification, while the similarly named `tool/result` is an append-only session event stored for replay and model history. - -## Agent-scoped registrations - -An agent scope couples two facts that must not drift apart: who can see a registration and who disposes it. The calling context determines both facts, leaving the domain-specific merge rules to each registry. - -### The resolution model is global plus exactly one scope - -Every scope-aware registry keeps a global layer and per-scope layers. Resolving for agent A combines the global layer with A's layer only; it does not walk A's parent lineage or combine sibling scopes. - -| Registration origin | Visible to | Disposed with | -|---|---|---| -| Plain plugin context | Every agent | The registering plugin | -| `agent.ctx` | That agent only | That agent's scope | - -Named scoped contributions shadow a same-named global contribution. A child persona is therefore a scoped `deployment:persona` section, and a per-agent tool implementation can keep the same model-facing name. Duplicate names within one layer still fail loudly. The deliberate exception is a globally protected prompt-section name, whose owner reserves it against scoped shadowing. - -The plugin-facing mechanism is the same API called through a different context. In language-neutral pseudocode: - -```text -# Deployment-wide contribution -appContext.tools.register(readTool) - -# Contribution visible only to agent A and disposed with A -agentA.ctx.tools.register(childOnlyTool) - -resolveTools(agent A): - visible = copy(globalTools allowed by A's restrictions) - visible.overlay(tools registered through A.ctx) - visible.append(reserved presentation transport, when configured) - return visible -``` - -There is no `for each ancestor` step. Resolving for A never reads the parent or sibling layers. - -The scope key is an opaque object compared by identity. The harness uses the live `Agent` object as its own key, so event payloads, tool executions, and prompt assemblies that already carry the agent can select the correct layer without translating through a string ID that may later be reused. - -### `agent.ctx.agent` is an association, not the scope resolver - -`agent.ctx` carries an own `agent` property for setup code and plugin ergonomics. Contexts derived from it inherit that association, while a plain context reads `undefined`. - -The property is deliberately not treated as the authoritative scope tag. A nested scope can install a nearer scope key while still inheriting the original `ctx.agent` association, so lower-level services resolve layers with `scopeOf(context)`. In normal agent composition the two point at the same live agent; the separation keeps the generic scope primitive independent of the agent package. - -The distinction appears when a plugin deliberately nests another scope: - -```js -const auditKey = {} -const auditScope = createScope(agent.ctx, auditKey) - -auditScope.ctx.agent === agent // true: inherited ergonomic association -scopeOf(auditScope.ctx) === auditKey // true: authoritative nearest scope tag - -await auditScope.dispose() -``` - -### The scope primitive has separate public and composite disposal forms - -`dsh-scope` exposes the minimum operations needed to create a layer, read it, target events, and dispose it. “Quiescent” here means that every asynchronous cleanup registered in the scope has settled and no teardown work remains in flight. - -| Operation | Responsibility | -|---|---| -| `createScope(context, key)` | Mount the ownership fiber and return its tagged derived context | -| `scopeOf(context)` | Read the nearest inherited scope key | -| `scopeTarget(subject, key)` | Build the receiver used for scope-filtered dispatch | -| `Scope.dispose()` | Give ordinary callers an idempotent promise shared by repeat and racing calls until quiescence | -| `Scope.rawDispose` | Expose the exact Cordis disposer so a larger generator lifecycle can nest it at a precise teardown position | - -The two disposal forms solve different framework constraints. Cordis identifies nested effects by disposer-function identity, so an ordered composite lifecycle must yield `rawDispose` exactly. Cordis disposers are also single-shot, so a second raw call may not await the first asynchronous teardown; `Scope.dispose()` follows the backing fiber's in-flight lifecycle and gives all ordinary callers the same quiescence boundary, including a race in which `rawDispose` started first. The test/tooling `ScopeHost.dispose()` extends that shared boundary across its host fiber and every minted child scope. Pre-registration of an effect wrapper solves a different race: it makes the first owner unload see construction in progress without changing this single-shot raw-disposer contract. - -The primitive itself is small. Its essential implementation shape is: - -```text -createScope(parentContext, key): - fiber = mount no-op plugin under parentContext - scopedContext = derive fiber.context with nearest-scope-tag = key - - rawDispose = fiber's exact disposer - dispose = memoized operation that: - invoke rawDispose if it has not started - follow fiber's in-flight teardown until quiescent - - return { ctx: scopedContext, rawDispose, dispose } -``` - -Derived contexts inherit the nearest scope tag. Mounting an ordinary plugin under `agent.ctx` therefore preserves the agent's scope, while deliberately creating another scope replaces the tag for registrations below it. - -### Registry resolution stays domain-specific - -The shared primitive answers “which layer?” and “who owns cleanup?” but does not force every service to merge data the same way. Tools, prompt sections, variables, and tool-schema providers retain rules appropriate to their domains. - -Prompt sections, prompt variables, and tools use scoped-over-global shadowing by name. Tool-schema providers are additive, but a provider registered through `agent.ctx` participates only in that agent's assemblies. Read operations name the subject explicitly: tool lookup and execution receive an agent or scope, and prompt assembly receives an `AssembleContext` whose `scope` selects the layer. - -Calling a service through `agent.ctx` does not implicitly make every later read agent-scoped. For example, `agent.ctx.systemPrompt.assemble()` without an assembly scope still requests the global layer. This keeps shared services able to operate on behalf of any subject and makes the subject visible at the read or execution call site. - -### Tool registrations are frozen snapshots - -The tool view must not change because a caller kept the object it passed to `register()` or received a definition from `get()` or `visible()`. Registration therefore creates the stored identity once; future changes happen through explicit unregister/register effects. - -Tool parameters cross the model and log boundary, so the registry materializes them with `snapshotJsonValue`: one recursive traversal reads each property once, rejects anything outside lossless JSON, and constructs the detached value that is actually stored. A check followed by `structuredClone` is not equivalent—a getter could return plain JSON to the check and a class instance to the clone, which would erase its prototype and silently accept different data. - -The first-party `defineTool()` helper closes the authoring boundary with the same primitive. It reads every top-level option once, materializes the `SchemaSpec`, and derives an independent wire schema plus all later execute and presentation validation from that accepted snapshot. Without that split, mutating an author-owned spec after definition could make the model call a schema that the tool no longer accepts. - -Registration then reads every top-level definition field exactly once, validates, binds, and stores only those captured values; a stateful `parameters` or callback accessor therefore cannot make the checked definition differ from the executable one. It snapshots the scalar fields, binds each callback once to the original definition as its method receiver, and deep-freezes the stored record. Replacing `definition.execute` after registration therefore has no effect, while a callback can still deliberately read mutable state from its closure or original receiver. `get()` and `visible()` return the frozen stored definitions; `schemas()` returns detached schema projections. - -```text -defineTool(options): - accepted = read each top-level option exactly once - parameterSpec = snapshotLosslessJson(accepted.parameters) - wireParameters = snapshotLosslessJson(convertToJsonSchema(parameterSpec)) - build execute and presentation validators over parameterSpec - -registerTool(context, definition): - accepted = read each top-level definition field exactly once - parameters = snapshotLosslessJson(accepted.parameters) - - stored = deepFreeze({ - accepted name, description, timeout, - parameters, - execute: bind accepted.execute to definition, - presentation callbacks: bind once when present - }) - - layerFor(scopeOf(context)).add(stored.name, stored) -``` - -The reserved Code Mode transport uses the same frozen-definition contract even though it lives outside the ordinary layers. - -### Tool restrictions filter the global view without removing transport - -A tool restriction masks the global end-capability layer for one agent, while tools registered in that agent's own layer are merged afterward. Multiple restrictions intersect, so separately installed filters can only reduce the global part of the view; they do not filter scope-local registrations. - -The restriction reads `allow` and `deny` once, snapshots those exact values, rejects an empty filter, and validates named tools against the pre-restriction capability universe. The same captured arrays are then enforced, so a stateful accessor cannot pass one policy through validation and install another. A restricted-away tool behaves like an unknown tool at execution, avoiding disclosure of a hidden global implementation. - -[Code Mode](../feature/2026-06-15-code-mode.md)'s `run_code` is not an end capability. It is a reserved presentation transport that carries calls to the visible end capabilities, so the registry keeps it outside both global and scoped registration layers: restrictions cannot remove it, a scoped tool cannot shadow it, and configuration cannot explicitly allow or deny it. Without this exception, a restriction could leave the generated SDK in the prompt but remove the only way to invoke it. - -The registry still uses one executable visibility view. It first resolves filtered global capabilities plus scope-local registrations, then appends the reserved transport in non-native modes; registry-owned prompt schemas, lookup, execution, Code Mode SDK bindings, timeout lookup, inspection, and UI presentation all consume that view. - -The public lookup API exposes the exact same resolution used for prompt schemas and execution: - -```js -ctx.tools.register(readTool) -ctx.tools.register(bashTool) - -agent.ctx.tools.restrict({ allow: ['read'] }) -agent.ctx.tools.register(reviewSummaryTool) - -ctx.tools.get('read', agent) // visible global definition -ctx.tools.get('bash', agent) // undefined: filtered global definition -ctx.tools.get('review_summary', agent) // visible scope-local definition -ctx.tools.get('review_summary') // undefined: absent from global view -``` - -Executing `bash` for this agent follows the same lookup and produces the ordinary unknown-tool error; it does not bypass the filter through a separate execution registry. The [security non-goal](#security-and-authority-are-explicit-non-goals) explains why later global registrations and scope-local registrations are not an authorization snapshot. - -The guarantee covers the tool registry's contribution. A plugin can deliberately use the lower-level `systemPrompt.tools()` API or assembly waterfall to add an unrelated wire schema; that plugin owns the matching executable behavior and any ordering it introduces. Owner protection preserves reserved named infrastructure without turning the system-prompt service into a validator for unrelated contributions. - -`knownNames` serves a narrower configuration purpose: it is the pre-restriction end-capability universe used to distinguish a typo from a deliberately hidden tool. The system-prompt provider adds presentation names when validating `toolOrder`: `code` mode accepts only `run_code`, `both` accepts end capabilities plus `run_code`, and a per-agent restriction may remove a known capability from one assembly without turning the deployment's order configuration into an error. - -## Scoped event delivery - -Scoped registration is incomplete unless behavior follows the same boundary. An event about agent A reaches global listeners and A-scoped listeners, never listeners installed for B. - -### Delivery is global plus the matching scope - -The dispatch receiver carries the operation's scope key. Its filter admits an unscoped listener or a listener registered through the matching scoped context, while a subject-less dispatch admits unscoped listeners only. Cordis's explicit `{ global: true }` listener option remains the intentional bypass for infrastructure that must observe every dispatch. - -Registry-membership notifications remain unfiltered. Events such as `tools/change`, `system-prompt/change`, `skill/provider-*`, and `subagent/provider-*` describe shared registry state rather than one agent's activity, so a scoped subscriber still observes those global changes. - -### Each event family derives its key from its real subject - -The operation being described determines the key; callers cannot attach an unrelated scope. Fused helpers and store-owned carriers keep the payload subject and delivery subject together. +An event about agent A ordinarily reaches unscoped listeners and A-scoped listeners, never B-scoped listeners. An agent-less dispatch admits only unscoped listeners. A listener registered with `{ global: true }` is the deliberate Cordis filtering bypass described above. The operation itself supplies the key; callers do not attach an independent scope that could disagree with the payload. | Event family | Scope source | |---|---| -| `agent/*`, including `agent/turn-stop` | The event's agent | +| `agent/*`, including `agent/turn-stop` | Event's agent | | `approval/request` | `ApprovalRequest.agent` | -| `tools/pre-execute`, `tools/execute`, `tools/post-execute`, `tools/result` | `ToolExecution.agent`, or no key for an agent-less call | +| Tool execution events | `ToolExecution.agent`, or no key for an agent-less call | | `system-prompt/assemble` | `AssembleContext.scope` | -| `session/created`, `session/disposed`, `session/event`, `session/flush` | The owner scope captured when the session enters the store | -| `subagent/start`, `subagent/end` | The delegating parent agent | +| Session lifecycle/events | Owner scope captured when the session enters the store | +| `subagent/start`, `subagent/end` | Delegating parent agent | -The observable rule is global plus matching, not global plus every scoped listener. This example drives a real tool execution so routing and notification use the same accepted `agent` subject: +Registry-membership events such as `tools/change`, `system-prompt/change`, and `SubagentProvider` added/removed events remain unfiltered because they describe shared registry state rather than one agent operation. ```js const seen = [] @@ -421,32 +330,15 @@ await ctx.tools.execute({ seen // ['global', 'A'] ``` -Approval requests cross an asynchronous answer boundary, so the service snapshots the accepted record synchronously. It preserves the exact agent and abort-signal identities but copies the scalar fields, captures the agent's session once, and uses that one snapshot for `approval/asked`, scoped dispatch, cancellation, policy, and `approval/decided`. Mutating the caller-owned record after `request()` returns therefore cannot split the audit pair or redirect the question to another agent's listeners. +Fused helpers keep values that must agree together. `agentEvents(context, agent)` uses one agent as the subject, scope key, and first event argument. `assembleContextFor(agent)` sets both prompt facts and the scope selector. The session store captures its carrier when a session enters because later appends and flushes may occur without the original agent context. -The dispatch rule can be read independently of Cordis internals: +#### The carrier preserves subject behavior -```text -dispatchScoped(subject, scopeKey, event, arguments): - carrier = proxy(subject, tag = scopeKey) +Function-style listeners receive the carrier as `this`, and agent listeners may call subject methods. The carrier is therefore a proxy that selects listeners while reading, writing, and invoking through the real subject. - for listener in listeners(event): - if listener has no scope tag or requests the explicit global bypass: - call listener with this = carrier - else if listener.scopeTag == scopeKey: - call listener with this = carrier - else: - skip listener -``` +The implementation uses a dedicated surrogate proxy target with an immutable composed-filter slot. It combines the subject context's existing `Context.filter` with the scope predicate instead of replacing it. Methods bind to the real subject; callable carriers preserve call and construct shape; descriptor queries normalize configurable flags as required by Proxy invariants; and definitions through the carrier require an explicitly configurable descriptor. Stable built-in references protect the composed filter from accidental `.call` replacement. -The real helpers fuse values that must agree. `agentEvents(context, agent)` uses the same agent as the subject, scope key, and first event argument. `assembleContextFor(agent)` similarly sets both the agent-facing field and the scope selector. The session store captures its carrier when a session enters because later appends and flushes may occur where the original agent context is no longer available. - -### The carrier behaves like the subject but has distinct identity - -Function-style listeners receive the carrier as `this`, and agent event APIs allow them to call subject methods. The carrier is therefore a JavaScript proxy that reads and writes through to the real subject and binds methods to it. - -Binding matters for classes with JavaScript private fields: a method called with the proxy itself as receiver would fail the runtime private-field identity check. The carrier therefore uses a dedicated surrogate proxy target with its own immutable composed-filter slot, while ordinary property access, writes, own-key visibility, methods, invocation, and construction delegate to the real subject; callable carriers also preserve whether the subject is constructable. - -A minimal JavaScript example shows why method binding is observable rather than a TypeScript detail: +Those mechanics preserve observable JavaScript behavior, including private-field method identity: ```js class Subject { @@ -455,24 +347,32 @@ class Subject { } const subject = new Subject() -new Proxy(subject, {}).increment() // TypeError: proxy lacks Subject's private identity +new Proxy(subject, {}).increment() // TypeError: proxy lacks Subject's private identity const carrier = scopeTarget(subject, subject) -carrier.increment() // works: method is bound to subject -carrier === subject // false: dispatch carrier has distinct identity +carrier.increment() // works: method is bound to subject +carrier === subject // false: carrier has distinct identity ``` -The composed filter is a listener-selection correctness boundary, not an ordinary exposed callback. It invokes a subject's pre-existing filter with stable references to the built-in `Reflect.apply` and `Function.prototype.call` operations, pins its own `.call` to that captured built-in, and freezes the callable. Code holding the subject or carrier therefore cannot accidentally replace either `.call` property and turn the scoped predicate into an always-admit predicate. Keeping the filter on the surrogate also means a filter property pinned on the subject before, during, or after carrier construction cannot trigger a Proxy invariant that silently replaces scope filtering with the subject's raw filter. +Together these constraints keep listener selection correct while preserving the subject behavior listeners expect. -The surrogate must remain extensible so its reported own-key view can follow the subject. For non-overlay properties owned by the subject, descriptor queries preserve values and flags except that `configurable` is reported as `true`, which is the only Proxy-safe description of a property the extensible surrogate does not itself own. For the same reason, defining a property through the carrier is supported only when the descriptor explicitly says `configurable: true`; an omitted or false flag is rejected before the subject is touched. The carrier is intentionally not identity-equal to the subject; event arguments carry the real object whenever identity matters. +The TypeScript-only `Scoped` marker requires a carrier at typed dispatch sites. Runtime marks and development invariants cover JavaScript, casts, and direct Cordis dispatch; they detect routing mistakes but do not confine hostile same-process code. -`Scoped` is a TypeScript-only marker that requires this carrier at declared scoped dispatch sites. It improves authoring but adds no runtime enforcement by itself, so runtime marks and development invariants check the same contract for JavaScript, casts, and hand-written dispatches. These checks detect routing mistakes; they do not confine a hostile in-process plugin. +## Lifecycle: compose privately, publish once, tear down in reverse -## Agent creation and teardown +Scope, session, registry entry, and driver form one transaction with two owners. Request fields are captured first; AgentLoop tracking and both identity reservations precede asynchronous work; the caller owns the prepared lifecycle before setup; publication proceeds in synchronous observable phases; and every teardown path reaches one reverse-order quiescence boundary. -An agent's scope, session, registry entry, and driver form one transaction with two ownership edges. The caller context owns the work it requested and receives the only consumer-facing teardown capability; the concrete `AgentLoop` provider is a structural co-owner because the live agent continues to use the provider's injected services. Either edge deactivates the transaction and converges on the same ordered, memoized quiescence boundary. Setup finishes before publication, and publication is synchronous and rollback-covered rather than magically atomic. +Two services split the public API from the implementation. `AgentRegistry`, reached as `ctx.agents`, stores live agents and is the front door for `create()` and `resume()`. Its registered `AgentFactory` is concretely implemented by `AgentLoop`, which constructs and drives agents using its own injected dependencies. The rest of this section calls that concrete co-owner the **AgentLoop factory**. -The public contract is simple: setup may await while both identities remain absent from their registries; fulfillment publishes the complete agent; disposal removes it again. +| Phase | Public state | Ownership fact | +|---|---|---| +| Reserve | IDs unavailable to competitors | AgentLoop tracking and exact reservations cover the next await | +| Prepare or load | Persistence data is loading, or session, scope, and driver exist privately | Resume's load sentinel covers persistence; the complete caller lifecycle covers setup | +| Setup | `setup(agent.ctx)` may await and register | Neither ID is published | +| Publish and start | Session, agent, and lifecycle notifications appear in order | Liveness is checked between observable phases | +| Dispose | Driver drains, registries detach, scope unwinds, IDs release | All owner paths join one completion promise | + +The public lifecycle is simple: ```js const setupGate = Promise.withResolvers() @@ -492,270 +392,333 @@ const creating = ctx.agents.create({ }, }) -ctx.agents.get(agentId) // undefined while setup is pending -ctx.sessions.get(sessionId) // undefined while setup is pending +ctx.agents.get(agentId) // undefined during setup +ctx.sessions.get(sessionId) // undefined during setup setupGate.resolve() const handle = await creating -ctx.agents.get(agentId) === handle.agent // true after publication -ctx.sessions.get(sessionId) === handle.agent.session // true after publication +ctx.agents.get(agentId) === handle.agent +ctx.sessions.get(sessionId) === handle.agent.session await handle.dispose() ctx.agents.get(agentId) // undefined after quiescent teardown ctx.sessions.get(sessionId) // undefined after quiescent teardown ``` -### Create and resume reserve identities before asynchronous work +### Reservations precede awaiting; lifecycle ownership precedes setup -Programmatic create and resume reserve both the agent ID and session ID before work that can await. Create prepares a fresh or seeded session; resume first loads and reconstructs the persisted session. Both paths then construct the agent, mint `agent.ctx`, and install the complete teardown skeleton before awaiting setup. +AgentLoop tracking and exact identity reservations precede the first await. Resume adds a caller sentinel across persistence loading; create and resume both establish the complete caller-owned lifecycle before invoking setup. -The registry treats the factory seam as an untrusted runtime boundary. A TypeScript interface checks source code but does not constrain the JavaScript object received at runtime, which may expose stateful getters. `setFactory()` therefore claims the single factory slot before reading method accessors, canonicalizes an already traced Cordis service to its concrete target, then captures that target plus the `createAgent` and `resume` callback identities once. A getter cannot reenter `setFactory()` and replace the outer factory while it is being accepted, later method replacement cannot redirect calls, and a service proxy cannot accumulate a second trace layer that breaks raw-identity state. On each call, the registry passes a caller-bound context carrying the accessing fiber and scope as `ownerCtx`, retraces the concrete service target exactly once through that context, and invokes the captured callback with both pieces. The explicit argument binds ownership; the traced receiver preserves the factory's dependency origin. +#### The prepared lifecycle is owned before setup callbacks -The factory first captures the requested IDs, setup callback, and caller-owned agent options. Seed events and session metadata take a stricter route than a preliminary clone: cloning can erase an exotic prototype before validation sees it, so the factory reads each reference once and hands it synchronously to the session store's reservation-bound prepare operation. That boundary rejects exotic shells, reads accepted metadata fields once, and recursively materializes each seed record in one pass. Resume applies the same rule to persistence output by capturing the loaded header fields once before reconstruction. The transaction therefore cannot move to different identities, storage routing, or lineage after an asynchronous boundary. +The caller context owns the work it requested and receives the consumer-facing `AgentHandle`. The AgentLoop factory is a structural co-owner because a live agent continues to depend on its injected services. Either owner can deactivate the transaction; both converge on the same lifecycle disposer. -Before setup can observe the new objects, their ownership-bearing public properties become stable runtime data slots rather than TypeScript-only `readonly` promises. The concrete agent pins its ID, accepted options, and session; the factory binds its scope context exactly once. The session pins its ID and detached, deep-frozen header. Registry detach closures likewise close over their accepted map keys instead of rereading public properties during teardown. A JavaScript assignment or stateful accessor therefore cannot split registry lookup, dispatch, persistence, and the driver into different identities. +| Owner mechanism | Covers | Retires when | +|---|---|---| +| Caller lifecycle sentinel | Caller-fiber loss from lifecycle preparation through live lifecycle | Shared lifecycle reaches quiescence | +| Resume load sentinel | Caller-fiber loss across persistence load and lifecycle handoff | Load rollback or the adopted lifecycle reaches quiescence | +| AgentLoop tracker | AgentLoop unload and structural dependency loss | Transaction and lifecycle settle | +| ID reservations | Competing agent/session insertion | Ordered teardown releases both IDs | -The session owns the accepted log as described in [the session-immutability RFC](2026-06-11-dev-invariants-over-deep-readonly.md). Seed and append paths materialize lossless JSON once, validate both the event envelope and the metadata that places message-producing events into derived model history, and deep-freeze the exact accepted event. `session.events` returns a frozen snapshot that never grows later. The store keeps append notification and scope-carrier state in store-owned private tables instead of caller-writable `Session` fields, so outside JavaScript cannot suppress or redirect `session/event` dispatch. +A **sentinel** is an owner-visible effect that follows work whose final disposer is not yet available. It adopts the exact reservation disposers immediately, then follows the complete lifecycle disposer once preparation establishes it. -Reservations prevent two concurrent factory transactions from composing different unpublished objects under the same public identities. Each capability's `release` is its exact Cordis effect disposer. Before asynchronous work, the owning sentinel adopts those functions by identity, removing them from the caller fiber's concurrent sibling list; teardown reaches them only after the transaction's driver, registry entries, session, and scope have quiesced. Explicit release covers pre-lifecycle failure and the ordered final step, while the owning fiber remains the backstop for an abandoned transaction. The concrete factory also tracks the whole create transaction before reservation and session preparation begin, and keeps that structural edge through reservation release. Provider unload first stops the factory from accepting work, then aborts or drains every tracked transaction before its dependency surface disappears. +Cordis must make construction owner-visible before setup can reenter teardown. An effect's cleanup wrapper enters its owner list before its setup body runs, a child fiber receives its parent-owned disposer before Cordis's child-plugin notification (`internal/plugin`) announces it, and a fiber already unloading rejects new effects after taking its cleanup snapshot. Teardown observers are contained independently so one callback cannot starve peers or interrupt cleanup. These are domain-neutral lifecycle rules; `dsh-scope` uses them by mounting a no-op plugin fiber as the ownership bucket for one scope. -The agent registry and session store recognize their own reserved keys: setup code that calls public reserve, prepare, create, register, or bare enter APIs with the same IDs fails. The session capability can prepare exactly one object, and publication succeeds only when both stores receive the factory-held exact capabilities; the session store additionally checks that the capability owns that exact prepared session. This closes the otherwise possible path in which setup publishes a substitute object under an ID that the factory merely tracked in a separate pending set, without letting a vanished owner wedge the ID forever. +#### Caller ownership and factory dependency lookup stay separate -Resume needs an ownership edge before an agent object exists. It reserves the identities, then installs a caller-liveness sentinel that adopts both exact reservation disposers before persistence I/O; a factory-tracked load transaction supplies the provider edge. If either owner wins, resume rejects, waits for the load transaction to settle, and only then releases both reservations; a backend promise that settles later cannot publish. After a successful load, `startOwned` synchronously returns both the complete lifecycle disposer and the asynchronous setup/publication result. Even a preparation failure is represented by a disposer-backed result, so the load sentinel can hand off to a real quiescence boundary instead of mistaking an async function's rejected promise for successful installation. The load tracker remains until the surrounding transaction settles, while the load and caller sentinels remain lifecycle-long followers, so no ownership or ID-release gap opens. Once the shared lifecycle quiesces, each sentinel first disarms its follower and then removes its owner-fiber effect; long-lived callers therefore do not retain completed agents, scopes, or reservation closures. +Factory delegation carries two contexts because ownership and dependency origin are different facts. `ownerCtx` is the caller-bound context whose fiber and optional scope own the requested lifecycle. The factory method receiver is the accepted factory traced through that access so the concrete service retains its own injected dependency view. -The load sentinel changes what it follows at handoff but remains an owner-visible boundary: +```text +callerCtx.agents.create(options) + ownerCtx = context carrying callerCtx's fiber and scope + factoryThis = concrete accepted factory traced through ownerCtx + Reflect.apply(capturedCreateAgent, factoryThis, [ownerCtx, options]) +``` + +`setFactory()` captures the concrete target and its `createAgent` and `resume` callbacks once. It canonicalizes an already traced service before retracing, avoiding a second proxy layer that would break raw-identity state. Plain factory objects receive the explicit `ownerCtx` without depending on Cordis tracing. + +#### Create and resume reserve identities before awaiting + +Programmatic create and resume reserve both agent and session IDs before any operation can await. Create prepares a new or seeded session; resume loads persisted data while a caller sentinel and AgentLoop load tracker already own the interval in which no `Agent` object exists. + +Reservations are capabilities, not advisory sets. Setup code cannot reserve, prepare, create, register, or enter a substitute under the same IDs. A session reservation prepares at most one exact object, and publication requires the matching factory-held capabilities. A failed or abandoned transaction therefore cannot publish a substitute or wedge an ID indefinitely. + +Resume transfers ownership rather than opening a gap: ```text resume(ownerCtx, request): - snapshot request ids, options, and setup callback - reservations = reserve agentId in AgentRegistry and sessionId in SessionStore - sentinel = ownerCtx.effect( - onDispose => abort and await load settlement before reservation release, - adopt exact reservation disposers) - loadTransaction = factory.track(onDispose => signal deactivated and await settlement) + snapshot request identity, options, and setup callback + reserve agentId and sessionId + install caller sentinel adopting both reservation disposers + track load under AgentLoop - try: - persisted = await firstOf(persistence.load(sessionId), deactivated) - session = reservations.session.prepare(reconstruct persisted data) - - # This synchronous call returns a lifecycle boundary even when preparation fails. - starting = startOwned(ownerCtx, agentId, session, options, reservations, setup) - sentinel.follow(starting.dispose) - return await starting.result - finally: - release directly only if no lifecycle boundary was established - settle and untrack the load transaction + persisted = await firstOf(persistence.load(sessionId), deactivated) + session = sessionReservation.prepare(reconstruct persisted data) + starting = startOwned(ownerCtx, session, reservations, setup) + caller sentinel follows starting.dispose + return await starting.result ``` -If deactivation wins, the load promise may continue inside the backend, but it has no path back to publication. +If deactivation wins, a backend load may still settle internally but has no path back to publication. Preparation failure still returns a rollback-backed lifecycle result, so both owners can wait for actual cleanup instead of mistaking a rejected async result for successful installation. ### Setup composes an unpublished world -The optional `setup(agentCtx)` callback receives the new agent context and may synchronously register contributions or await child-plugin activation. During setup, neither the session nor agent is visible through its global registry, but `agentCtx.agent` exposes the unpublished agent to the code composing it. +`setup(agentCtx)` may register tools, prompt state, restrictions, listeners, protections, or child plugins and may await their activation. The new agent is available as `agentCtx.agent`, but neither agent nor session is visible in its global registry. -Setup may register scoped tools, prompt sections, variables, restrictions, listeners, protections, or child plugins. If it throws or rejects, the scope unwinds without publishing either object, and the reserved IDs become reusable. If either the caller owner or concrete factory unloads during an await, the preinstalled teardown skeleton marks the transaction inactive; late setup completion cannot publish. +The complete rollback skeleton exists before setup runs. If setup throws, rejects, or loses either owner, the scope and prepared resources unwind and the IDs become reusable. After setup settles, a microtask checkpoint and liveness checks let a same-turn owner unload win before publication. -Both structural edges exist before driver preparation or scope minting. The provider uses a tracked placeholder, while the caller gets a lifecycle-long sentinel that adopts the reservation effects and resolves to the same memoized lifecycle disposer. If `internal/plugin` reentrantly unloads either owner while the scope fiber is being constructed, Cordis has already attached the child disposer to its parent and the sentinel waits until preparation publishes either the complete lifecycle or a rollback disposer. A failure halfway through preparation therefore leaves both owners with a quiescence boundary for the prepared driver, minted scope, and reservations. - -The factory checks liveness before invoking arbitrary setup. After setup settles, it yields one microtask checkpoint and checks the lifecycle flag, factory state, caller-fiber state, and the owner context's associated agent state again. Cordis begins owner unload synchronously but may run nested effect disposers in the next microtask; the explicit checks and checkpoint let a same-turn unload win instead of allowing an immediately fulfilled setup to publish an already-doomed agent. - -Setup composes but does not drive. The concrete agent rejects `send`, `steer`, `inject`, and `cancel` until publication reaches the session-start boundary, keeps its inbox in a JavaScript native-private field, and allows only one concrete driver to claim a session. Driver startup is absent from the package surface: the package exports neither its loop/inbox internals nor source subpaths, and only instance-bound controls held by the factory can enable and start the driver. JavaScript or a type cast therefore cannot bypass the lock by calling a public `start()` or writing directly into the queue. These boundaries prevent a turn from opening before lifecycle listeners know the session exists. - -The common create/resume tail makes the unpublished boundary explicit: +Setup composes but cannot drive. `send`, `steer`, `inject`, and `cancel` reject until publication reaches the session-start boundary. The driver lock and inbox use runtime-private state, and only factory-held controls enable and start the loop; JavaScript casts cannot call a public start method or write directly into the queue. ```text startOwned(ownerCtx, snapshot, preparedSession): - try: - world = prepareLifecycle(ownerCtx, snapshot, preparedSession) - # Factory placeholder, lifecycle-long caller sentinel, reservation adoption, - # and complete rollback/teardown skeleton all exist before the first await. - catch preparationError with rollbackBoundary: - return { dispose: rollbackBoundary, - result: await rollbackBoundary then reject original error } + world = prepareLifecycleWithCompleteRollback(ownerCtx, snapshot, preparedSession) result = async: - require world.lifecycleActive + require world active await firstOf(snapshot.setup(world.agent.ctx), world.deactivated) await oneMicrotask() - require world.lifecycleActive - require world.factoryActive - require world.ownerFiberActive - require world.ownerAgentNotDisposed - + require caller, factory, owner fiber, and owner agent still active world.publish(snapshot.source) return handle(world.agent, world.dispose) - catch error: - await world.dispose() - throw error - return { dispose: world.dispose, result } + on any error: + await world.dispose() + rethrow ``` -`setup` can await arbitrary plugin activation, but every exit still passes through the already-installed disposer. +### Publication is ordered, observable, and rollback-covered -### Publication is ordered and rollback-covered - -After setup succeeds, the factory publishes in one synchronous sequence with no `await` between steps. Each registry has already claimed its ID across every caller-code boundary needed to construct a stable entry: the agent registry pins the accepted ID and captures one lifecycle carrier while its claim is held, and the session store holds the same kind of claim while evaluating its filter and carrier. A Proxy trap or filter getter can therefore neither overwrite a reentrant same-ID entry nor create a stale detach capability that later deletes another object. Liveness checkpoints then divide publication into three notification phases, and an outer publication barrier keeps teardown from revoking either registry entry or the scope while one of those phases is on the stack: +Publication is one synchronous sequence with liveness checks between three observable notification phases. Both registry entries exist before the first listener runs, but driving stays locked until immediately before `agent/session-start`. 1. Enter the session store and capture its scope carrier. 2. Enter the agent registry without announcing it. -3. Recheck caller and factory liveness; entering either registry may have evaluated a caller-owned getter that began teardown. +3. Recheck caller and factory liveness. 4. Emit `session/created`. -5. Recheck liveness; if teardown began, skip the agent announcement and roll back. +5. Recheck liveness. 6. Emit `agent/created`. -7. Recheck liveness; if teardown began, keep driving locked and roll back. +7. Recheck liveness. 8. Enable driving. 9. Emit `agent/session-start`. -10. Recheck liveness; if teardown began, roll back without starting the driver. -11. Start the driver loop. - -The implementation keeps publication synchronous and leaves rollback to the surrounding owned transaction: +10. Recheck liveness. +11. Start the driver. ```text publish(world): world.beginSynchronousPublication() try: - world.detachSession = world.agent.ctx.sessions.enter(world.session, world.sessionReservation) - world.detachAgent = app.agents.enter(world.agent, world.agentReservation) - require world.callerAndFactoryActive - app.sessions.announce(world.session) - require world.callerAndFactoryActive - app.agents.announce(world.agent) - require world.callerAndFactoryActive + world.detachSession = sessions.enter(world.session, sessionReservation) + world.detachAgent = agents.enter(world.agent, agentReservation) + require callerAndFactoryActive + sessions.announce(world.session) + require callerAndFactoryActive + agents.announce(world.agent) + require callerAndFactoryActive world.driver.enableDrivingVerbs() emitNonVetoing(agent/session-start) - require world.callerAndFactoryActive + require callerAndFactoryActive world.driver.start() finally: world.endSynchronousPublication() ``` -Both registry entries exist before the first creation listener runs, and setup-installed listeners receive every announcement that publication reaches. Driving opens immediately before `agent/session-start`, so that event remains the first supported place for a listener to inject or queue startup work. A synchronous teardown request from any notification marks the lifecycle inactive immediately, which makes the next checkpoint abort, but actual loop, registry, session, and scope cleanup waits until the current synchronous notification phase and publication call stack unwind. Teardown itself therefore cannot make a later listener that still runs observe a different world; teardown from `session/created` prevents `agent/created`, teardown from `agent/created` prevents session start, and teardown from `agent/session-start` prevents the driver from starting. +#### Creation is paired, not atomic -The sequence is not described as atomic because observers run between its steps. If a `session/created` or `agent/created` listener throws synchronously, the transaction rolls the registry entries and scope back, but effects already performed by an earlier listener cannot be retracted. Each store therefore marks its announcement as begun before invoking creation listeners and rejects a repeat or reentrant announcement before dispatch. Rollback emits `session/disposed` or `agent/disposed` exactly once for every corresponding creation announcement that began, including a partial emit in which an early listener observed creation before a later listener threw. An object entered but never announced has no disposal notification because no observer was told it existed. +Observers run between publication steps, so the sequence is not described as atomic. Effects already performed by an earlier listener cannot be retracted if a later listener throws. Instead, each registry marks a creation announcement as begun before dispatch and emits exactly one matching disposal edge during rollback. An entered object that was never announced has no disposal notification because no observer was told it existed. -Each registry also protects ordering inside its own creation phase. If a listener uses an advanced detach capability while `session/created` or `agent/created` is dispatching, removal and the paired disposal edge are deferred until that dispatch unwinds. The agent's creation and disposal edges reuse the carrier captured before commit instead of rebuilding it from a mutable filter getter. A detach request therefore cannot make a later listener observe `created` after `disposed`, find the just-created entry missing, or trigger disposal while creation is still constructing its receiver. Exact-object guards on both detach paths are the final defense against a stale capability deleting a later same-ID entry. The factory's outer publication barrier is the cross-registry complement: caller or provider teardown cannot remove the other entry or unwind `agent.ctx` while the current phase is still running. +A detach requested during `session/created` or `agent/created` is deferred until that dispatch unwinds. Stable captured carriers and exact-object guards prevent a later listener from observing `disposed` before `created` or a stale detach from deleting a replacement with the same ID. The outer publication barrier likewise prevents caller or AgentLoop teardown from removing the other registry entry or unwinding `agent.ctx` while an announcement remains on the stack. -Creation notification preserves that synchronous veto while also defending against JavaScript's asynchronous callback shape. A listener may return a promise even though the event type returns `void`; the dispatcher does not await it because publication has no asynchronous gap, but it observes and logs a later rejection. Such a rejection is too late to roll back, does not become unhandled, and does not starve the listeners invoked after that callback. +Creation listener synchronous throws remain vetoes. Returned promise rejections are observed and logged but not awaited: publication has no asynchronous gap in which such a result could roll back safely. Disposal notifications and `agent/session-start` are non-vetoing and independently contain both synchronous throws and returned-promise rejections so one listener cannot block cleanup or later observers. -The disposal notifications and `agent/session-start` do not treat return values or listener failures as vetoes. Their dispatchers invoke every listener synchronously and independently; they log and contain both a synchronous throw and a rejection from a returned promise. Completion or rejection of a returned promise is observed but not awaited, so it cannot delay rollback or teardown, veto driver startup, or starve a later listener. The callback's synchronous prefix remains ordinary code: if it holds and disposes a structural ownership edge, the next publication liveness check deliberately aborts startup. +### Teardown stops work before revoking registrations -### Teardown stops work before revoking its world +Every owner path reaches one memoized reverse-order transaction. It marks the lifecycle inactive, waits for an in-progress synchronous publication phase, stops the driver through actual exit and final durability work, detaches the agent and session, unwinds the scope, and releases IDs last. -Every owner path reaches the same memoized reverse order: the consumer handle, caller-fiber disposal, and structural factory-provider unload first deactivate the lifecycle; wait for an in-progress synchronous publication phase; stop the loop and await its actual exit plus every agent-started durability checkpoint; remove the agent from the registry; detach the session; unwind the scope; and only then release both IDs. Final turn events, the turn-ending flush, and any outstanding idle-injection flush therefore settle while the session and scoped listeners are still live, and a replacement cannot reuse either identity while old scoped cleanup remains in flight. +Final turn events, the turn-ending flush, and any outstanding session flush started while the agent was idle therefore run while the session and scoped listeners still exist. `agent/disposed` observes an already quiescent and unregistered concrete agent while its session remains live; `session/disposed` follows after event feed detachment and store removal. Both use the stable carrier captured for their matching creation edge. ```text disposeOwnedAgent(world): mark world inactive await world.synchronousPublicationIfRunning() - await world.stopDriver() # waits for loop exit and all agent-started flushes - world.detachAgent() # leaves registry; emits agent/disposed if announced - world.detachSession() # stops event feed, leaves store; emits session/disposed if announced + await world.stopDriver() # loop exit plus agent-started flushes + world.detachAgent() + world.detachSession() await world.scope.dispose() world.releaseSessionReservation() world.releaseAgentReservation() ``` -The actual Cordis generator yields these disposers in reverse so its last-in-first-out teardown executes in the order shown. +`AgentHandle.dispose()` gives repeated and racing consumers the same completion promise. The lifecycle-long caller sentinel follows that promise even when handle disposal wins first, while the AgentLoop ledger independently stops new transactions and waits for every structurally dependent agent before the service disappears. -For the concrete AgentLoop transaction, `agent/disposed` runs after the driver is quiescent and the agent has left the registry; the session is still live during that notification. The public AgentRegistry alone promises only exact removal, because a custom registered `Agent` owns any stronger driver contract itself. `session/disposed` follows after append notification has been detached and the session has left its store. The scope is still live when each disposal listener is selected and invoked, although returned asynchronous work is observed rather than awaited. Both notifications use the stable scope key and delivery rule captured for their creation partners and occur exactly once only when those creation announcements began. +AgentLoop co-ownership follows dependency shape, not a blanket “creator owns every returned value” rule. An AgentLoop-created agent continues to depend on the loop's services, so AgentLoop unload stops it. -`AgentHandle.dispose()` is memoized so repeated consumer calls await the same full transaction. The lifecycle-long caller sentinel independently follows that memoized promise, so handle-first teardown cannot make a racing caller-fiber unload observe Cordis's inert second raw-disposer call and return early. Once the transaction reaches its final quiescent stage, retirement disarms and removes the sentinel before settling that shared promise. `Scope.dispose()` provides the corresponding shared boundary for direct scope disposal and raw-disposer races. The provider's ownership ledger is internal rather than another public handle: it stops accepting new transactions, invokes every tracked disposer independently, and waits for all of them before the AgentLoop service surface disappears. +## Boundary ownership: accept once and own the accepted value -Provider co-ownership is specific to resources that remain structurally dependent on their provider. An AgentLoop-created agent continues to resolve the loop's injected services, so loop unload must stop it. A worker workflow run instead captures its holder-bound `SubagentService` handle synchronously at `start()` and stores that independent dependency on the run; unloading `WorkerWorkflowEngine` removes the ability to start new runs but does not revoke an already returned run or prevent its later worker message from starting a child. The two lifetimes differ by dependency shape, not by a blanket rule that every service must own every value it creates. +Acceptance-sensitive boundaries that cross asynchronous, reentrant, model-visible, or durable-log code read caller-owned fields once and retain only owner-controlled identities or snapshots. This rule is independent of TypeScript: `readonly` annotations vanish at runtime, and JavaScript accessors can return a different value on every read. -Parent-owned subagents use explicit ownership rather than registration inheritance. The driver creates one run-owner fiber under `parent.ctx` and invokes the child factory through that fiber, so lifecycle ownership exists before setup or publication begins; disposing a parent reaches its descendants even if a delegating tool never reaches its own `finally`. The child still receives a newly minted scope and resolves only global plus child-scoped registrations. - -## Owner-final policy boundaries - -Cooperative waterfalls remain the general extension mechanism, but an invariant belongs after the last transformable point. The design adds four narrow boundaries, each owned by the service that can define what “final” means. - -### Prompt protection restores named canonical contributions - -`systemPrompt.protect({ sections, tools })` declares that selected names must match the canonical registry/provider assembly after the complete `system-prompt/assemble` waterfall. It reads each caller array once before deduplication, so the names checked for an empty protection are the names actually installed. Protections registered globally and for the current scope compose by set union, so callback order cannot weaken them. Protection finalizes a returned assembly rather than recovering from listener failure; if the waterfall throws, assembly still fails. - -For each protected name, the service restores the canonical presence and definition. If the canonical assembly omitted the name, protection removes a listener-fabricated entry; this makes mode-dependent absence enforceable as well as presence. Tool providers receive the same coherence treatment: assembly reads `schemas`, optional `knownNames`, and every schema field once, detaches that record, and uses its captured names for both `toolOrder` validation and the model-visible collection. A stateful provider therefore cannot validate a phantom name while showing a different tool. - -A global section protection also reserves the registry name against scoped shadowing. Registering a scoped section under an already protected global name throws, and adding global protection throws if any scoped shadow already exists. Section registration copies `name`, `order`, and the text value or callback before the check and stores that record, so later mutation of the caller's object cannot rename a safe section into a reserved one. This check must happen before assembly: otherwise the ordinary scoped-over-global merge would make the shadow itself look canonical, leaving post-waterfall restoration with the wrong owner's value. Tool-schema protection does not impose a blanket schema-name reservation because providers are additive and may deliberately contribute unrelated executable schemas. - -Restoration is intentionally not a whole-assembly reset. The service first removes protected names from the waterfall result, then reinserts protected canonical entries in their canonical order immediately before the first surviving later unprotected canonical neighbor, or at the end when no such neighbor survives. Unprotected entries keep the ordering and definitions chosen by the waterfall. This anchor rule preserves the protected contribution's meaningful local placement without claiming that protection restores every global relative position after arbitrary listener reordering. - -Only the restoration inputs are detached before dispatch: the canonical section array when section protection is active and the canonical tool array when tool protection is active. The waterfall receives the original mutable assembly, not a clone, and variables and other merge-extensible fields remain entirely under ordinary waterfall semantics. +The shared shape distinguishes identity-bearing references from data. Agent objects and abort signals are retained by identity after one read. Boundaries whose contract requires lossless JSON—such as session events and subagent payloads—validate and materialize it in one traversal; other boundaries use their own owned representation, such as `structuredClone` for agent options. Scalars and callbacks are captured once, then each boundary applies the validation promised by its API before downstream use. ```text -registerSection(input, scope): - stored = copy(input.name, input.order, input.text) - if scope exists and stored.name is globally protected: - fail before registration - sectionLayer(scope).add(stored) - -assemble(context): - assembly = assemble registries for context.scope - canonicalSections = active section protection ? clone(assembly.sections) : absent - canonicalTools = active tool protection ? clone(assembly.tools) : absent - transformed = await systemPromptAssembleWaterfall(assembly) - - for each protected name in the corresponding canonical array: - remove every transformed entry with that name - if the canonical array contains the name: - if a later unprotected canonical neighbor survived: - insert the canonical entry before that neighbor - else: - append the canonical entry - - return transformed +accept(input): + read every relevant top-level field exactly once + retain identity-bearing references without rereading them + validate acceptance-time fields from those captures + copy or pin data in the representation owned by this boundary + bind accepted callbacks once when method receiver state is intentional + expose only owner-controlled identities, frozen records, or detached results ``` -This algorithm restores a protected entry's definition, presence or absence, and useful local anchor without erasing unrelated listener output. +Capture does not imply uniform eager callback type-checking. Agent `setup` is captured once and any invocation failure enters rollback; a tool guard is likewise captured, and an invalid cast becomes a normalized execution error. The invariant is that later work never rereads caller fields to choose a different value. -Code Mode uses global protection for the `tools:sdk` section and reserved `run_code` schema. Structured output adds scoped protection for its instruction and capture schema. These are named guarantees: unrelated listeners may still contribute unrelated sections or tools. +| Boundary | Identity retained | Data detached or pinned | +|---|---|---| +| Tool and `SubagentProvider` registration | Original callback receiver | Name, flags, schemas, scalar config | +| Agent create/resume | Caller context, setup callback | IDs, options, session metadata and seed | +| Approval request | Agent and abort signal | Tool name, call ID, and reason | +| Tool execution | Agent, signal, registry-minted parent token | Call identity and arguments | +| Session append/load | Session identity | Header and event envelopes | +| Subagent start/result | Parent and signal | Prompt, filters, schema, options, result | -### Tool executions have stable identity +Before agent setup can run, the concrete agent pins its accepted ID, options, and session and binds `ctx` once. Registry detach closures likewise close over their accepted keys instead of rereading mutable public fields. -`ctx.tools.execute(input)` accepts a caller-owned `ToolExecutionInput` and snapshots it into a distinct pipeline-owned `ToolExecution`. It reads `callId` and `name` once and requires each value to be a string before treating the pair as trustworthy correlation identity. A throwing accessor or non-string value rejects before `tools/result`, because even an error result could not carry a valid identity. After that boundary, the registry reads every other top-level caller field once, and any later accessor or validation failure becomes one normalized final error notification built from the already accepted strings and captured optional fields. +A stateful getter shows why validation and ownership must use the same capture: -The registry materializes `arguments` in one lossless-JSON traversal and deep-freezes the result, so parent-token validation, scope routing, policy, dispatch, and final observation receive exactly the value that passed validation. A cloneable but mutable exotic such as `Map` or a class instance is rejected before policy rather than smuggled through an apparently frozen wrapper. +```js +let reads = 0 +const input = { + get name() { + reads += 1 + return reads === 1 ? 'safe_tool' : 'different_tool' + }, +} -The registry assigns each pipeline trip a frozen, property-free `ToolExecutionToken`; callers cannot choose that token. The execution is identity-stable, not fully immutable, while the pipeline runs: its `token`, `callId`, `name`, `agent`, optional opaque `parent` token, and detached `arguments` are non-writable and non-configurable from the first policy listener onward. `signal` is the only operational field; an around-dispatch wrapper may add, replace, or remove it. The registry freezes the complete execution before outcome observation. +// Wrong: validation and storage observe different values. +validateName(input.name) +storeName(input.name) -Stable identity prevents a listener from changing which tool or scope the pipeline accepted after policy ran. It also gives commit-style observers a safe `WeakMap` key even when an adapter reuses a model call ID. +// Right: one accepted value drives both. +reads = 0 +const acceptedName = input.name +validateName(acceptedName) +storeName(acceptedName) +``` -For a nested transport dispatch, `parent` carries only the enclosing execution's opaque token rather than its live object. Code Mode sets an SDK sub-call's `parent` to the outer `run_code` execution's `token`, so an observer can correlate the two outcomes without receiving a reference that could mutate the still-running outer wrapper. +### Registered definitions are frozen snapshots -The input-to-execution conversion is intentionally one-way: +Tool registration creates the stored definition identity once; changes occur through explicit unregister/register effects rather than mutation of a caller-retained object. Parameters are materialized in one traversal, callbacks bind once to the accepted definition receiver, and the stored record is deep-frozen. + +The first-party `defineTool()` helper applies the same boundary before registration. It captures each option once, materializes the authoring `SchemaSpec`, and derives both the wire schema and later execution/presentation validation from that owned spec. + +```text +defineTool(options): + accepted = read each option exactly once + parameterSpec = snapshotLosslessJson(accepted.parameters) + wireSchema = snapshotLosslessJson(convertToJsonSchema(parameterSpec)) + build execute and presentation validation over parameterSpec + +registerTool(context, definition): + accepted = read each definition field exactly once + stored = deepFreeze({ + accepted name, description, timeout, + parameters: snapshotLosslessJson(accepted.parameters), + execute: bind accepted.execute to definition, + presentation callbacks: bind accepted callbacks when present + }) + layerFor(scopeOf(context)).add(stored.name, stored) +``` + +`get()` and `visible()` return the frozen stored definitions; `schemas()` returns detached projections. Replacing `definition.execute` after registration has no effect, while a callback can deliberately read live state from its closure or original receiver. + +Factory and backend registration use different reentrancy orderings around the same ownership rule. `AgentFactory` registration claims its single slot before reading callback accessors. `SubagentProvider` registration first snapshots the provider fields, then its effect checks and enters the accepted name. Both capture callback identity and intentional receiver state once, and hot-reload cleanup closes over the accepted slot or key instead of rereading a mutable public property. + +### Durable session data belongs to the session + +The session pins its ID and detached, deep-frozen header. Seed and append paths materialize lossless JSON once, validate the event envelope and message-history metadata against that owned record, and deep-freeze the exact accepted event. `session.events` returns a frozen snapshot that never grows later. + +The store keeps append observers, accepted registry IDs, and scope carriers in private owner state rather than caller-writable fields. Outside JavaScript therefore cannot rename a stored session, redirect `session/event`, or mutate an earlier snapshot into newer history. + +Approval requests follow the same async boundary at smaller scale: one capture preserves exact agent/signal identities, copies scalar fields, captures the session once, and drives `approval/asked`, scoped policy, cancellation, and `approval/decided` from that record. + +### Tool execution has pipeline-owned identity + +`ctx.tools.execute(input)` turns caller-owned input into one pipeline-owned `ToolExecution`. It first reads `callId` and `name` once and requires strings; a failure there rejects because even an error result would lack trustworthy correlation identity. Once those strings are accepted, later input failures can become normal final error outcomes. + +Arguments are materialized once and deep-frozen. The registry assigns a frozen property-free `ToolExecutionToken`; callers cannot choose it. `token`, `callId`, `name`, `arguments`, `agent`, and optional opaque `parent` token become non-writable and non-configurable before policy. `signal` is the only operational field an around-dispatch wrapper may replace or remove. ```text prepareExecution(input): callId = read input.callId exactly once name = read input.name exactly once - require callId and name are strings - # A failure above rejects: no trustworthy correlation identity exists. + require both are strings accepted = read arguments, agent, parent, and signal exactly once - require accepted.parent is absent or a registry-minted token - detachedArguments = snapshotLosslessJson(accepted.arguments) + require parent is absent or a registry-minted token + arguments = deepFreeze(snapshotLosslessJson(accepted.arguments)) execution = { token: new frozen property-free object, - callId, - name, - arguments: deepFreeze(detachedArguments), + callId, name, arguments, agent: accepted.agent, parent: accepted.parent, signal: accepted.signal } - - make every field except signal non-writable and non-configurable + protect every field except signal return execution ``` -### Tool guards can deny but never re-allow +Stable execution identity prevents middleware from changing which tool or scope policy accepted. It also gives structured-output commit a safe `WeakMap` key when an adapter reuses a string call ID. Code Mode correlates an SDK sub-call with its enclosing `run_code` using only the outer execution's opaque token, never a mutable reference to the live outer object. -`ctx.tools.guard()` installs a synchronous global or scope-specific guard after the extensible `tools/pre-execute` waterfall and before dispatch. A guard returns a denial reason or `undefined`; it has no allow result. +Result boundaries apply the same ownership rule. Each transform returns data that is captured field-by-field, validated, materialized, and ultimately deep-frozen for final observers; malformed outcomes normalize to JSON-safe error results rather than reaching the session log as apparent success. -This one-way result makes the boundary monotonic. Pre-execution hooks can still compose ordinary allow, deny, and ask decisions; an ask resolves through the optional `ctx.approval` seam, where only `allowed-once` becomes allow and an absent channel or any non-grant becomes deny before guards run. No listener ordering can convert a guard denial back into dispatched work. A denied call still continues through result transformation and final observation as an error outcome. +## Owner-final policy: four narrow boundaries -The two APIs have deliberately different strength. A waterfall listener may return an allow decision, but the later guard has no corresponding allow result: +Waterfalls remain the ordinary extension mechanism; each of four protocol invariants runs after the last extension point capable of violating that specific invariant. Each owner-final API has the weakest one-way power that can preserve its guarantee. + +Here **canonical** means the named registry or tool-schema-provider output assembled before the waterfall—not “all output the service approves.” Protection restores only the names its owner declares. + +| Invariant | Cooperative extension point | Owner-final boundary | Guarantee | +|---|---|---|---| +| Named prompt/tool contribution | `system-prompt/assemble` waterfall | `systemPrompt.protect()` finalization | Canonical presence, absence, definition, and local anchor survive | +| Non-overridable tool denial | `tools/pre-execute` allow/deny/ask waterfall | Synchronous `tools.guard()` | A denial cannot become allow | +| Authoritative live outcome | Execute and post-execute waterfalls | Awaited `tools/result` notification | Observers receive one immutable final result | +| Terminal protocol completion | Continuation waterfall and pending steering | Serial `agent/turn-stop` | No middleware or late steering creates another step | + +### Prompt protection restores named canonical contributions + +`systemPrompt.protect({ sections, tools })` snapshots the requested names and restores their canonical registry or tool-schema-provider output after the complete assembly waterfall. Global and matching scoped protections compose by set union; a waterfall failure still fails assembly rather than triggering recovery. + +Protection covers both presence and absence. If the canonical assembly omits a protected name, finalization removes a listener-fabricated entry; this is how Code Mode keeps a native schema absent while preserving the SDK/transport form. Tool providers likewise expose one captured coherent record for schemas and optional known names, so a stateful getter cannot validate one name and display another. + +#### Global section protection reserves its name + +A globally protected section name cannot be shadowed by a scoped section. Scoped registration under an already protected name fails, and adding protection fails if a scoped shadow already exists. This check occurs before assembly because scoped-over-global merge would otherwise make the shadow itself appear canonical. + +Tool-schema protection does not create a blanket reservation for unrelated schema names. Providers are additive and may deliberately contribute other executable schemas; the owner-final guarantee covers only the named canonical contribution. + +#### Restoration preserves a useful local anchor + +Protection does not reset the whole assembly. It removes protected names from the waterfall result and reinserts each canonical entry before the first surviving later unprotected canonical neighbor, or at the end if none survives. Unprotected entries retain the order and definitions chosen by middleware. + +```text +assemble(context): + assembly = assemble registries for context.scope + canonical = snapshot protected section/tool inputs + transformed = await systemPromptAssembleWaterfall(assembly) + + for each protected canonical name: + remove every transformed entry with that name + if canonical includes the name: + insert before first surviving later canonical neighbor, else append + + return transformed +``` + +Code Mode globally protects `tools:sdk` and reserved `run_code`; structured output adds scoped protection for its instruction and capture schema. + +### Tool guards deny monotonically + +`ctx.tools.guard()` installs a global or scoped synchronous check after the complete `tools/pre-execute` waterfall and before dispatch. A guard returns a denial reason or `undefined`; it has no allow result. + +Pre-execute hooks still compose ordinary allow, deny, and ask decisions. An ask resolves through the optional approval service, where only `allowed-once` becomes allow and absence or any non-grant becomes deny. Guards run afterward, so listener order cannot convert their denial into dispatched work. ```js agent.ctx.on( @@ -771,72 +734,56 @@ agent.ctx.tools.guard(execution => ) ``` -Even a later prepended allow listener cannot bypass this guard because the registry evaluates guards after the complete waterfall. +Even a later prepended allow listener cannot bypass the guard. A denied call still becomes an error outcome that flows through result transformation and final observation. -### `tools/result` observes the authoritative live outcome +### `tools/result` observes the final live outcome -The complete live pipeline is `tools/pre-execute` → monotonic guards → `tools/execute` → `tools/post-execute` → `tools/result`. The first three named events are transformable waterfalls; `tools/result` is an awaited, observe-only notification after all transforms and the registry's outer error normalization. At each untrusted result boundary, the registry captures every top-level field once and materializes the complete authoritative outcome as detached lossless JSON. Immediately before observation it materializes that owned outcome again and deep-freezes the shared listener snapshot. An invalid tool or listener result becomes a normal JSON-safe `isError` outcome instead of reaching observers as apparent success and failing later at the session log. +The live pipeline is `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute` → `tools/result`. The first, execute, and post stages are transformable waterfalls; `tools/result` is an awaited observe-only notification after every transform and outer error normalization. -Every `tools/result` listener receives the same frozen execution and deep-frozen result snapshot. Listener failures are contained independently, so they cannot change the caller's result or starve peer observers. Scope filtering derives from `execution.agent`. +Every observer receives the same frozen execution and a separate deep-frozen snapshot of the owned result returned to the caller. Listener failures are contained independently, so they cannot change that returned result or starve peers. Routing uses `execution.agent`. -`tools/result` is not the durable session event `tool/result`. The live notification belongs to the registry and also fires for direct programmatic executions; the agent loop subsequently appends `tool/result` to the session log for replay, UI reconstruction, and model history. A policy that needs the final in-process verdict uses the former, while a consumer that needs persisted transcript state uses the latter. - -The entire registry method reads like one execution-decision pipeline: +`tools/result` is not the durable `tool/result` session event. The live notification also fires for direct programmatic executions and is the source of truth for in-process commit logic. The agent loop later appends the durable event for replay, UI reconstruction, and model history. ```text execute(input): - callId = read input.callId exactly once - name = read input.name exactly once - require callId and name are strings + accept trustworthy callId and name + try to prepare pipeline-owned execution + on preparation failure: + create an identity-bearing error shell + ownedResult = owned error result + freeze execution + observerResult = deepFreeze(snapshotLosslessJson(ownedResult)) + await every tools/result observer independently with observerResult + return ownedResult - try: - execution = prepareExecutionFromTrustedIdentity(input, callId, name) - catch invalidInput: - execution = frozen identity shell with arguments = undefined - result = errorResult(invalidInput) - await tools/result observers with independent failure containment - return result + gate = await tools/pre-execute(execution) + resolve ask through approval when needed + denial = policy denial or first guard denial - try: - gate = await tools/pre-execute(execution) - decision = gate - if gate asks: - decision = await resolveWithApproval(gate, execution.agent) - # approval absence and every non-grant resolve to deny + if denied: + result = errorResult(denial) + else: + result = await tools/execute(execution, dispatchRegisteredTool) - if decision allows: - denial = firstRegisteredGuardDenial(execution) - else: - denial = decision.denial - - if denial exists: - result = errorResult(denial) - else: - result = await tools/execute(execution, next = dispatchRegisteredTool) - result = requireValidExecutionResult(result) - - result = await tools/post-execute(execution, result) - result = snapshotLosslessJson(result) - catch pipelineFailure: - result = errorResult(pipelineFailure) - - freeze(execution) - frozenResult = deepFreeze(snapshotLosslessJson(result)) - await every tools/result observer independently, containing each failure - return result + result = await tools/post-execute(execution, result) + ownedResult = normalize into owned lossless JSON + freeze execution + observerResult = deepFreeze(snapshotLosslessJson(ownedResult)) + await every tools/result observer independently with observerResult + return ownedResult ``` -Waterfalls can transform only at their named stages. Guards can only deny, and the final observers can only observe. +Waterfalls transform only at their named stages; guards only deny; final observers only observe. -### `agent/turn-stop` makes a composed continuation terminal +### `agent/turn-stop` makes continuation terminal -Steering is input injected into an already running turn for the next model step; ordinary queued prompts wait for a future turn. The loop normally preserves that distinction by moving leftover steering into another step while leaving the queued-prompt FIFO alone. +Steering is input for another model step inside the current turn; queued prompts wait for a future turn. Ordinary continuation remains extensible: the loop computes a default, runs `agent/turn-continuation`, records any force-continue reason as steering, and treats pending steering as a reason to continue. -Ordinary continuation remains extensible. The loop computes a default, runs the `agent/turn-continuation` waterfall, records any force-continue reason as steering, and folds pending steering into the decision because steering normally demands another model step. +The scoped serial `agent/turn-stop` checkpoint runs after that folding. A listener returns `{ action: 'stop' }` or abstains with `undefined`; malformed values and throws close the current turn with an error. A stop is terminal, so later listeners and steering cannot restore continuation. -The scoped serial `agent/turn-stop` checkpoint runs after that folding. Its strict serial helper consults listeners in order until one returns a non-`undefined` value; a listener returns `{ action: 'stop' }` or abstains with `undefined`. The dedicated helper exists because ordinary Cordis serial dispatch treats `null` and `false` as framework abstentions, while this public contract has exactly one abstention value. A stop is terminal, so later listeners and pending steering cannot restore continuation. A malformed result, including `null` or `false`, or a throwing policy closes the current turn with an error while leaving the driver available for later work. +The loop uses `strictSerial` because ordinary Cordis serial dispatch treats `null` and `false` as abstentions. This terminal protocol permits only `undefined` to abstain, making accidental return values fail closed. -Terminal stop deliberately discards steering while preserving ordinary queued prompts. Its terminal state remains in force through `turn/end` and the durability flush, so steering added by continuation, turn-close, or flush listeners cannot escape through the loop's late-steering fallback into another step or turn. This is the explicit exception to the normal rule that leftover steering becomes input for another turn. The stronger terminal control is reserved for protocols, such as a completed structured child, where further model work would violate the result contract. +Terminal state remains active through `turn/end` and the durability flush. Steering added by continuation, turn-close, or flush listeners is discarded after a terminal stop, while the ordinary queued-prompt FIFO remains untouched. ```text afterSuccessfulStep(turn): @@ -845,7 +792,6 @@ afterSuccessfulStep(turn): if steering is pending: decision = continue terminal = await strictSerial(agent/turn-stop) - # undefined means abstain; null, false, malformed values, and throws are errors if terminal == stop: discard steering terminalStopped = true @@ -860,13 +806,27 @@ afterSuccessfulStep(turn): move leftover steering to the next-turn queue ``` -The queued-prompt FIFO is separate and is never drained by terminal stop. +This stronger control is reserved for terminal protocols such as a completed structured child; ordinary continuation policy remains cooperative. -## Subagent composition +## Subagents: the composition proof -In-process subagents demonstrate how the scope, lifecycle, and final-policy pieces compose. A provider builds the child's world during unpublished setup, then lets the ordinary agent lifecycle own it. +In-process subagents add no second scoping model. They create a fresh flat child scope during unpublished setup, install ordinary scoped persona/filter/protocol registrations, own the child through a run handle, and use the same owner-final checkpoints for structured output. -The caller-facing seam separates acceptance, readiness, result settlement, cancellation, and disposal. This example assumes the spawn backend is loaded under its configurable default provider name, `spawn`, `parent` is top-level (depth 0), and a global `read` tool has already been registered. A caller observes readiness before treating the child as live and always disposes the run: +The roles and phases are explicit: + +| Role | Responsibility | +|---|---| +| Caller | Supplies parent, prompt, optional child configuration, and eventual disposal | +| `SubagentService` | Validates capabilities, owns the public wrapper, normalizes result and lifecycle telemetry | +| `SubagentProvider` backend | Chooses transport and creates one run | +| In-process driver | Owns child creation, setup, prompt drive, result read, cancellation, and teardown | +| Child `Agent` | Uses the ordinary agent lifecycle and its fresh `agent.ctx` | + +```text +accepted start -> started (published) -> result (settled) -> dispose (quiescent) +``` + +Assume the in-process `spawn` backend uses its default name, `parent` is top-level, and global `read` exists: ```js const run = ctx.subagents.start('spawn', { @@ -886,156 +846,15 @@ const run = ctx.subagents.start('spawn', { try { await run.started const result = await run.result - // result.structured exists only after a successful committed capture. + // result.structured exists only after successful final commit. } finally { await run.dispose() } ``` -### Inputs and ownership are fixed before asynchronous creation +### The child world uses ordinary registrations -This section follows a child from provider/request acceptance through the service wrapper and then through the workflow bridge. Each layer captures its boundary before arbitrary asynchronous work and owns the cleanup it may need to start. - -#### Provider and request acceptance own the child boundary - -Provider registration first freezes an acceptance snapshot of the provider name, capability flags, parent-context descriptor, and `start` callback; the callback is bound to the original provider object so its intentional internal state stays live. Lookup, validation, model-facing wording, dispatch, lifecycle notifications, and hot-reload cleanup all use that snapshot. Mutating or reusing the caller's provider object later therefore cannot rename a live entry, change its advertised powers, replace its callback, or make its disposer delete the wrong key. - -Starting a run reads every top-level request field once before capability validation, then snapshots every accepted field before asynchronous owner setup. This order makes checked and delegated capabilities identical even for a JavaScript caller with stateful accessors. Fixed scalars are checked at the same boundary: `maxDepth` must be a non-negative safe integer and `persona` must be a string. The parent and abort signal are retained as identity capabilities but never reread from the mutable request record; tool filters, seed events, agent options, output schema, and prompt are detached through the one-pass lossless-JSON materializer. The exported in-process driver repeats this boundary for direct callers before it awaits run-owner activation, including taking one seed snapshot from which it derives both the child prefix and `seedLength`. Later caller mutation therefore cannot change lifecycle scope, configuration, the schema enforced by the capture tool, or the prompt eventually logged and sent. - -Depth validation is intentionally repeated at every public entry path, while one seam-owned helper keeps the accepted domain identical: - -```text -tool-subagent plugin load: - schema requires natural <= Number.MAX_SAFE_INTEGER - assertSubagentMaxDepth(config.maxDepth) - -SubagentService.start(request): - capture request.maxDepth once - assertSubagentMaxDepth(captured maxDepth) - -startInProcessRun(request): - capture request.maxDepth once - assertSubagentMaxDepth(captured maxDepth) - parentDepth = depthOf(parent) # also a non-negative safe integer - childDepth = parentDepth + 1 - if childDepth is not a safe integer: throw RangeError - if maxDepth is defined and childDepth > maxDepth: throw SubagentDepthError -``` - -The derived-value check is separate from validating either input: `Number.MAX_SAFE_INTEGER` is a valid stored parent depth, but adding one cannot produce a contract-valid child depth. The driver rejects that overflow even when no request-level `maxDepth` cap was supplied. - -The driver first installs provider ownership. Only after that succeeds does it attach the request's abort listener and create one run-owner Cordis fiber under `parent.ctx`; an already-unloading provider therefore leaves neither a child nor an orphaned listener. Calling `runOwner.ctx.agents.create()` gives the child factory an explicit `ownerCtx` carrying the run-owner fiber and scope, while the registry's traced factory receiver preserves AgentLoop's injected dependency origin. Parent teardown, provider teardown, and manual run disposal all dispose this same run-owner node; moving it out of the active state synchronously prevents an unpublished setup from publishing afterward, while all three paths follow one quiescence promise. This structured ownership does not change the child's flat registration view. - -#### The service wrapper orders readiness, results, and lifecycle - -The provider's run separates acceptance from publication with `started: Promise`, but the service does not expose that caller-owned handle directly. It captures `id`, `started`, `result`, and each method once, binds methods to the provider-owned run handle, and returns a frozen service-owned wrapper. Capturing `dispose` first also preserves a rollback capability if a later accessor or method check reveals a malformed handle. The wrapper installs its shared disposal promise before invoking the raw provider callback, so synchronous reentry through the returned wrapper and ordinary repeat calls join one provider disposal rather than slipping through a not-yet-assigned memo. If the raw disposer directly returns that same reentrant wrapper promise, the service rejects the cyclic provider contract instead of awaiting a promise that depends on itself forever. - -The wrapper's `result` promise captures `output`, optional `structured`, and `stopReason` once and resolves to one detached, deeply frozen lossless-JSON value shared by the caller and lifecycle telemetry. Malformed terminal data is an infrastructure fault; it rejects only after the service has started rollback of the provider attempt. The service observes the normalized result immediately, before waiting for readiness, so an early rejection is never temporarily unhandled. - -For spawn and fork, the accepted `started` promise fulfills only after the child factory returns a published handle. The service can then emit `subagent/start` with `ctx.agents.get(id)` already live and release any buffered terminal event; if readiness rejects, it emits neither start nor end. Lifecycle notification is fire-and-forget and non-vetoing: each listener receives the same deeply frozen payload, and synchronous throws or returned-promise rejections are logged and contained per listener without awaiting them. The child result driver awaits the same readiness boundary before sending the prompt. - -```text -startInProcessRun(providerContext, acceptedRequest): - snapshot all request data, including parent identity - - providerLink = providerContext.effect(onDispose => disposeRunOwner()) - attach snapshot.abortSignal listener - runOwner = mount no-op plugin under snapshot.parent.ctx - - returnedRun.dispose = () =>: - dispose providerLink - await disposeRunOwner() - - creation = runOwner.ctx.agents.create({ - fresh ids and lineage, - detached options and optional seed, - setup(childCtx) => install persona, tool restriction, structured runtime - }) - - returnedRun.started = creation.then(childHandle => publication complete) - returnedRun.result = async: - await returnedRun.started - send the child prompt, await idle, derive the terminal result - -SubagentService.start(...): - providerRun = provider.start(detached request) - serviceRun = freeze({ - id, started, and methods captured once from providerRun, - methods bound to providerRun, - result: normalize once into detached, deeply frozen lossless JSON - }) - attach settlement handlers to serviceRun.result immediately - attach handlers to serviceRun.started: - on fulfillment, emit subagent/start and then buffered or eventual subagent/end - on rejection, discard buffered lifecycle telemetry - return serviceRun immediately -``` - -#### The workflow bridge closes readiness and settlement races - -Every downstream protocol that announces a subagent must honor the same boundary. The workflow worker bridge therefore registers the returned run before waiting, observes and snapshots `result` immediately, and sends `ChildStarted` only after `started` fulfills while admission remains open. A readiness rejection is refused and host-disposed; `ChildStartError` is sent while worker-message admission remains open, and an already-retired exact run is not cleaned twice. Provider `start()` is itself arbitrary code and may synchronously reenter workflow cancellation before its returned run reaches that registry. The bridge attaches both promise observers, re-checks terminal admission immediately after `start()` returns and again at readiness, and turns a closed boundary into identity-guarded cancellation, disposal, and refusal rather than late worker admission or lifecycle announcement. An arbitrary provider may still fulfill its own `started` promise after the workflow boundary; the bridge refuses and cleans up that attempt instead of claiming it can undo provider-side publication. - -```text -Workflow worker bridge after receiving returnedRun: - register the run so cancellation can reach pre-publication work - attach result settlement handlers immediately and snapshot the outcome - re-check terminal admission after provider start returns - if admission closed: - if the exact run remains registered: cancel once and dispose it - if worker-message admission remains open: send ChildStartError - else wait for returnedRun.started: - on fulfillment: - re-check terminal admission - if closed: apply the same identity-guarded refusal - else: send ChildStarted; then send the buffered or eventual outcome - on rejection: - if worker-message admission remains open: send ChildStartError - if the exact run remains registered: dispose it - -Worker after choosing its result: - queue Result on the worker-to-host port - only then reap stray child handles - -Host at workflow Result receipt: - cancellationWasRequested = external cancellation is already in flight - atomically claim chosen = - if cancellationWasRequested and result is not cancelled: - cancelledResult - else: - result - - if not cancellationWasRequested: - abort the shared child-request signal - call cancel("workflow settled") on every host-registered run - - settle chosen - -Host at the first worker death signal: - close worker-message admission - claim death or preserve an earlier cancellation/Result/grace outcome - cancel and dispose every registered child - synthesize missing lifecycle ends - -Host at physical worker exit: - perform a final disposal-only sweep - do not repeat explicit child cancellation -``` - -Cancellation before readiness is a publication decision, not merely a flag for later result mapping. The in-process run synchronously deactivates its owner fiber. If cancellation lands before publication, the factory's liveness check prevents either creation edge. If it begins synchronously inside `session/created`, `agent/created`, or `agent/session-start`, the publication barrier lets the current notification phase unwind without revoking its world, the next liveness check prevents every later phase and driver start, and rollback pairs every creation edge that already began. In either case `started` rejects, no `subagent/start` or `subagent/end` is emitted, and the run result settles as `aborted`. - -Receipt of the worker's `Result` message is the workflow host's atomic first-wins boundary. The worker queues that message before its own settlement-reap `ChildCancel` messages, so same-port FIFO prevents an internal child callback from masquerading as earlier run cancellation. Each contender records its claim before its own callback fanout: external `cancel()` records its reason first, while Result receipt snapshots any earlier cancellation and claims the resulting terminal outcome before invoking settlement-cleanup provider code. A caller, signal, or dispose cancellation already in flight therefore overrides a non-cancelled worker report, while the report wins otherwise. Before exposing that chosen result, the host drives both permitted child-cancellation channels by aborting the shared request signal and calling every registered run's `cancel()`, including runs still waiting on readiness. Those calls are settlement-only cleanup, and the terminal claim makes a reentrant `WorkerRun.cancel()` a side-effect-free loser rather than merely repairing its result afterward. Host fanout and the worker's FIFO-later `ChildCancel` can both reach the explicit channel, so a per-call gate invokes each provider `cancel()` at most once; the seam does not require that callback to be idempotent. Explicit child cancel callbacks are contained independently so one throwing callback cannot starve peers or alter settlement. - -Unexpected worker death uses the same terminal-claim rule, but terminal ownership, message admission, and exit cleanup are separate state. The host snapshots whether external cancellation was already accepted, claims either `cancelled` or the death error, closes inbound worker messages, and only then reaps children and synthesizes missing lifecycle events. Closing admission is necessary because Node may emit `error`, deliver an already-queued `message`, and only then emit `exit`; without the logical barrier, that message could start a child or narrate after `workflow/end`. Provider code reentering cancellation during cleanup cannot rewrite a death-first error; conversely, a cancellation accepted before death remains the winner. If Result or grace already claimed the outcome, death preserves it while still reaping promptly. Physical exit then performs a final disposal-only sweep without repeating explicit provider cancellation. `handle.dispose()` claims its public promise before that traversal invokes cancellation or disposal callbacks, and every `disposeChild` path independently claims the call ID promise before invoking the wrapped child disposer. Public-first reentry returns the existing holder promise; worker-first reentry may begin holder disposal, whose child traversal joins the already-claimed call ID promise. This distinction is necessary because grace settlement precedes `worker.terminate()` and its exit event: suppressing a duplicate outcome, late message, or repeated cleanup request must not suppress disposal of survivors in the host registry. - -Together these rules prevent an early result rejection from going unhandled, ensure `workflow/agent-start` never names an unready child, and prevent the bridge from admitting or announcing a child after its workflow has ended. - -Parent teardown reaches `runOwner` by nesting; the provider and returned run handle reach the same node through their explicit disposers. - -### Persona, filtering, and lifetime use ordinary registrations - -A child persona is a scoped `deployment:persona` section that shadows the deployment-wide section. A child tool filter is a scoped restriction over global end capabilities. Omitted filters remain omitted; a materialized empty `allow` list means “allow nothing” and is not confused with absence. - -The child's persona, filter, and structured runtime are installed inside factory setup. Persona and filtering use public registration methods directly. The package-internal structured helper groups the public tool, prompt, protection, guard, and listener registrations that form one terminal protocol: +A child persona is a scoped `deployment:persona` section. Its tool filter is a scoped restriction over the live global tool layer. Structured output is a bundle of scoped tool, prompt, protection, guard, and listener registrations. ```js let structured @@ -1054,30 +873,31 @@ const setup = childCtx => { } ``` -The common run-owner fiber gives structured-concurrency-style teardown without importing the parent's registration layer into the child. The filter affects the child's global tool view; it is not a parent-derived authority ceiling. +The driver creates one run-owner fiber under `parent.ctx` and calls the child factory through it. Parent teardown, `spawn` backend teardown, and manual run disposal reach the same node, but the child still receives a new registration key. Lifetime inheritance therefore does not imply registration inheritance. ### Structured output is a child-owned terminal protocol -A structured child registers a real-schema `structured_output` tool and its instruction through its own context. Concurrent children can use different schemas because each scope resolves its own definition, with no global placeholder, reference count, or remove-for-everyone-else pass. +A structured child registers a real-schema `structured_output` tool and instruction in its own scope. Concurrent children can use different schemas without a global placeholder, reference count, or remove-for-everyone pass. -Presentation mode changes where the model invokes the capture capability, but not which child owns it: +Presentation mode changes the invocation route, not ownership: -| Tool mode | Registry's canonical wire contribution | Generated SDK | Structured-output guarantee | -|---|---|---|---| -| `native` | Visible end-capability schemas, including scoped `structured_output` | None | Protection restores the capture schema and instruction | -| `code` | Reserved `run_code` transport | Visible end-capability bindings, including `structured_output` | Protection keeps `run_code` and the SDK present, keeps native `structured_output` absent from the wire, and restores the instruction | -| `both` | Visible native schemas plus reserved `run_code` | Visible end-capability bindings, including `structured_output` | The model may call the protected capture capability natively or through the protected transport | +| Mode | Advertised invocation route | Canonical wire contribution | Generated SDK | Owner-final guarantee | +|---|---|---|---|---| +| `native` | Native `structured_output` | Visible native schemas, including scoped capture | None | Restore the capture schema and instruction | +| `code` | The `structured_output` SDK binding inside `run_code` | Reserved `run_code`; native capture remains absent | Visible end-capability bindings, including capture | Restore the transport, SDK, native absence, and instruction | +| `both` | Either native capture or its SDK binding | Visible native schemas plus `run_code` | Visible end-capability bindings, including capture | Restore both invocation routes and the instruction | -The table describes the registry's named canonical contribution. An unrelated assembly listener may deliberately add another schema; protection does not erase unrelated names. +Tool mode controls presentation, not an execution allowlist. In code mode, an adapter or direct caller that emits the unadvertised `structured_output` name can still resolve the scoped end capability and takes the native one-stage commit path; a deployment that must forbid that route needs an execution guard. -### Capture uses stage, final commit, monotonic denial, and terminal stop +An unrelated assembly listener may deliberately add another schema; named protection does not erase unrelated contributions. -The capture tool validates its arguments and stages the cloned value in a JavaScript `WeakMap` keyed by the identity-stable `ToolExecution`. This is an object-identity table whose key does not keep an abandoned execution alive. Validation failure becomes the ordinary `INVALID_ARGS` error that the model can correct within the turn. +#### Native commits once; Code Mode commits twice -The scoped `tools/result` observer commits a direct native capture only when that exact execution's authoritative final result succeeds. A later call with a reused string call ID cannot reach the weak-keyed stage, and a post-execution block cannot promote it. +The capture body validates and stages a cloned value by stable `ToolExecution` identity. The scoped final-result observer commits a native capture only if that exact execution's final result succeeds. + +A schema-validation failure becomes the ordinary `INVALID_ARGS` tool result, so the model can correct the value and call the capture tool again within the same turn. ```text -# Native structured-output call structured_output.body(value, execution): validate value against this child's schema staged[execution] = clone(value) @@ -1090,10 +910,9 @@ on tools/result(execution, finalResult): captured = value ``` -For a Code Mode SDK call, successful inner observation records a pending value against the child execution's opaque `parent` token instead of committing immediately. When the enclosing `run_code` reaches its own `tools/result`, the observer compares that pending token with the outer execution's `token` and commits only on success. A program error or outer post-policy block discards the pending value. This extra boundary is necessary because an inner side effect can succeed while the transport that is supposed to deliver the structured answer still fails. +For a Code Mode SDK call, successful inner observation records a pending value against the opaque outer `run_code` token. Commit waits for the outer transport's own successful final result because an inner side effect can succeed while the program or its post-policy still fails. ```text -# Code Mode adds an outer transport commit on tools/result(innerStructuredCall, innerResult): if innerStructuredCall is staged: value = staged.remove(innerStructuredCall) @@ -1108,21 +927,139 @@ on tools/result(outerRunCodeCall, outerResult): captured = value ``` -The native path has one final-result commit; Code Mode has two because the inner capability and outer transport can fail independently. +Once capture is staged against an outer transport or committed, the scoped guard denies later calls in that response. After commit, `agent/turn-stop` ends the turn after ordinary continuation and steering fold. A child that otherwise completes cleanly without a committed capture returns an error rather than being re-prompted; requesting a schema makes output mandatory, not guaranteed. -Once a value is captured or pending on its outer transport, the scoped `ToolGuard` denies later calls in the same response. After a committed capture, the scoped `agent/turn-stop` ends the turn after ordinary continuation and steering have been folded. Together these boundaries prevent post-capture side effects and prevent a successful tool call from purchasing an otherwise automatic extra model step. +### The run protocol separates acceptance, readiness, result, and disposal -The provider does not re-prompt a child that finishes without a committed capture. Such a run returns an error result with no `structured` value; requesting an output schema creates a requirement, not a guarantee that a failed child produces a value. +`SubagentService.start()` returns synchronously, but `run.started` is the publication boundary. Callers treat the child as live only after readiness, consume `result`, and always dispose the run. + +Pre-readiness cancellation of an in-process run deactivates the run-owner fiber, prevents publication, rejects `started`, resolves `result` as `aborted`, and emits neither subagent lifecycle edge. + +`SubagentProvider` registration captures name, capability flags, the `inheritsParentContext` conversation-history descriptor, and the bound start callback once. The descriptor says whether completed parent turns seed the child's conversation; it says nothing about scope, services, tools, or authority. + +Starting a run captures every request field once. Parent and abort signal remain identity references; prompt, filter, schema, and options are detached lossless JSON; fixed `persona` and absolute `maxDepth` values validate before backend ownership. The in-process backend separately snapshots its optional session seed, and the service snapshots the terminal result when it settles. + +Depth validation repeats at each public entry while one helper owns the accepted domain: + +```text +tool-subagent plugin load: + assertSubagentMaxDepth(config.maxDepth) + +SubagentService.start(request): + capture and validate request.maxDepth + +startInProcessRun(request): + capture and validate request.maxDepth + parentDepth = validated depthOf(parent) + childDepth = parentDepth + 1 + reject if childDepth is not a safe integer + reject if maxDepth exists and childDepth > maxDepth +``` + +Only `undefined` means parent depth zero. Present depth and cap values must be non-negative safe integers and must not be negative zero; derived overflow rejects even when no request cap exists. + +The service does not expose the backend-owned run handle directly. It captures `id`, `started`, `result`, and methods once; binds methods to that handle; wraps result in one detached frozen record; and installs a shared disposal promise before calling untrusted backend cleanup. Once a callable backend disposer has been captured, a malformed later field triggers rollback; if no callable disposer exists, rollback is impossible and acceptance fails immediately. A backend disposer that directly returns the wrapper's reentrant promise is rejected as a cycle instead of hanging. + +```text +startInProcessRun(backendContext, acceptedRequest): + install backend ownership + attach accepted abort signal + create run-owner fiber under accepted parent.ctx + create child through runOwner.ctx.agents with unpublished setup + + started = child creation publication + result = after started: + send accepted prompt + await child idle + derive owned terminal result + dispose = dispose run owner and await quiescence + +SubagentService.start(...): + backendRun = backend.start(detached request) + serviceRun = freeze accepted id, readiness, bound methods, normalized result + observe result immediately + after readiness: + emit subagent/start, then buffered/eventual subagent/end + on readiness failure: + emit neither lifecycle edge +``` + +The service observes result settlement immediately even while readiness is pending, preventing an early rejection from becoming temporarily unhandled. Lifecycle listeners receive one frozen payload; their throws and returned-promise rejections are contained independently and cannot veto the run. + +## Workflow integration preserves the subagent contract + +The worker workflow bridge preserves the same readiness, terminal-claim, and bounded-cleanup boundaries across a message port. It never announces an unready child, never lets cleanup rewrite an already chosen result, and never suppresses disposal merely because another terminal fact already won. + +The worker executes the workflow script and exchanges protocol messages; the host owns `SubagentService`, which invokes `SubagentProvider` backends and returns normalized run wrappers that the host retains. Their lifetimes follow dependency shape: an AgentLoop-created agent stops when its loop unloads, while a workflow run captures its holder-bound `SubagentService` at start, so unloading the workflow engine prevents new runs without revoking an already returned run. + +Three state dimensions remain separate: + +| Dimension | Question | Winning rule | +|---|---|---| +| Admission | May a worker message still start or announce a child? | Closed admission refuses the exact run and cleans it up | +| Terminal claim | Which external result does the workflow expose? | Earlier accepted external cancellation wins; otherwise first result/death claim wins | +| Physical cleanup | Which registered children and worker resources remain? | Every path may still dispose survivors through per-call gates | + +### Child admission waits for readiness + +After `SubagentService.start()` returns its normalized wrapper, the host registers that exact wrapper before awaiting, attaches result observers immediately, and rechecks admission both then and when `started` settles. A closed boundary claims cancellation and disposal for that exact entry, removes it only when disposal settles, and reports `ChildStartError` only while the worker reply channel remains open. + +The backend's nested `start()` may synchronously reenter workflow cancellation before the service wrapper reaches the host registry. The immediate post-start check and exact-wrapper identity guard close that interval; a backend that later fulfills its own readiness cannot resurrect workflow admission. + +```text +after subagents.start returns its run wrapper: + register exact wrapper for cancellation + observe and snapshot result immediately + if admission closed: refuse and clean exact wrapper + else await run.started + + on ready: + if admission closed: refuse and clean exact run + else send ChildStarted, then buffered/eventual outcome + + on readiness failure: + send ChildStartError only if the worker reply channel remains open + dispose exact wrapper if still registered +``` + +### Each terminal contender claims before its own callbacks + +Each terminal path records the state it owns before invoking its own callback fanout. External `cancel()` records the accepted cancellation reason before invoking child cancellation. On the Result path, the worker queues its `Result` message before settlement cleanup messages on the same port, and the host records the winning result before any Result-triggered abort or cancellation. Reentry therefore observes the fact that already won instead of rewriting it. + +```text +on workflow Result: + cancellationWasAlreadyAccepted = external cancellation is in flight + claim chosen result: + if earlier external cancellation and result is not cancelled: + cancelled result + else: + worker result + + if not cancellationWasAlreadyAccepted: + abort shared child-request signal + cancel every registered child through its at-most-once gate + settle chosen result +``` + +The worker may also send a later `ChildCancel`; host fanout and the worker message share one per-call cancellation gate, so an arbitrary backend's `cancel()` need not be idempotent. Each callback is contained independently. + +### Worker death, exit, and disposal remain separate + +The first worker death signal closes message admission, claims a death result unless an earlier terminal fact won, cancels and disposes registered children, and synthesizes missing lifecycle ends. A queued message can arrive between Node's `error` and `exit`, so the logical admission barrier—not physical exit—prevents late child creation or narration. + +Physical exit performs a final disposal-only sweep without repeating explicit cancellation. A cancellation grace period bounds how long the host waits for cooperative settlement before terminating the worker; a grace result can already be chosen while exit cleanup still needs to dispose surviving child handles. The bound is real: after grace expires, public disposal may return after invoking child disposal and reaping host resources even if a slow backend disposer has not reached quiescence. + +Public `handle.dispose()` claims its shared promise before invoking cancellation or child callbacks. Each `disposeChild` likewise claims its call-ID promise before invoking the backend disposer. Public-first reentry joins the public promise; worker-first reentry lets the holder traversal join the already claimed child promise. Settled `dispose()` still drives a host-side reap before awaiting quiescence, so a fire-and-forget child cannot remain alive merely because workflow result settlement already occurred. + +Together these rules ensure `workflow/agent-start` names only ready children, external result precedence is stable, and every surviving child reaches disposal. ## Correctness enforcement -Scope mistakes are fail-open if they merely omit a carrier, so the implementation checks the contract at API, type, runtime, and repository-gate boundaries. None of these checks substitutes for using the correct runtime carrier. +The runtime rule is checked at four escape boundaries: API shape couples related subjects, TypeScript marks typed dispatch, development invariants inspect actual dispatch, and repository gates keep declarations aligned with enforcement. -### API shape couples subjects that must agree +### API shape couples values that must agree -`agentEvents(context, agent)` couples the dispatch carrier to the agent argument, `assembleContextFor(agent)` couples prompt facts to the scope selector, and `SessionStore.flush(session)` owns lookup of the carrier captured when the session entered the store. These helpers make a mismatched subject harder to express than the correct spelling. - -Their essential construction makes the coupling explicit: +`agentEvents(context, agent)` couples carrier, subject, and first event argument. `assembleContextFor(agent)` couples prompt facts with scope selection. `SessionStore.flush(session)` owns lookup of the carrier captured when the session entered. ```text assembleContextFor(agent): @@ -1133,99 +1070,108 @@ agentEvents(context, agent): return dispatcher that always injects agent as the event subject ``` +These helpers make a mismatch harder to express than the correct spelling. + ### Type markers cover every scoped event declaration -Scoped agent, approval, tool, prompt, session, and subagent lifecycle events declare a `Scoped` receiver. TypeScript therefore rejects a bare subject at typed dispatch sites, including the `subagent/start` and `subagent/end` paths whose scope is the delegating parent. +Scoped agent, approval, tool, prompt, session, and subagent lifecycle events declare a `Scoped` receiver. TypeScript rejects a bare subject at typed dispatch sites, including subagent lifecycle events scoped to the delegating parent. -The marker is compile-time only. JavaScript callers, casts, and direct use of Cordis's dispatch APIs can bypass it, which is why the runtime checks remain necessary. +The marker is compile-time only; JavaScript, casts, and direct Cordis dispatch can bypass it. -### Development invariants check actual dispatch +### Development invariants inspect actual dispatch -The invariants plugin observes Cordis's internal dispatch path before listener delivery. For each scope-filtered event it requires a marked carrier and, where the event arguments expose the subject, verifies that the carrier key is the same object. +The invariants plugin observes Cordis's internal dispatch before listener delivery. Every scoped event requires a marked carrier, and events whose arguments expose the subject require the carrier key to be the same object. -Session and subagent payloads do not expose the owner key directly, so their invariant proves carrier presence while their service centralizes how the correct key is chosen. Additional invariants reject an assembly whose `agent` and `scope` disagree and a turn opened before `agent/session-start`. +Session and subagent payloads do not expose their owner key directly, so their service centralizes key selection and the invariant proves carrier presence. Additional invariants reject an assembly whose `agent` and `scope` disagree and a turn opened before `agent/session-start`. + +Dedicated `dsh-scope` unit tests cover the carrier's advanced Proxy behavior: private-field method binding, call/construct shape, primordial filter invocation, own-key/descriptor consistency, and explicit configurable definitions. These are implementation tests, not checks performed by the invariants plugin. ### Repository gates keep declarations and dispatchers aligned -`verify-scoped-dispatch` compares the declared scoped events with the runtime invariant table, and the generated event matrix requires every declaration to have a recognized dispatcher. Source JSDoc is regenerated into the [event catalog](../../../cordis-catalog/events.md), keeping the exhaustive signature and mode reference in one place. +`verify-scoped-dispatch` compares declared scoped events with the runtime invariant table, and the generated event matrix requires every declaration to name a recognized dispatcher. Source JSDoc generates the [event catalog](../../../cordis-catalog/events.md), which remains the exhaustive signature and mode reference. ## Alternatives considered -The rejected designs either split visibility from ownership, isolate the wrong boundary, or depend on extension ordering for correctness. +The rejected designs fail one of the four governing questions: they separate visibility from ownership, choose the wrong isolation unit, expose partial lifecycle, leave accepted values mutable, or rely on extension order for invariants. ### Pass an agent option to every registration -An API such as `tools.register(definition, { agent })` leaves global registration as the leak-by-omission default and requires parallel scope plumbing in every registry. It also allows “visible to agent A, disposed with unrelated plugin B,” which the scoped context makes unrepresentable. - -### Create one isolated service graph per agent - -Service isolation chooses one registry instance for a context, while agent composition needs a merged view of deployment-global contributions plus one agent's additions. Per-agent graphs would duplicate shared adapters and force infrastructure such as persistence and UI bridges to discover every new instance. - -Isolation remains appropriate for independent applications. It is too coarse for collaborating agents inside one deployment. - -### Inherit the parent's registrations into a child - -Hierarchical registration inheritance makes lifetime convenient but silently copies every parent-scoped tool and policy into each child. A flat view plus an explicit parent-owned disposer separates lifetime from registration composition: the parent owns the child without importing the parent's layer. As the [security non-goal](#security-and-authority-are-explicit-non-goals) states, flat lookup does not by itself impose a child-within-parent authority relationship. - -### Publish the agent before running setup - -Early publication lets setup resolve the agent from global registries, but observers can see and act on a partially configured world. Rollback can remove entries but cannot retract external effects from already-run listeners. - -The unpublished callback already receives both the agent context and its `ctx.agent` association, so early global lookup is unnecessary. - -### Allow only synchronous setup - -Synchronous setup is simpler but cannot honestly compose a child plugin whose activation is asynchronous. In TypeScript, a callback returning a promise can also be assigned to a void-returning callback type, so declaring setup as synchronous would not reliably prevent accidental escape from the rollback boundary. - -Awaited setup makes the transaction explicit and keeps the first assembly behind it. - -### Enforce invariants with prepended waterfall listeners - -A prepended listener is not necessarily outermost: another plugin can prepend later, a short-circuit can skip inner work, and an outer wrapper can replace the result after delegation. The same issue appears in prompt assembly, tool decisions, result commit, and turn continuation. - -The owner-final APIs express the actual strength required by each rule: restore named canonical data, deny monotonically, observe the immutable final outcome, or stop after all ordinary continuation inputs are folded. +An API such as `tools.register(definition, { agent })` leaves global registration as the leak-by-omission default and repeats scope plumbing in every registry. It can also express “visible to A, disposed with unrelated plugin B,” which `agent.ctx` prevents. ### Filter events while keeping registries global Listener filtering prevents a hook from intercepting the wrong agent but does not scope tool schemas, executable lookup, prompt sections, variables, or Code Mode bindings. Persona, tool filtering, and concurrent structured schemas would still require global mutation. +### Create one isolated service graph per agent + +Service isolation chooses one registry instance, while agent composition needs a merged view of deployment globals plus one agent layer. Per-agent graphs duplicate adapters and force shared persistence and UI infrastructure to discover every instance. + +Independent applications still deserve separate graphs; collaborating agents inside one deployment do not. + +### Inherit the parent's registrations into a child + +Hierarchical registration inheritance silently copies every parent-scoped tool and policy into the child. A flat child layer plus a parent-owned disposer separates lifetime from composition: the parent owns the child without importing its registrations. + +This choice does not create a parent-subset authority guarantee; registration scope and authorization are different designs. + +### Publish the agent before running setup + +Early publication lets setup find the agent in global registries but lets observers act on a partially configured world. Rollback can remove entries but cannot retract external effects from listeners that already ran. + +The unpublished setup callback already receives `agent.ctx` and `ctx.agent`, so early global lookup is unnecessary. + +### Allow only synchronous setup + +Synchronous setup cannot honestly compose child plugins whose activation is asynchronous. TypeScript also permits a promise-returning callback where a void return is expected, so a synchronous-looking type would not reliably contain accidental async work. + +Awaited setup makes the transaction explicit and keeps first publication and prompt assembly behind it. + +### Validate caller data, then clone it + +Validation followed by a separate clone rereads accessors, so it can approve one value and retain another. A generic JSON clone can also erase or coerce exotic prototypes and unsupported values. The lossless-JSON traversal validates and materializes one captured value in the same operation. + +### Enforce invariants with prepended waterfall listeners + +A prepended listener is not permanently outermost: another plugin can prepend later, a short-circuit can skip inner work, and an outer wrapper can replace a downstream result. The same defect appears in prompt assembly, tool decisions, result commit, and turn continuation. + +The four owner-final APIs express the exact one-way power required: restore named canonical data, deny monotonically, observe immutable final outcome, or stop after ordinary continuation folding. + ### Put agent-scope policy inside vendored Cordis -Cordis already provides derived contexts, effect-owning fibers, and receiver-based listener filtering, so the harness-level primitive composes those mechanisms instead of teaching the framework about agents, tools, prompts, or global-plus-scope resolution. The implementation does harden Cordis's domain-neutral lifecycle substrate: effects are owner-visible before setup callbacks, child fibers are parent-owned before publication, and an unloading fiber rejects registrations that missed its cleanup snapshot. Those rules are required by every plugin under reentrant HMR, not scope-specific policy pushed into the framework. +Cordis already supplies derived contexts, effect ownership, and receiver-based filtering. The harness-level primitive composes those domain-neutral mechanisms rather than teaching Cordis about agents, tools, prompts, or global-plus-agent merge rules. + +The lifecycle hardening remains correctly inside Cordis because effect pre-registration, parent ownership before child publication, and rejection of late effects protect every plugin under reentrant hot reload, not only agent scopes. ## Consequences -The design makes per-agent composition ordinary and lifecycle-safe at the cost of a small scope runtime and several deliberately narrow final-policy APIs. The complexity is concentrated in services and dispatch helpers rather than repeated in every plugin. +The design buys one composition model across data, behavior, and lifetime. Its cost is per-scope state, transactional lifecycle machinery, owned runtime snapshots, disciplined dispatch, and four deliberately narrow owner-final APIs. ### Benefits -The main benefit is one composition model across data, behavior, and lifetime: registrations follow their context, while service-owned finalizers protect only the invariants that require stronger ordering. +The main benefit is that plugin authors change context, not API. Registries and dispatchers then apply the same agent key across presentation, execution, observation, and cleanup. -- Plugin authors use the same registration APIs globally and per agent; only the context changes. -- Registry-owned prompt schemas, executable lookup, Code Mode bindings, policy listeners, and UI presentation resolve from the same agent view. -- Create and resume expose no partially configured registry entry during awaited setup, and overlapping caller/factory ownership leaves no gap between resume load, preparation failure, and the live lifecycle. -- Agent disposal revokes scoped contributions after the driver and all final or idle-injection session flushes have settled, and retains both public IDs until scope cleanup is quiescent. -- Structured output composes per child without global mutation or listener-order assumptions. -- Existing unscoped plugins remain deployment-wide contributors and observers. +- Global plugins remain deployment-wide contributors and observers. +- Per-agent tools, prompt state, restrictions, and listeners use ordinary registration methods through `agent.ctx`. +- Model-visible schemas, executable lookup, Code Mode bindings, policy, and UI presentation resolve from one agent view. +- Create and resume expose no partially configured registry entry, while caller and AgentLoop ownership cover every await and rollback path. +- Agent teardown preserves the session and scoped listeners through loop exit and final flush, then releases IDs only after scope quiescence. +- Structured output composes independently per child without global mutation or middleware-order assumptions. ### Costs and constraints -The costs are concentrated in dispatch discipline, per-scope registry state, and owner-final decision boundaries that are intentionally stronger than ordinary middleware. +The costs correspond to the four governing questions rather than one hidden framework abstraction. -- Every scoped event dispatcher must carry the correct receiver; fused helpers, type markers, invariants, and gates exist because omission would otherwise deliver only to global listeners. -- `agent.ctx` is service-bearing. Its available services come from the agent loop's injected context, so holders receive that deliberate dependency surface; this is not confinement. -- Registries maintain per-scope maps and perform a global-plus-one-layer merge for the agent lifetime. -- The dispatch carrier is proxy-shaped and not identity-equal to its subject, even though method calls and property access behave like the subject. Its composed filter is frozen, and defining a property through the carrier requires an explicitly configurable descriptor because the extensible surrogate cannot truthfully expose a new non-configurable subject property. -- Flat scopes do not inherit parent registrations; a desired child-local contribution must be global or explicitly registered for the child. -- `run_code` is protected transport infrastructure rather than a filterable end capability, so a policy that must forbid programs denies execution at the tool-policy layer instead of removing the transport from a Code Mode prompt. -- Prompt protection restores named canonical contributions and their anchor placement, not the entire assembly; unprotected output remains extensible, while a globally protected section name is deliberately unavailable for scoped shadowing. -- Terminal turn stopping can discard pending steering. That control is appropriate for owner-enforced terminal protocols and too strong for ordinary cooperative continuation policy. -- Programmatic `ctx.agents.create()` and `ctx.agents.resume()` are asynchronous because they await setup. The direct no-setup `ctx.agentLoop.create()` path, used by configuration and programmatic callers that already have complete options, remains synchronous. -- A programmatic agent is caller-owned but also structurally owned by its concrete AgentLoop provider. Reloading that provider tears the agent down even if a consumer still holds its handle, because the handle cannot keep the provider's dependency surface valid. -- Ordered composition requires exact raw effect identities plus shared public quiescence promises; the dual surfaces and lifecycle-long owner sentinels reflect distinct Cordis nesting and repeated-caller requirements. +- **Registration and delivery:** registries maintain global and per-scope state; every scoped dispatcher must carry the real subject's key; the carrier is proxy-shaped and not identity-equal to its subject. +- **Lifecycle:** programmatic `create()` and `resume()` are asynchronous; caller sentinels, AgentLoop trackers, reservations, publication barriers, and shared quiescence promises cover construction and teardown races. +- **Boundary ownership:** public values are copied, frozen, bound, or retained by identity at their acceptance boundary; data that the boundary's owned representation cannot preserve fails instead of being coerced. +- **Owner-final policy:** prompt protection can reserve names, guards can only deny, final result observers cannot transform, and terminal stop may discard steering. +- **Flat scope:** a desired child-local contribution must be global or registered explicitly for the child; parent ownership alone does not import registrations. +- **Code Mode:** `run_code` remains protected transport infrastructure, so policy that forbids programs denies execution rather than removing the transport from an SDK-based prompt. + +The direct no-setup `ctx.agentLoop.create()` path remains synchronous for configuration and callers that already have complete options. Programmatic registry create/resume use the full unpublished transaction. ### Deliberate boundaries -The scope primitive is generic, but this decision applies it only where one agent needs a coherent registration view: tools, prompt state, scoped events, sessions, and in-process subagent composition. `agent.ctx` does not automatically scope every service call; filesystem policy, LLM interception, background subagent state, and other registries retain their existing seams until their own designs explicitly adopt the context rule. +The decision applies registration scope to tools, prompt state, scoped events, sessions, approvals, and in-process subagent composition. `agent.ctx` does not automatically scope every service call; filesystem policy, LLM interception, background subagent state, and other registries retain their existing subject or policy seams until their own designs adopt the rule. -Security hardening remains separate design work; the [security and authority non-goals](#security-and-authority-are-explicit-non-goals) define this RFC's trust boundary without turning registration scope into an authorization model. +Security hardening remains separate work. This design does not sandbox same-process plugins, derive child authorization from a parent, freeze a grant set at agent creation, or introduce generic capability/output/termination tags. Those requirements need an explicit authority model rather than additional meaning attached to registration scope. From 7e3d46a3ceb0b0b32a42d2de843d770ed24ec967 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 12 Jul 2026 13:25:04 +0800 Subject: [PATCH 49/64] docs(scope): split contract from runtime design --- docs/core-data-structures/scope.md | 2 +- docs/rfc/INDEX.md | 1 + .../2026-07-08-agent-scope-contexts.md | 1140 +---------------- .../2026-07-12-agent-scope-runtime-design.md | 969 ++++++++++++++ .../feature/2026-07-05-dynamic-workflows.md | 11 +- packages/core/agent/README.md | 2 +- 6 files changed, 1045 insertions(+), 1080 deletions(-) create mode 100644 docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md diff --git a/docs/core-data-structures/scope.md b/docs/core-data-structures/scope.md index d2a2fc47d4..9ccbfcc1fe 100644 --- a/docs/core-data-structures/scope.md +++ b/docs/core-data-structures/scope.md @@ -1,6 +1,6 @@ # Scoped Registration -The [scope package](../../packages/core/scope) supplies the identity and carrier vocabulary that makes one registration context mean both per-agent visibility and shared lifetime ownership. It is a library primitive rather than a Cordis service; the [agent-scope RFC](../rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md) owns the design rationale, while the package [README](../../packages/core/scope/README.md) owns the callable API and filtering semantics. +The [scope package](../../packages/core/scope) supplies the identity and carrier vocabulary that makes one registration context mean both per-agent visibility and shared lifetime ownership. It is a library primitive rather than a Cordis service; the [agent-scope runtime-design RFC](../rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#scope-mechanism-context-key-and-lifetime) owns the implementation rationale, while the package [README](../../packages/core/scope/README.md) owns the callable API and filtering semantics. Source: [`packages/core/scope/src/index.ts`](../../packages/core/scope/src/index.ts). diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 454997ef1f..5e508344fa 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -133,6 +133,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 diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md index 9707d550f2..9f380e6dc1 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -4,41 +4,43 @@ Status: implemented ## Problem -One application needs to share infrastructure across many agents while giving each agent a coherent local world. Model adapters, persistence, user interfaces, and most tool implementations belong to the deployment; personas, visible tools, live policy, and cleanup often belong to one agent. +One application needs to share infrastructure across many agents while giving each agent a coherent local world. Model adapters, persistence, user interfaces, and most tool implementations belong to the deployment; personas, visible tools, live policy, event listeners, and cleanup often belong to one agent. -This is a composition problem, not an application-isolation problem. A separate service graph per agent duplicates too much shared infrastructure, while one global registration graph lets agent-specific contributions leak across agents. +A separate service graph per agent duplicates shared infrastructure. One global registration graph has the opposite failure: an agent-specific tool, prompt section, restriction, or listener can leak into unrelated agents. Contributors need one way to compose local behavior without learning a different registration API for every service. -| Question | Required behavior | Failure without it | -|---|---|---| -| What participates? | Each operation sees deployment-global contributions plus the contributions for its agent | A child-only tool, prompt, or listener affects unrelated agents | -| When does that world exist? | The complete agent world appears only after setup and remains until work and cleanup reach quiescence | Observers see partial setup, or final work loses its scoped policy | -| Which value is authoritative? | Validation, execution, logging, and observation use the same accepted data | Mutable inputs pass one check and produce different behavior later | -| What may extensions override? | Ordinary middleware stays extensible, while a few protocol invariants finish at owner-controlled boundaries | Listener order removes required prompt state, re-allows denied work, commits a failed result, or forces an extra model step | - -In-process subagents expose all four requirements at once. Two concurrent children can request different personas, tool filters, and structured-result schemas; each child must receive its own complete view, publish only after that view exists, preserve the exact accepted request, and keep terminal structured-output rules stronger than unrelated middleware. +The mechanism also needs a clear lifetime. An agent must not become visible before its local registrations exist, and final loop work must not lose those registrations before it settles. Parent-owned subagents make both failures easy to trigger because several differently configured agents can exist concurrently inside one application. ## Decision -Every live agent owns one flat registration layer through `agent.ctx`. Four matching rules make that layer coherent: registration and dispatch select the agent view, lifecycle publishes and revokes the view transactionally, acceptance transfers caller data into owner-controlled records, and four narrow owner-final checkpoints preserve invariants after extensible middleware. +Every live agent owns one flat registration layer through `agent.ctx`. Code registers through the context that owns the contribution; scope-aware services resolve deployment-global registrations plus exactly one matching agent layer; scoped events route by the operation's real agent; and the layer is published and revoked with the agent lifecycle. -| Governing question | Decision | Guarantee | +A Cordis **context** is the object through which code accesses services and registers owned effects. The [Cordis primer](../../../cordis-primer.md) explains the framework beyond that concept. + +The contract has four parts: + +| Contributor question | Contract | +|---|---| +| Where do I register agent-local behavior? | Use the ordinary service API through `agent.ctx` | +| What does an agent see? | Deployment globals plus its own layer, with service-specific merge rules | +| Which scoped listeners run? | By default, unscoped listeners plus listeners for the operation's agent; an explicit global-listener exception is described below | +| How long does local behavior exist? | Assembled during unpublished setup, observable only after creation succeeds, and retained through quiescent teardown | + +The scope is deliberately flat. Resolution never walks parent or sibling scopes. Parent ownership links lifetimes without importing registrations. + +The companion [runtime-design RFC](2026-07-12-agent-scope-runtime-design.md) explains how the implementation preserves this contract under Cordis dispatch, JavaScript mutation and reentrancy, asynchronous setup, rollback, and racing disposal. + +### Registration origin selects visibility and cleanup + +A contribution made through a plain plugin context is deployment-global and is disposed with that plugin. The same method called through `agent.ctx` contributes only to that agent and is disposed with the agent scope. + +| Registration origin | Registration layer and default audience | Disposed with | |---|---|---| -| What participates? | Resolve deployment globals plus exactly one agent layer; route scoped events by the operation's real agent | Data and behavior use the same flat agent view | -| When does it exist? | Treat scope, session, registry entry, and driver as one caller- and agent-factory-owned transaction | Setup is unpublished; teardown drains before revocation | -| Which value is authoritative? | Read caller-owned fields once, validate that capture, and retain only owner-controlled identities or snapshots | Checked, executed, logged, and observed values cannot diverge | -| What may extensions override? | Keep waterfalls for cooperation, then place prompt protection, monotonic guards, final result observation, and terminal turn stopping at service-owned boundaries | Extension ordering cannot undo protocol invariants | +| Plain plugin context | Deployment-global; eligible for every agent view, subject to service merge and restriction rules | Registering plugin | +| `agent.ctx` | Agent-local; visible to that agent by default | Agent scope | -The scope is deliberately flat. An agent resolves deployment-global registrations plus its own registrations; it never traverses parent or sibling scopes. Parent ownership links lifetimes without importing the parent's registration layer. +This applies to tools, prompt sections and variables, restrictions, protections, guards, and scoped event listeners. Named scoped values ordinarily shadow same-named global values; an owning service may reserve a protected name and reject the shadow instead. Duplicate names within one layer fail. Event listeners have the explicit `{ global: true }` audience exception described below. -This is a composition boundary, not an authority boundary. Agent scopes compose trusted in-process registrations; they do not sandbox plugins or define a parent-to-child authority lattice. A plugin holding a Cordis context runs in the same process and can call the services injected into that context. Scope selection answers which registered contribution participates and who cleans it up, not whether a child can do no more than its parent. - -The detailed consequences for tool filters, future global registrations, and child-local tools appear under [the tool-view contract](#the-tool-view-is-live-and-executable). Security hardening requires a separate authority representation and enforcement boundary. - -### Worked example: one agent-local reviewer - -Agent setup uses ordinary registration methods through `agent.ctx`; the context determines visibility and cleanup together. In these focused examples, `ctx` is a plugin service context, `setup(agentCtx)` receives the unpublished agent's scoped context, and helpers such as `AgentId`, `SessionId`, and `CallId` construct opaque IDs. - -Assume the deployment already registered global `read` and `bash` tools. This creates a reviewer whose persona, filtered global tools, and reporting tool exist only for that agent and disappear with its handle: +The public pattern is ordinary registration inside `setup`: ```js const reviewSummaryTool = { @@ -66,204 +68,50 @@ const handle = await ctx.agents.create({ }) const reviewer = handle.agent -ctx.tools.get('read', reviewer) // global tool, visible -ctx.tools.get('bash', reviewer) // undefined: filtered global tool +ctx.tools.get('read', reviewer) // global and allowed +ctx.tools.get('bash', reviewer) // undefined: filtered global ctx.tools.get('review_summary') // undefined: not global -ctx.tools.get('review_summary', reviewer) // reviewer-only definition +ctx.tools.get('review_summary', reviewer) // reviewer-local await handle.dispose() -ctx.tools.get('review_summary', reviewer) // undefined: scope was unwound +ctx.tools.get('review_summary', reviewer) // undefined: scope is gone ``` -The remaining sections descend from this contract. +### The operation selects the view -## Reader model: domain terms and Cordis mechanics +Registration origin and operation subject are separate facts. Calling a read method through `agent.ctx` does not implicitly select that agent; lookup, execution, prompt assembly, and event dispatch still receive the agent or scope they act for. -Readers need four domain terms and four Cordis mechanics to follow the implementation. Readers already familiar with this codebase and Cordis can skim this section. +For example, `agent.ctx.systemPrompt.assemble()` without an assembly scope requests the global view. `ctx.tools.get(name, agent)` and `ctx.tools.execute({ ..., agent })` select that agent's tool view explicitly. This lets one shared service act for any agent without binding the service instance itself to one scope. -### Recurring domain terms +`agent.ctx.agent` is the associated agent for setup code, but it is not a general scope-selection shortcut. Contributors creating nested generic scopes use the nearest scope tag as the registration key; inheriting an `agent` property does not import the outer registration layer. -Four domain terms keep the rest of the RFC compact. A **Session** is an agent run's append-only event log, from which model history and durable replay are derived. **Lossless JSON** is the JSON subset that can be copied without changing meaning: primitives, dense arrays, and plain objects; cycles, sparse arrays, exotic prototypes, non-finite numbers, negative zero, `undefined`, `bigint`, functions, and symbols are rejected. An **end capability** is an actual callable tool implementation, whether the model sees it as a native schema or a Code Mode binding. **Code Mode** gives the model a generated SDK and a reserved `run_code` transport instead of advertising every end capability as a native wire tool. +### Scoped events follow the operation's real subject -### Four Cordis mechanics +By default, an event about agent A reaches unscoped listeners and A-scoped listeners, not B-scoped listeners; agent-less dispatch reaches only unscoped listeners. Product helpers and service-owned paths couple the routing key to the value the operation already owns—such as `ToolExecution.agent`, `ApprovalRequest.agent`, the prompt assembly scope, or the session's captured owner. Advanced code that constructs a low-level carrier or assembly context directly must keep its subject and scope fields aligned; development invariants detect mismatches, but the low-level types do not make every mismatch unrepresentable. -Contexts select service access and registration origin, fibers own effects, waterfalls provide cooperative transformation, and dispatch receivers select listeners. +Cordis listeners have one explicit exception. `{ global: true }` bypasses contextual filtering, so a listener registered through `agent.ctx` can observe other agents and subjectless dispatches while its cleanup still belongs to that agent scope. Use it only for deliberate cross-scope observation. -| Cordis concept | Meaning in this RFC | -|---|---| -| Context | The object through which a plugin reaches services and registers contributions; a derived context can carry a different registration scope | -| Fiber and effect | The runtime owner and one owned piece of setup/cleanup; disposing the fiber unwinds its effects | -| Waterfall | Ordered around-middleware whose listener calls `next()` to include downstream work and may transform or short-circuit the result | -| Dispatch receiver | The `this` object used by Cordis listener filtering; a scope carrier encodes the operation's agent key | +Registry-membership notifications remain unfiltered because they describe shared registry state rather than an operation for one agent. The generated [event catalog](../../../cordis-catalog/events.md) is the exhaustive reference for event signatures and modes. -#### Context selects both service access and registration origin +### Creation publishes after setup; disposal revokes after work stops -A Cordis `Context` is the object through which code calls services such as `ctx.tools`, `ctx.systemPrompt`, and `ctx.sessions`. A service can recover the context through which it was accessed, so the same method can register globally from a plain plugin context or locally from `agent.ctx` without adding an `agent` option to every registration API. Cordis implements contextual service access with a **traced receiver**: a proxy that carries the accessing context while forwarding calls to the concrete service object. +`ctx.agents.create()` and `resume()` construct an unpublished agent. Their optional `setup(agentCtx)` callback may await child-plugin activation and register the complete local world. During setup, neither the agent nor its session is visible in the public registries, and driving methods reject. -```js -ctx.tools.register(globalTool) -agent.ctx.tools.register(agentOnlyTool) +The returned promise resolves only after setup, ordered lifecycle notification, and loop start succeed. Setup failure or owner loss rolls the unpublished world back and releases its IDs. A caller therefore never receives a handle to a partially configured agent. -ctx.on('tools/result', globalObserver) -agent.ctx.on('tools/result', agentObserver) -``` +`AgentHandle.dispose()` performs the reverse boundary. It stops and drains the loop, preserves the session and scoped listeners through final events and flushes, detaches the agent and session, unwinds the scope, and releases IDs. Repeated or racing calls join the same completion promise. -A context also exposes the dependency view injected into the plugin that minted it. `agent.ctx` therefore carries the agent loop's deliberate service surface; it is not an ambient root context or a security boundary. +The calling Cordis context and AgentLoop are structural co-owners. Unloading either disposes the agent, so creation through a short-lived plugin context intentionally gives the agent that shorter lifetime. -#### Effects make cleanup follow ownership +Contributors should put agent-local activation inside `setup` and always dispose the returned handle. Code that needs to observe a live agent waits for `create()`/`resume()` to resolve rather than polling the registries during setup. -An effect is setup whose cleanup belongs to a fiber. Tool registration, prompt contribution, event subscription, and an agent scope are effects, so normal disposal, failure, and hot module reload all follow the same ownership graph. +## Tool restrictions resolve against a live flat view -```js -ctx.effect(() => { - const resource = openResource() - return async () => { - await resource.close() - } -}) -``` +A tool restriction filters the live deployment-global end-capability layer, after which scope-local tools are added. `allow` retains named globals, `deny` removes named globals, multiple restrictions intersect, and a hidden global tool is absent from both registry presentation and executable lookup. -Cordis also supports generator effects that nest child effects in a chosen teardown order. The lifecycle section explains why construction must become owner-visible before arbitrary callbacks run. +Filter presence is explicit: omitting a filter installs no restriction, `restrict({})` rejects, and `allow: []` deliberately hides every global end capability. -#### Waterfalls remain cooperative extension points - -A waterfall listener wraps downstream work. Calling `next()` includes the remaining listeners and base implementation; returning directly skips that downstream portion. - -```js -ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { - const downstream = await next() - return { - ...downstream, - sections: [...downstream.sections, extraSection], - } -}) - -ctx.on('system-prompt/assemble', async () => replacementAssembly) -// The direct return skips this listener's downstream/base. An outer listener -// that already awaited next() still resumes around replacementAssembly. -``` - -This flexibility is intentional for ordinary policy, but it cannot express a fact that must remain true after every wrapper and short-circuit. [Owner-final policy](#owner-final-policy-four-narrow-boundaries) adds only the four final checkpoints that need stronger semantics. - -#### Dispatch receivers select scoped listeners - -Cordis filters listeners using the dispatch receiver, the object visible as `this` inside a function-style listener. `dsh-scope` builds a receiver carrying the operation's scope key, allowing global listeners plus listeners registered for that exact key while rejecting other agents' listeners. - -The receiver is live coordination state, not durable session data. For example, `tools/result` is a live final-outcome notification, while `tool/result` is an append-only session event used for replay and model history. - -## Registration and delivery: global plus exactly one agent layer - -One scope key controls both registered data and registered behavior. Reads combine the deployment-global layer with exactly one agent layer, while scoped event dispatch admits global listeners plus the listeners for that same agent. - -Scope keys are opaque objects compared by identity; a live `Agent` is its own registration key. There is no name-based equality or parent traversal. - -### Scope mechanism: context, key, and lifetime - -The registration context selects the layer, the scope primitive binds that layer to cleanup, and the nearest scope tag—not an inherited convenience property—selects the key. - -#### The calling context selects visibility and cleanup - -A contribution made through a plain plugin context is visible to every agent and disposed with that plugin. A contribution made through `agent.ctx` is visible only to that agent and disposed with its scope. - -| Registration origin | Visible to | Disposed with | -|---|---|---| -| Plain plugin context | Every agent | Registering plugin | -| `agent.ctx` | That agent only | Agent scope | - -The table describes ordinary registrations. Cordis listeners alone have an explicit `{ global: true }` bypass: it suppresses contextual filtering, so a listener registered through `agent.ctx` can receive other agents' and subjectless dispatches while its cleanup still belongs to that agent scope. Cross-scope observation must opt into this bypass deliberately. - -Named scoped contributions shadow same-named global contributions. This is how a child persona replaces `deployment:persona` and how one agent can use a different implementation under the same tool name. Duplicate names within one layer still fail loudly. - -```text -resolveLayer(agentA): - visible = copy(global registrations) - visible.overlay(registrations from agentA.ctx) - return visible -``` - -There is no ancestor loop. Resolving for agent A never reads parent or sibling layers. - -#### The scope primitive keeps layer and owner together - -`dsh-scope` exposes only the operations needed to mint a tagged ownership layer, read its key, target dispatch, and reach quiescent cleanup. A separate `{ scope }` option on each registry could express “visible to A, disposed with B”; the scoped context makes that mismatch unrepresentable. - -| Operation | Responsibility | -|---|---| -| `createScope(context, key)` | Mount an ownership fiber and return its tagged context | -| `scopeOf(context)` | Read the nearest inherited scope key | -| `scopeTarget(subject, key)` | Build the receiver for scope-filtered dispatch | -| `Scope.dispose()` | Return one shared idempotent promise that reaches cleanup quiescence | -| `Scope.rawDispose` | Expose the exact Cordis disposer for ordered generator composition | - -`Scope.dispose()` and `rawDispose` serve different callers. Cordis raw disposers are single-shot, so a repeated raw call need not wait for an earlier asynchronous teardown; the public method follows the backing fiber's in-flight cleanup and gives racing callers the same completion promise. Generator lifecycles use `rawDispose` because Cordis recognizes nested ownership by exact disposer identity. - -The primitive has one essential shape: - -```text -createScope(parentContext, key): - fiber = mount no-op plugin under parentContext - scopedContext = derive fiber.context with nearest-scope-tag = key - - rawDispose = fiber's exact disposer - dispose = memoized operation that: - invoke rawDispose if teardown has not started - follow fiber's in-flight cleanup until quiescent - - return { ctx: scopedContext, rawDispose, dispose } -``` - -Derived contexts inherit the nearest tag. Mounting a plugin under `agent.ctx` preserves the agent scope; deliberately creating another scope replaces the tag below it. - -#### `ctx.agent` is an association; `scopeOf()` selects the layer - -`agent.ctx.agent` gives setup code convenient access to the associated agent, but the nearest scope tag remains authoritative for resolution. A nested scope can inherit the ergonomic `agent` property while replacing the registration key. - -```js -const auditKey = {} -const auditScope = createScope(agent.ctx, auditKey) - -auditScope.ctx.agent === agent // true: inherited association -scopeOf(auditScope.ctx) === auditKey // true: nearest registration key - -await auditScope.dispose() -``` - -This separation keeps the generic scope package independent of the agent package. - -### Resolution contracts preserve domain semantics - -The shared scope selects two layers, but each registry retains its own merge rules and must keep presentation, lookup, and execution coherent within the view it owns. - -#### Registries retain domain-specific merge rules - -The shared primitive answers “which layer?” and “who owns cleanup?”; each service still defines how its values combine. Prompt sections, variables, and tools use scoped-over-global shadowing by name. Tool-schema providers are additive. Tool lookup and execution receive an agent or scope explicitly, while prompt assembly receives an `AssembleContext` whose `scope` selects the layer. - -Calling a read method through `agent.ctx` does not silently choose an agent subject. For example, `agent.ctx.systemPrompt.assemble()` without an assembly scope still requests the global view. Registration origin and operation subject remain explicit, allowing one shared service to act for any agent. - -#### The tool view is live and executable - -Within `ToolRegistry`'s contribution, presentation, lookup, execution, Code Mode bindings, timeouts, inspection, and UI rendering all consume one resolved view. The registry filters the live global layer, overlays scope-local tools, and then adds reserved presentation transport when the configured mode requires it. - -```js -ctx.tools.register(readTool) -ctx.tools.register(bashTool) - -agent.ctx.tools.restrict({ allow: ['read'] }) -agent.ctx.tools.register(reviewSummaryTool) - -ctx.tools.get('read', agent) // visible global definition -ctx.tools.get('bash', agent) // undefined: filtered global definition -ctx.tools.get('review_summary', agent) // visible scope-local definition -ctx.tools.get('review_summary') // undefined: absent globally -``` - -Executing `bash` for this agent follows the same lookup and returns the ordinary unknown-tool error. A hidden global implementation therefore cannot remain callable through a second registry. - -Final prompt assembly remains extensible beyond `ToolRegistry`. A lower-level `systemPrompt.tools()` provider or assembly listener may add an unrelated wire schema; that extension then owns the matching executable behavior and ordering. The one-view guarantee covers the registry-owned schemas, SDK bindings, lookup, execution, and presentation—not arbitrary schemas contributed elsewhere. - -A restriction filters only the global end-capability layer. `allow` keeps named global tools, `deny` removes named global tools, multiple restrictions intersect, and scope-local tools are merged afterward. The filter values are captured when registered, but resolution uses the live global registry: - -Filter presence is explicit: omitting a filter installs no restriction, `restrict({})` rejects as ambiguous, and `allow: []` deliberately hides every global end capability. +Because globals are live, allow- and deny-lists intentionally differ when a new global tool appears: ```text at time 0: @@ -276,902 +124,48 @@ after registering global tool web: allow { read } view = { read } ``` -The flat child relationship follows directly: +Scope-local tools are merged after the filter. A local tool can therefore exist even when it is absent from an allow-list over globals. This is composition behavior, not an authorization promise. -```text -global tools = { read, bash } -parent restriction = allow { read } -parent scoped registrations = { delegate } -child restriction = none -child scoped registrations = { deploy } +Reserved Code Mode presentation is not part of the filterable end-capability layer. The [Code Mode RFC](../feature/2026-06-15-code-mode.md) owns the `run_code`, SDK, `toolOrder`, and presentation-versus-execution contracts; contributors changing Code Mode behavior follow that decision rather than inferring new authority semantics from agent scope. -visible(parent) = { read, delegate } -visible(child) = { read, bash, deploy } -``` +## Security and authority are explicit non-goals -Through `delegate`, the parent can ask the child to perform work with `bash` or `deploy`. This is why registration scope is not an authority ceiling. A deployment that needs parent-to-child non-escalation requires a separate authorization model, including authority representation, propagation, and execution checks. +Agent scopes compose trusted in-process registrations. They do not sandbox plugins, define a parent-to-child authority lattice, freeze a creation-time grant set, or guarantee that a child can do no more than its parent. A plugin holding a Cordis context runs in the same process and can call the services injected into that context. -`run_code` is a reserved presentation transport rather than an end capability. Restrictions cannot remove it, scope-local tools cannot shadow it, and configuration cannot explicitly allow or deny it. In Code Mode the transport remains available while its generated SDK contains only the end capabilities visible to the agent. Without that exception, a filter could leave SDK declarations in the prompt but remove the only invocation path. +A parent can own a child whose visible tool set is wider than its own. For example, a parent restricted to global `read` can spawn a child with no restriction; the child then sees later global tools plus its own local registrations. The parent owns the child's lifetime but does not donate or cap the child's registration layer. -Two similarly named checks use different universes. `ToolRegistry.knownNames()` exposes the pre-restriction end-capability set so a misspelled restriction fails loudly. The system-prompt provider validates `toolOrder` against a mode-specific set: native mode accepts end capabilities, both mode accepts end capabilities plus `run_code`, and code mode accepts only `run_code`. Filtering one agent's view does not turn a valid deployment-wide order into a configuration error. +Deployments that need non-escalation require a separate authority representation, propagation rule, and execution check. Authority-versus-visibility ledgers, parent-subset grants, explicit future-grant APIs, and generic capability/output/termination tags are outside this decision. -### Dispatch contract follows the operation subject +## Subagents use the same composition rule -The operation supplies the scope key, and a carrier composes that key with the subject's existing dispatch behavior. Callers cannot provide an independent routing value that might disagree with the payload. +In-process subagents are a consumer of agent scope, not a second scoping model. A child gets a fresh flat layer during unpublished setup; its persona, tool filter, structured-output protocol, and listeners are ordinary registrations through the child's context. Parent teardown, backend teardown, and manual run disposal own the child lifetime without importing the parent's registrations. -#### The operation subject selects the listener set - -An event about agent A ordinarily reaches unscoped listeners and A-scoped listeners, never B-scoped listeners. An agent-less dispatch admits only unscoped listeners. A listener registered with `{ global: true }` is the deliberate Cordis filtering bypass described above. The operation itself supplies the key; callers do not attach an independent scope that could disagree with the payload. - -| Event family | Scope source | -|---|---| -| `agent/*`, including `agent/turn-stop` | Event's agent | -| `approval/request` | `ApprovalRequest.agent` | -| Tool execution events | `ToolExecution.agent`, or no key for an agent-less call | -| `system-prompt/assemble` | `AssembleContext.scope` | -| Session lifecycle/events | Owner scope captured when the session enters the store | -| `subagent/start`, `subagent/end` | Delegating parent agent | - -Registry-membership events such as `tools/change`, `system-prompt/change`, and `SubagentProvider` added/removed events remain unfiltered because they describe shared registry state rather than one agent operation. - -```js -const seen = [] -ctx.tools.register(readTool) -ctx.on('tools/result', () => seen.push('global')) -agentA.ctx.on('tools/result', () => seen.push('A')) -agentB.ctx.on('tools/result', () => seen.push('B')) - -await ctx.tools.execute({ - callId: CallId('read-1'), - name: 'read', - arguments: {}, - agent: agentA, -}) -seen // ['global', 'A'] -``` - -Fused helpers keep values that must agree together. `agentEvents(context, agent)` uses one agent as the subject, scope key, and first event argument. `assembleContextFor(agent)` sets both prompt facts and the scope selector. The session store captures its carrier when a session enters because later appends and flushes may occur without the original agent context. - -#### The carrier preserves subject behavior - -Function-style listeners receive the carrier as `this`, and agent listeners may call subject methods. The carrier is therefore a proxy that selects listeners while reading, writing, and invoking through the real subject. - -The implementation uses a dedicated surrogate proxy target with an immutable composed-filter slot. It combines the subject context's existing `Context.filter` with the scope predicate instead of replacing it. Methods bind to the real subject; callable carriers preserve call and construct shape; descriptor queries normalize configurable flags as required by Proxy invariants; and definitions through the carrier require an explicitly configurable descriptor. Stable built-in references protect the composed filter from accidental `.call` replacement. - -Those mechanics preserve observable JavaScript behavior, including private-field method identity: - -```js -class Subject { - #count = 0 - increment() { this.#count += 1 } -} - -const subject = new Subject() -new Proxy(subject, {}).increment() // TypeError: proxy lacks Subject's private identity - -const carrier = scopeTarget(subject, subject) -carrier.increment() // works: method is bound to subject -carrier === subject // false: carrier has distinct identity -``` - -Together these constraints keep listener selection correct while preserving the subject behavior listeners expect. - -The TypeScript-only `Scoped` marker requires a carrier at typed dispatch sites. Runtime marks and development invariants cover JavaScript, casts, and direct Cordis dispatch; they detect routing mistakes but do not confine hostile same-process code. - -## Lifecycle: compose privately, publish once, tear down in reverse - -Scope, session, registry entry, and driver form one transaction with two owners. Request fields are captured first; AgentLoop tracking and both identity reservations precede asynchronous work; the caller owns the prepared lifecycle before setup; publication proceeds in synchronous observable phases; and every teardown path reaches one reverse-order quiescence boundary. - -Two services split the public API from the implementation. `AgentRegistry`, reached as `ctx.agents`, stores live agents and is the front door for `create()` and `resume()`. Its registered `AgentFactory` is concretely implemented by `AgentLoop`, which constructs and drives agents using its own injected dependencies. The rest of this section calls that concrete co-owner the **AgentLoop factory**. - -| Phase | Public state | Ownership fact | -|---|---|---| -| Reserve | IDs unavailable to competitors | AgentLoop tracking and exact reservations cover the next await | -| Prepare or load | Persistence data is loading, or session, scope, and driver exist privately | Resume's load sentinel covers persistence; the complete caller lifecycle covers setup | -| Setup | `setup(agent.ctx)` may await and register | Neither ID is published | -| Publish and start | Session, agent, and lifecycle notifications appear in order | Liveness is checked between observable phases | -| Dispose | Driver drains, registries detach, scope unwinds, IDs release | All owner paths join one completion promise | - -The public lifecycle is simple: - -```js -const setupGate = Promise.withResolvers() -const agentId = AgentId('reviewer') -const sessionId = SessionId('reviewer-session') -const creating = ctx.agents.create({ - agentId, - sessionId, - agentOptions: { model: 'model-name' }, - async setup(agentCtx) { - await setupGate.promise - agentCtx.systemPrompt.section({ - name: 'deployment:persona', - order: 0, - text: 'Review the change.', - }) - }, -}) - -ctx.agents.get(agentId) // undefined during setup -ctx.sessions.get(sessionId) // undefined during setup -setupGate.resolve() - -const handle = await creating -ctx.agents.get(agentId) === handle.agent -ctx.sessions.get(sessionId) === handle.agent.session - -await handle.dispose() -ctx.agents.get(agentId) // undefined after quiescent teardown -ctx.sessions.get(sessionId) // undefined after quiescent teardown -``` - -### Reservations precede awaiting; lifecycle ownership precedes setup - -AgentLoop tracking and exact identity reservations precede the first await. Resume adds a caller sentinel across persistence loading; create and resume both establish the complete caller-owned lifecycle before invoking setup. - -#### The prepared lifecycle is owned before setup callbacks - -The caller context owns the work it requested and receives the consumer-facing `AgentHandle`. The AgentLoop factory is a structural co-owner because a live agent continues to depend on its injected services. Either owner can deactivate the transaction; both converge on the same lifecycle disposer. - -| Owner mechanism | Covers | Retires when | -|---|---|---| -| Caller lifecycle sentinel | Caller-fiber loss from lifecycle preparation through live lifecycle | Shared lifecycle reaches quiescence | -| Resume load sentinel | Caller-fiber loss across persistence load and lifecycle handoff | Load rollback or the adopted lifecycle reaches quiescence | -| AgentLoop tracker | AgentLoop unload and structural dependency loss | Transaction and lifecycle settle | -| ID reservations | Competing agent/session insertion | Ordered teardown releases both IDs | - -A **sentinel** is an owner-visible effect that follows work whose final disposer is not yet available. It adopts the exact reservation disposers immediately, then follows the complete lifecycle disposer once preparation establishes it. - -Cordis must make construction owner-visible before setup can reenter teardown. An effect's cleanup wrapper enters its owner list before its setup body runs, a child fiber receives its parent-owned disposer before Cordis's child-plugin notification (`internal/plugin`) announces it, and a fiber already unloading rejects new effects after taking its cleanup snapshot. Teardown observers are contained independently so one callback cannot starve peers or interrupt cleanup. These are domain-neutral lifecycle rules; `dsh-scope` uses them by mounting a no-op plugin fiber as the ownership bucket for one scope. - -#### Caller ownership and factory dependency lookup stay separate - -Factory delegation carries two contexts because ownership and dependency origin are different facts. `ownerCtx` is the caller-bound context whose fiber and optional scope own the requested lifecycle. The factory method receiver is the accepted factory traced through that access so the concrete service retains its own injected dependency view. - -```text -callerCtx.agents.create(options) - ownerCtx = context carrying callerCtx's fiber and scope - factoryThis = concrete accepted factory traced through ownerCtx - Reflect.apply(capturedCreateAgent, factoryThis, [ownerCtx, options]) -``` - -`setFactory()` captures the concrete target and its `createAgent` and `resume` callbacks once. It canonicalizes an already traced service before retracing, avoiding a second proxy layer that would break raw-identity state. Plain factory objects receive the explicit `ownerCtx` without depending on Cordis tracing. - -#### Create and resume reserve identities before awaiting - -Programmatic create and resume reserve both agent and session IDs before any operation can await. Create prepares a new or seeded session; resume loads persisted data while a caller sentinel and AgentLoop load tracker already own the interval in which no `Agent` object exists. - -Reservations are capabilities, not advisory sets. Setup code cannot reserve, prepare, create, register, or enter a substitute under the same IDs. A session reservation prepares at most one exact object, and publication requires the matching factory-held capabilities. A failed or abandoned transaction therefore cannot publish a substitute or wedge an ID indefinitely. - -Resume transfers ownership rather than opening a gap: - -```text -resume(ownerCtx, request): - snapshot request identity, options, and setup callback - reserve agentId and sessionId - install caller sentinel adopting both reservation disposers - track load under AgentLoop - - persisted = await firstOf(persistence.load(sessionId), deactivated) - session = sessionReservation.prepare(reconstruct persisted data) - starting = startOwned(ownerCtx, session, reservations, setup) - caller sentinel follows starting.dispose - return await starting.result -``` - -If deactivation wins, a backend load may still settle internally but has no path back to publication. Preparation failure still returns a rollback-backed lifecycle result, so both owners can wait for actual cleanup instead of mistaking a rejected async result for successful installation. - -### Setup composes an unpublished world - -`setup(agentCtx)` may register tools, prompt state, restrictions, listeners, protections, or child plugins and may await their activation. The new agent is available as `agentCtx.agent`, but neither agent nor session is visible in its global registry. - -The complete rollback skeleton exists before setup runs. If setup throws, rejects, or loses either owner, the scope and prepared resources unwind and the IDs become reusable. After setup settles, a microtask checkpoint and liveness checks let a same-turn owner unload win before publication. - -Setup composes but cannot drive. `send`, `steer`, `inject`, and `cancel` reject until publication reaches the session-start boundary. The driver lock and inbox use runtime-private state, and only factory-held controls enable and start the loop; JavaScript casts cannot call a public start method or write directly into the queue. - -```text -startOwned(ownerCtx, snapshot, preparedSession): - world = prepareLifecycleWithCompleteRollback(ownerCtx, snapshot, preparedSession) - - result = async: - require world active - await firstOf(snapshot.setup(world.agent.ctx), world.deactivated) - await oneMicrotask() - require caller, factory, owner fiber, and owner agent still active - world.publish(snapshot.source) - return handle(world.agent, world.dispose) - - on any error: - await world.dispose() - rethrow -``` - -### Publication is ordered, observable, and rollback-covered - -Publication is one synchronous sequence with liveness checks between three observable notification phases. Both registry entries exist before the first listener runs, but driving stays locked until immediately before `agent/session-start`. - -1. Enter the session store and capture its scope carrier. -2. Enter the agent registry without announcing it. -3. Recheck caller and factory liveness. -4. Emit `session/created`. -5. Recheck liveness. -6. Emit `agent/created`. -7. Recheck liveness. -8. Enable driving. -9. Emit `agent/session-start`. -10. Recheck liveness. -11. Start the driver. - -```text -publish(world): - world.beginSynchronousPublication() - try: - world.detachSession = sessions.enter(world.session, sessionReservation) - world.detachAgent = agents.enter(world.agent, agentReservation) - require callerAndFactoryActive - sessions.announce(world.session) - require callerAndFactoryActive - agents.announce(world.agent) - require callerAndFactoryActive - world.driver.enableDrivingVerbs() - emitNonVetoing(agent/session-start) - require callerAndFactoryActive - world.driver.start() - finally: - world.endSynchronousPublication() -``` - -#### Creation is paired, not atomic - -Observers run between publication steps, so the sequence is not described as atomic. Effects already performed by an earlier listener cannot be retracted if a later listener throws. Instead, each registry marks a creation announcement as begun before dispatch and emits exactly one matching disposal edge during rollback. An entered object that was never announced has no disposal notification because no observer was told it existed. - -A detach requested during `session/created` or `agent/created` is deferred until that dispatch unwinds. Stable captured carriers and exact-object guards prevent a later listener from observing `disposed` before `created` or a stale detach from deleting a replacement with the same ID. The outer publication barrier likewise prevents caller or AgentLoop teardown from removing the other registry entry or unwinding `agent.ctx` while an announcement remains on the stack. - -Creation listener synchronous throws remain vetoes. Returned promise rejections are observed and logged but not awaited: publication has no asynchronous gap in which such a result could roll back safely. Disposal notifications and `agent/session-start` are non-vetoing and independently contain both synchronous throws and returned-promise rejections so one listener cannot block cleanup or later observers. - -### Teardown stops work before revoking registrations - -Every owner path reaches one memoized reverse-order transaction. It marks the lifecycle inactive, waits for an in-progress synchronous publication phase, stops the driver through actual exit and final durability work, detaches the agent and session, unwinds the scope, and releases IDs last. - -Final turn events, the turn-ending flush, and any outstanding session flush started while the agent was idle therefore run while the session and scoped listeners still exist. `agent/disposed` observes an already quiescent and unregistered concrete agent while its session remains live; `session/disposed` follows after event feed detachment and store removal. Both use the stable carrier captured for their matching creation edge. - -```text -disposeOwnedAgent(world): - mark world inactive - await world.synchronousPublicationIfRunning() - await world.stopDriver() # loop exit plus agent-started flushes - world.detachAgent() - world.detachSession() - await world.scope.dispose() - world.releaseSessionReservation() - world.releaseAgentReservation() -``` - -`AgentHandle.dispose()` gives repeated and racing consumers the same completion promise. The lifecycle-long caller sentinel follows that promise even when handle disposal wins first, while the AgentLoop ledger independently stops new transactions and waits for every structurally dependent agent before the service disappears. - -AgentLoop co-ownership follows dependency shape, not a blanket “creator owns every returned value” rule. An AgentLoop-created agent continues to depend on the loop's services, so AgentLoop unload stops it. - -## Boundary ownership: accept once and own the accepted value - -Acceptance-sensitive boundaries that cross asynchronous, reentrant, model-visible, or durable-log code read caller-owned fields once and retain only owner-controlled identities or snapshots. This rule is independent of TypeScript: `readonly` annotations vanish at runtime, and JavaScript accessors can return a different value on every read. - -The shared shape distinguishes identity-bearing references from data. Agent objects and abort signals are retained by identity after one read. Boundaries whose contract requires lossless JSON—such as session events and subagent payloads—validate and materialize it in one traversal; other boundaries use their own owned representation, such as `structuredClone` for agent options. Scalars and callbacks are captured once, then each boundary applies the validation promised by its API before downstream use. - -```text -accept(input): - read every relevant top-level field exactly once - retain identity-bearing references without rereading them - validate acceptance-time fields from those captures - copy or pin data in the representation owned by this boundary - bind accepted callbacks once when method receiver state is intentional - expose only owner-controlled identities, frozen records, or detached results -``` - -Capture does not imply uniform eager callback type-checking. Agent `setup` is captured once and any invocation failure enters rollback; a tool guard is likewise captured, and an invalid cast becomes a normalized execution error. The invariant is that later work never rereads caller fields to choose a different value. - -| Boundary | Identity retained | Data detached or pinned | -|---|---|---| -| Tool and `SubagentProvider` registration | Original callback receiver | Name, flags, schemas, scalar config | -| Agent create/resume | Caller context, setup callback | IDs, options, session metadata and seed | -| Approval request | Agent and abort signal | Tool name, call ID, and reason | -| Tool execution | Agent, signal, registry-minted parent token | Call identity and arguments | -| Session append/load | Session identity | Header and event envelopes | -| Subagent start/result | Parent and signal | Prompt, filters, schema, options, result | - -Before agent setup can run, the concrete agent pins its accepted ID, options, and session and binds `ctx` once. Registry detach closures likewise close over their accepted keys instead of rereading mutable public fields. - -A stateful getter shows why validation and ownership must use the same capture: - -```js -let reads = 0 -const input = { - get name() { - reads += 1 - return reads === 1 ? 'safe_tool' : 'different_tool' - }, -} - -// Wrong: validation and storage observe different values. -validateName(input.name) -storeName(input.name) - -// Right: one accepted value drives both. -reads = 0 -const acceptedName = input.name -validateName(acceptedName) -storeName(acceptedName) -``` - -### Registered definitions are frozen snapshots - -Tool registration creates the stored definition identity once; changes occur through explicit unregister/register effects rather than mutation of a caller-retained object. Parameters are materialized in one traversal, callbacks bind once to the accepted definition receiver, and the stored record is deep-frozen. - -The first-party `defineTool()` helper applies the same boundary before registration. It captures each option once, materializes the authoring `SchemaSpec`, and derives both the wire schema and later execution/presentation validation from that owned spec. - -```text -defineTool(options): - accepted = read each option exactly once - parameterSpec = snapshotLosslessJson(accepted.parameters) - wireSchema = snapshotLosslessJson(convertToJsonSchema(parameterSpec)) - build execute and presentation validation over parameterSpec - -registerTool(context, definition): - accepted = read each definition field exactly once - stored = deepFreeze({ - accepted name, description, timeout, - parameters: snapshotLosslessJson(accepted.parameters), - execute: bind accepted.execute to definition, - presentation callbacks: bind accepted callbacks when present - }) - layerFor(scopeOf(context)).add(stored.name, stored) -``` - -`get()` and `visible()` return the frozen stored definitions; `schemas()` returns detached projections. Replacing `definition.execute` after registration has no effect, while a callback can deliberately read live state from its closure or original receiver. - -Factory and backend registration use different reentrancy orderings around the same ownership rule. `AgentFactory` registration claims its single slot before reading callback accessors. `SubagentProvider` registration first snapshots the provider fields, then its effect checks and enters the accepted name. Both capture callback identity and intentional receiver state once, and hot-reload cleanup closes over the accepted slot or key instead of rereading a mutable public property. - -### Durable session data belongs to the session - -The session pins its ID and detached, deep-frozen header. Seed and append paths materialize lossless JSON once, validate the event envelope and message-history metadata against that owned record, and deep-freeze the exact accepted event. `session.events` returns a frozen snapshot that never grows later. - -The store keeps append observers, accepted registry IDs, and scope carriers in private owner state rather than caller-writable fields. Outside JavaScript therefore cannot rename a stored session, redirect `session/event`, or mutate an earlier snapshot into newer history. - -Approval requests follow the same async boundary at smaller scale: one capture preserves exact agent/signal identities, copies scalar fields, captures the session once, and drives `approval/asked`, scoped policy, cancellation, and `approval/decided` from that record. - -### Tool execution has pipeline-owned identity - -`ctx.tools.execute(input)` turns caller-owned input into one pipeline-owned `ToolExecution`. It first reads `callId` and `name` once and requires strings; a failure there rejects because even an error result would lack trustworthy correlation identity. Once those strings are accepted, later input failures can become normal final error outcomes. - -Arguments are materialized once and deep-frozen. The registry assigns a frozen property-free `ToolExecutionToken`; callers cannot choose it. `token`, `callId`, `name`, `arguments`, `agent`, and optional opaque `parent` token become non-writable and non-configurable before policy. `signal` is the only operational field an around-dispatch wrapper may replace or remove. - -```text -prepareExecution(input): - callId = read input.callId exactly once - name = read input.name exactly once - require both are strings - - accepted = read arguments, agent, parent, and signal exactly once - require parent is absent or a registry-minted token - arguments = deepFreeze(snapshotLosslessJson(accepted.arguments)) - - execution = { - token: new frozen property-free object, - callId, name, arguments, - agent: accepted.agent, - parent: accepted.parent, - signal: accepted.signal - } - protect every field except signal - return execution -``` - -Stable execution identity prevents middleware from changing which tool or scope policy accepted. It also gives structured-output commit a safe `WeakMap` key when an adapter reuses a string call ID. Code Mode correlates an SDK sub-call with its enclosing `run_code` using only the outer execution's opaque token, never a mutable reference to the live outer object. - -Result boundaries apply the same ownership rule. Each transform returns data that is captured field-by-field, validated, materialized, and ultimately deep-frozen for final observers; malformed outcomes normalize to JSON-safe error results rather than reaching the session log as apparent success. - -## Owner-final policy: four narrow boundaries - -Waterfalls remain the ordinary extension mechanism; each of four protocol invariants runs after the last extension point capable of violating that specific invariant. Each owner-final API has the weakest one-way power that can preserve its guarantee. - -Here **canonical** means the named registry or tool-schema-provider output assembled before the waterfall—not “all output the service approves.” Protection restores only the names its owner declares. - -| Invariant | Cooperative extension point | Owner-final boundary | Guarantee | -|---|---|---|---| -| Named prompt/tool contribution | `system-prompt/assemble` waterfall | `systemPrompt.protect()` finalization | Canonical presence, absence, definition, and local anchor survive | -| Non-overridable tool denial | `tools/pre-execute` allow/deny/ask waterfall | Synchronous `tools.guard()` | A denial cannot become allow | -| Authoritative live outcome | Execute and post-execute waterfalls | Awaited `tools/result` notification | Observers receive one immutable final result | -| Terminal protocol completion | Continuation waterfall and pending steering | Serial `agent/turn-stop` | No middleware or late steering creates another step | - -### Prompt protection restores named canonical contributions - -`systemPrompt.protect({ sections, tools })` snapshots the requested names and restores their canonical registry or tool-schema-provider output after the complete assembly waterfall. Global and matching scoped protections compose by set union; a waterfall failure still fails assembly rather than triggering recovery. - -Protection covers both presence and absence. If the canonical assembly omits a protected name, finalization removes a listener-fabricated entry; this is how Code Mode keeps a native schema absent while preserving the SDK/transport form. Tool providers likewise expose one captured coherent record for schemas and optional known names, so a stateful getter cannot validate one name and display another. - -#### Global section protection reserves its name - -A globally protected section name cannot be shadowed by a scoped section. Scoped registration under an already protected name fails, and adding protection fails if a scoped shadow already exists. This check occurs before assembly because scoped-over-global merge would otherwise make the shadow itself appear canonical. - -Tool-schema protection does not create a blanket reservation for unrelated schema names. Providers are additive and may deliberately contribute other executable schemas; the owner-final guarantee covers only the named canonical contribution. - -#### Restoration preserves a useful local anchor - -Protection does not reset the whole assembly. It removes protected names from the waterfall result and reinserts each canonical entry before the first surviving later unprotected canonical neighbor, or at the end if none survives. Unprotected entries retain the order and definitions chosen by middleware. - -```text -assemble(context): - assembly = assemble registries for context.scope - canonical = snapshot protected section/tool inputs - transformed = await systemPromptAssembleWaterfall(assembly) - - for each protected canonical name: - remove every transformed entry with that name - if canonical includes the name: - insert before first surviving later canonical neighbor, else append - - return transformed -``` - -Code Mode globally protects `tools:sdk` and reserved `run_code`; structured output adds scoped protection for its instruction and capture schema. - -### Tool guards deny monotonically - -`ctx.tools.guard()` installs a global or scoped synchronous check after the complete `tools/pre-execute` waterfall and before dispatch. A guard returns a denial reason or `undefined`; it has no allow result. - -Pre-execute hooks still compose ordinary allow, deny, and ask decisions. An ask resolves through the optional approval service, where only `allowed-once` becomes allow and absence or any non-grant becomes deny. Guards run afterward, so listener order cannot convert their denial into dispatched work. - -```js -agent.ctx.on( - 'tools/pre-execute', - async () => ({ kind: 'allow' }), - { prepend: true }, -) - -agent.ctx.tools.guard(execution => - execution.name === 'bash' - ? 'reviewer agents are read-only' - : undefined, -) -``` - -Even a later prepended allow listener cannot bypass the guard. A denied call still becomes an error outcome that flows through result transformation and final observation. - -### `tools/result` observes the final live outcome - -The live pipeline is `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute` → `tools/result`. The first, execute, and post stages are transformable waterfalls; `tools/result` is an awaited observe-only notification after every transform and outer error normalization. - -Every observer receives the same frozen execution and a separate deep-frozen snapshot of the owned result returned to the caller. Listener failures are contained independently, so they cannot change that returned result or starve peers. Routing uses `execution.agent`. - -`tools/result` is not the durable `tool/result` session event. The live notification also fires for direct programmatic executions and is the source of truth for in-process commit logic. The agent loop later appends the durable event for replay, UI reconstruction, and model history. - -```text -execute(input): - accept trustworthy callId and name - try to prepare pipeline-owned execution - on preparation failure: - create an identity-bearing error shell - ownedResult = owned error result - freeze execution - observerResult = deepFreeze(snapshotLosslessJson(ownedResult)) - await every tools/result observer independently with observerResult - return ownedResult - - gate = await tools/pre-execute(execution) - resolve ask through approval when needed - denial = policy denial or first guard denial - - if denied: - result = errorResult(denial) - else: - result = await tools/execute(execution, dispatchRegisteredTool) - - result = await tools/post-execute(execution, result) - ownedResult = normalize into owned lossless JSON - freeze execution - observerResult = deepFreeze(snapshotLosslessJson(ownedResult)) - await every tools/result observer independently with observerResult - return ownedResult -``` - -Waterfalls transform only at their named stages; guards only deny; final observers only observe. - -### `agent/turn-stop` makes continuation terminal - -Steering is input for another model step inside the current turn; queued prompts wait for a future turn. Ordinary continuation remains extensible: the loop computes a default, runs `agent/turn-continuation`, records any force-continue reason as steering, and treats pending steering as a reason to continue. - -The scoped serial `agent/turn-stop` checkpoint runs after that folding. A listener returns `{ action: 'stop' }` or abstains with `undefined`; malformed values and throws close the current turn with an error. A stop is terminal, so later listeners and steering cannot restore continuation. - -The loop uses `strictSerial` because ordinary Cordis serial dispatch treats `null` and `false` as abstentions. This terminal protocol permits only `undefined` to abstain, making accidental return values fail closed. - -Terminal state remains active through `turn/end` and the durability flush. Steering added by continuation, turn-close, or flush listeners is discarded after a terminal stop, while the ordinary queued-prompt FIFO remains untouched. - -```text -afterSuccessfulStep(turn): - decision = await agent/turn-continuation(defaultDecision) - record decision.reason as steering when present - if steering is pending: decision = continue - - terminal = await strictSerial(agent/turn-stop) - if terminal == stop: - discard steering - terminalStopped = true - decision = stop - - append turn/end - await session/flush - - if terminalStopped: - discard steering added by turn/end or flush listeners - else: - move leftover steering to the next-turn queue -``` - -This stronger control is reserved for terminal protocols such as a completed structured child; ordinary continuation policy remains cooperative. - -## Subagents: the composition proof - -In-process subagents add no second scoping model. They create a fresh flat child scope during unpublished setup, install ordinary scoped persona/filter/protocol registrations, own the child through a run handle, and use the same owner-final checkpoints for structured output. - -The roles and phases are explicit: - -| Role | Responsibility | -|---|---| -| Caller | Supplies parent, prompt, optional child configuration, and eventual disposal | -| `SubagentService` | Validates capabilities, owns the public wrapper, normalizes result and lifecycle telemetry | -| `SubagentProvider` backend | Chooses transport and creates one run | -| In-process driver | Owns child creation, setup, prompt drive, result read, cancellation, and teardown | -| Child `Agent` | Uses the ordinary agent lifecycle and its fresh `agent.ctx` | - -```text -accepted start -> started (published) -> result (settled) -> dispose (quiescent) -``` - -Assume the in-process `spawn` backend uses its default name, `parent` is top-level, and global `read` exists: - -```js -const run = ctx.subagents.start('spawn', { - parent, - prompt: [{ type: 'text', text: 'Review this change.' }], - persona: 'You are a careful code reviewer.', - toolFilter: { allow: ['read'] }, - maxDepth: 2, - outputSchema: { - type: 'object', - properties: { summary: { type: 'string' } }, - required: ['summary'], - additionalProperties: false, - }, -}) - -try { - await run.started - const result = await run.result - // result.structured exists only after successful final commit. -} finally { - await run.dispose() -} -``` - -### The child world uses ordinary registrations - -A child persona is a scoped `deployment:persona` section. Its tool filter is a scoped restriction over the live global tool layer. Structured output is a bundle of scoped tool, prompt, protection, guard, and listener registrations. - -```js -let structured -const setup = childCtx => { - if (persona !== undefined) { - childCtx.systemPrompt.section({ - name: 'deployment:persona', - order: 0, - text: persona, - }) - } - if (toolFilter !== undefined) childCtx.tools.restrict(toolFilter) - if (schema !== undefined) { - structured = attachStructuredRuntime(childCtx, schema) - } -} -``` - -The driver creates one run-owner fiber under `parent.ctx` and calls the child factory through it. Parent teardown, `spawn` backend teardown, and manual run disposal reach the same node, but the child still receives a new registration key. Lifetime inheritance therefore does not imply registration inheritance. - -### Structured output is a child-owned terminal protocol - -A structured child registers a real-schema `structured_output` tool and instruction in its own scope. Concurrent children can use different schemas without a global placeholder, reference count, or remove-for-everyone pass. - -Presentation mode changes the invocation route, not ownership: - -| Mode | Advertised invocation route | Canonical wire contribution | Generated SDK | Owner-final guarantee | -|---|---|---|---|---| -| `native` | Native `structured_output` | Visible native schemas, including scoped capture | None | Restore the capture schema and instruction | -| `code` | The `structured_output` SDK binding inside `run_code` | Reserved `run_code`; native capture remains absent | Visible end-capability bindings, including capture | Restore the transport, SDK, native absence, and instruction | -| `both` | Either native capture or its SDK binding | Visible native schemas plus `run_code` | Visible end-capability bindings, including capture | Restore both invocation routes and the instruction | - -Tool mode controls presentation, not an execution allowlist. In code mode, an adapter or direct caller that emits the unadvertised `structured_output` name can still resolve the scoped end capability and takes the native one-stage commit path; a deployment that must forbid that route needs an execution guard. - -An unrelated assembly listener may deliberately add another schema; named protection does not erase unrelated contributions. - -#### Native commits once; Code Mode commits twice - -The capture body validates and stages a cloned value by stable `ToolExecution` identity. The scoped final-result observer commits a native capture only if that exact execution's final result succeeds. - -A schema-validation failure becomes the ordinary `INVALID_ARGS` tool result, so the model can correct the value and call the capture tool again within the same turn. - -```text -structured_output.body(value, execution): - validate value against this child's schema - staged[execution] = clone(value) - return ordinary success - -on tools/result(execution, finalResult): - if execution is staged: - value = staged.remove(execution) - if finalResult succeeded: - captured = value -``` - -For a Code Mode SDK call, successful inner observation records a pending value against the opaque outer `run_code` token. Commit waits for the outer transport's own successful final result because an inner side effect can succeed while the program or its post-policy still fails. - -```text -on tools/result(innerStructuredCall, innerResult): - if innerStructuredCall is staged: - value = staged.remove(innerStructuredCall) - if innerResult succeeded: - pending = { outerToken: innerStructuredCall.parent, value } - -on tools/result(outerRunCodeCall, outerResult): - if pending.outerToken == outerRunCodeCall.token: - value = pending.value - pending = none - if outerResult succeeded: - captured = value -``` - -Once capture is staged against an outer transport or committed, the scoped guard denies later calls in that response. After commit, `agent/turn-stop` ends the turn after ordinary continuation and steering fold. A child that otherwise completes cleanly without a committed capture returns an error rather than being re-prompted; requesting a schema makes output mandatory, not guaranteed. - -### The run protocol separates acceptance, readiness, result, and disposal - -`SubagentService.start()` returns synchronously, but `run.started` is the publication boundary. Callers treat the child as live only after readiness, consume `result`, and always dispose the run. - -Pre-readiness cancellation of an in-process run deactivates the run-owner fiber, prevents publication, rejects `started`, resolves `result` as `aborted`, and emits neither subagent lifecycle edge. - -`SubagentProvider` registration captures name, capability flags, the `inheritsParentContext` conversation-history descriptor, and the bound start callback once. The descriptor says whether completed parent turns seed the child's conversation; it says nothing about scope, services, tools, or authority. - -Starting a run captures every request field once. Parent and abort signal remain identity references; prompt, filter, schema, and options are detached lossless JSON; fixed `persona` and absolute `maxDepth` values validate before backend ownership. The in-process backend separately snapshots its optional session seed, and the service snapshots the terminal result when it settles. - -Depth validation repeats at each public entry while one helper owns the accepted domain: - -```text -tool-subagent plugin load: - assertSubagentMaxDepth(config.maxDepth) - -SubagentService.start(request): - capture and validate request.maxDepth - -startInProcessRun(request): - capture and validate request.maxDepth - parentDepth = validated depthOf(parent) - childDepth = parentDepth + 1 - reject if childDepth is not a safe integer - reject if maxDepth exists and childDepth > maxDepth -``` - -Only `undefined` means parent depth zero. Present depth and cap values must be non-negative safe integers and must not be negative zero; derived overflow rejects even when no request cap exists. - -The service does not expose the backend-owned run handle directly. It captures `id`, `started`, `result`, and methods once; binds methods to that handle; wraps result in one detached frozen record; and installs a shared disposal promise before calling untrusted backend cleanup. Once a callable backend disposer has been captured, a malformed later field triggers rollback; if no callable disposer exists, rollback is impossible and acceptance fails immediately. A backend disposer that directly returns the wrapper's reentrant promise is rejected as a cycle instead of hanging. - -```text -startInProcessRun(backendContext, acceptedRequest): - install backend ownership - attach accepted abort signal - create run-owner fiber under accepted parent.ctx - create child through runOwner.ctx.agents with unpublished setup - - started = child creation publication - result = after started: - send accepted prompt - await child idle - derive owned terminal result - dispose = dispose run owner and await quiescence - -SubagentService.start(...): - backendRun = backend.start(detached request) - serviceRun = freeze accepted id, readiness, bound methods, normalized result - observe result immediately - after readiness: - emit subagent/start, then buffered/eventual subagent/end - on readiness failure: - emit neither lifecycle edge -``` - -The service observes result settlement immediately even while readiness is pending, preventing an early rejection from becoming temporarily unhandled. Lifecycle listeners receive one frozen payload; their throws and returned-promise rejections are contained independently and cannot veto the run. - -## Workflow integration preserves the subagent contract - -The worker workflow bridge preserves the same readiness, terminal-claim, and bounded-cleanup boundaries across a message port. It never announces an unready child, never lets cleanup rewrite an already chosen result, and never suppresses disposal merely because another terminal fact already won. - -The worker executes the workflow script and exchanges protocol messages; the host owns `SubagentService`, which invokes `SubagentProvider` backends and returns normalized run wrappers that the host retains. Their lifetimes follow dependency shape: an AgentLoop-created agent stops when its loop unloads, while a workflow run captures its holder-bound `SubagentService` at start, so unloading the workflow engine prevents new runs without revoking an already returned run. - -Three state dimensions remain separate: - -| Dimension | Question | Winning rule | -|---|---|---| -| Admission | May a worker message still start or announce a child? | Closed admission refuses the exact run and cleans it up | -| Terminal claim | Which external result does the workflow expose? | Earlier accepted external cancellation wins; otherwise first result/death claim wins | -| Physical cleanup | Which registered children and worker resources remain? | Every path may still dispose survivors through per-call gates | - -### Child admission waits for readiness - -After `SubagentService.start()` returns its normalized wrapper, the host registers that exact wrapper before awaiting, attaches result observers immediately, and rechecks admission both then and when `started` settles. A closed boundary claims cancellation and disposal for that exact entry, removes it only when disposal settles, and reports `ChildStartError` only while the worker reply channel remains open. - -The backend's nested `start()` may synchronously reenter workflow cancellation before the service wrapper reaches the host registry. The immediate post-start check and exact-wrapper identity guard close that interval; a backend that later fulfills its own readiness cannot resurrect workflow admission. - -```text -after subagents.start returns its run wrapper: - register exact wrapper for cancellation - observe and snapshot result immediately - if admission closed: refuse and clean exact wrapper - else await run.started - - on ready: - if admission closed: refuse and clean exact run - else send ChildStarted, then buffered/eventual outcome - - on readiness failure: - send ChildStartError only if the worker reply channel remains open - dispose exact wrapper if still registered -``` - -### Each terminal contender claims before its own callbacks - -Each terminal path records the state it owns before invoking its own callback fanout. External `cancel()` records the accepted cancellation reason before invoking child cancellation. On the Result path, the worker queues its `Result` message before settlement cleanup messages on the same port, and the host records the winning result before any Result-triggered abort or cancellation. Reentry therefore observes the fact that already won instead of rewriting it. - -```text -on workflow Result: - cancellationWasAlreadyAccepted = external cancellation is in flight - claim chosen result: - if earlier external cancellation and result is not cancelled: - cancelled result - else: - worker result - - if not cancellationWasAlreadyAccepted: - abort shared child-request signal - cancel every registered child through its at-most-once gate - settle chosen result -``` - -The worker may also send a later `ChildCancel`; host fanout and the worker message share one per-call cancellation gate, so an arbitrary backend's `cancel()` need not be idempotent. Each callback is contained independently. - -### Worker death, exit, and disposal remain separate - -The first worker death signal closes message admission, claims a death result unless an earlier terminal fact won, cancels and disposes registered children, and synthesizes missing lifecycle ends. A queued message can arrive between Node's `error` and `exit`, so the logical admission barrier—not physical exit—prevents late child creation or narration. - -Physical exit performs a final disposal-only sweep without repeating explicit cancellation. A cancellation grace period bounds how long the host waits for cooperative settlement before terminating the worker; a grace result can already be chosen while exit cleanup still needs to dispose surviving child handles. The bound is real: after grace expires, public disposal may return after invoking child disposal and reaping host resources even if a slow backend disposer has not reached quiescence. - -Public `handle.dispose()` claims its shared promise before invoking cancellation or child callbacks. Each `disposeChild` likewise claims its call-ID promise before invoking the backend disposer. Public-first reentry joins the public promise; worker-first reentry lets the holder traversal join the already claimed child promise. Settled `dispose()` still drives a host-side reap before awaiting quiescence, so a fire-and-forget child cannot remain alive merely because workflow result settlement already occurred. - -Together these rules ensure `workflow/agent-start` names only ready children, external result precedence is stable, and every surviving child reaches disposal. - -## Correctness enforcement - -The runtime rule is checked at four escape boundaries: API shape couples related subjects, TypeScript marks typed dispatch, development invariants inspect actual dispatch, and repository gates keep declarations aligned with enforcement. - -### API shape couples values that must agree - -`agentEvents(context, agent)` couples carrier, subject, and first event argument. `assembleContextFor(agent)` couples prompt facts with scope selection. `SessionStore.flush(session)` owns lookup of the carrier captured when the session entered. - -```text -assembleContextFor(agent): - return { agent, scope: agent } - -agentEvents(context, agent): - carrier = scopeTarget(agent, agent) - return dispatcher that always injects agent as the event subject -``` - -These helpers make a mismatch harder to express than the correct spelling. - -### Type markers cover every scoped event declaration - -Scoped agent, approval, tool, prompt, session, and subagent lifecycle events declare a `Scoped` receiver. TypeScript rejects a bare subject at typed dispatch sites, including subagent lifecycle events scoped to the delegating parent. - -The marker is compile-time only; JavaScript, casts, and direct Cordis dispatch can bypass it. - -### Development invariants inspect actual dispatch - -The invariants plugin observes Cordis's internal dispatch before listener delivery. Every scoped event requires a marked carrier, and events whose arguments expose the subject require the carrier key to be the same object. - -Session and subagent payloads do not expose their owner key directly, so their service centralizes key selection and the invariant proves carrier presence. Additional invariants reject an assembly whose `agent` and `scope` disagree and a turn opened before `agent/session-start`. - -Dedicated `dsh-scope` unit tests cover the carrier's advanced Proxy behavior: private-field method binding, call/construct shape, primordial filter invocation, own-key/descriptor consistency, and explicit configurable definitions. These are implementation tests, not checks performed by the invariants plugin. - -### Repository gates keep declarations and dispatchers aligned - -`verify-scoped-dispatch` compares declared scoped events with the runtime invariant table, and the generated event matrix requires every declaration to name a recognized dispatcher. Source JSDoc generates the [event catalog](../../../cordis-catalog/events.md), which remains the exhaustive signature and mode reference. +`inheritsParentContext` describes conversation-history seeding only, not Cordis scope, service injection, tools, or authority. The [subagent capability RFC](../feature/2026-06-21-subagent-capability-seam.md) owns run usage and the provider contract, while the [runtime-design RFC](2026-07-12-agent-scope-runtime-design.md) explains in-process structured output and workflow race handling. ## Alternatives considered -The rejected designs fail one of the four governing questions: they separate visibility from ownership, choose the wrong isolation unit, expose partial lifecycle, leave accepted values mutable, or rely on extension order for invariants. +The rejected architectures either separate visibility from cleanup, scope only behavior but not registered data, duplicate shared infrastructure, or conflate parent ownership with registration inheritance. ### Pass an agent option to every registration -An API such as `tools.register(definition, { agent })` leaves global registration as the leak-by-omission default and repeats scope plumbing in every registry. It can also express “visible to A, disposed with unrelated plugin B,” which `agent.ctx` prevents. +An API such as `tools.register(definition, { agent })` leaves global registration as the leak-by-omission default and repeats scope plumbing in every registry. It can also express “visible to A, disposed with unrelated plugin B,” which registration through `agent.ctx` prevents. ### Filter events while keeping registries global -Listener filtering prevents a hook from intercepting the wrong agent but does not scope tool schemas, executable lookup, prompt sections, variables, or Code Mode bindings. Persona, tool filtering, and concurrent structured schemas would still require global mutation. +Listener filtering prevents a hook from intercepting the wrong agent but does not scope tool schemas, executable lookup, prompt sections, variables, or Code Mode bindings. Agent-local composition would still require temporary global mutation. ### Create one isolated service graph per agent -Service isolation chooses one registry instance, while agent composition needs a merged view of deployment globals plus one agent layer. Per-agent graphs duplicate adapters and force shared persistence and UI infrastructure to discover every instance. - -Independent applications still deserve separate graphs; collaborating agents inside one deployment do not. +Service isolation chooses one registry instance, while the desired view is deployment globals plus one agent layer. Per-agent graphs duplicate adapters and force shared persistence and UI infrastructure to discover every instance. Independent applications still deserve separate graphs; collaborating agents inside one deployment do not. ### Inherit the parent's registrations into a child -Hierarchical registration inheritance silently copies every parent-scoped tool and policy into the child. A flat child layer plus a parent-owned disposer separates lifetime from composition: the parent owns the child without importing its registrations. - -This choice does not create a parent-subset authority guarantee; registration scope and authorization are different designs. - -### Publish the agent before running setup - -Early publication lets setup find the agent in global registries but lets observers act on a partially configured world. Rollback can remove entries but cannot retract external effects from listeners that already ran. - -The unpublished setup callback already receives `agent.ctx` and `ctx.agent`, so early global lookup is unnecessary. - -### Allow only synchronous setup - -Synchronous setup cannot honestly compose child plugins whose activation is asynchronous. TypeScript also permits a promise-returning callback where a void return is expected, so a synchronous-looking type would not reliably contain accidental async work. - -Awaited setup makes the transaction explicit and keeps first publication and prompt assembly behind it. - -### Validate caller data, then clone it - -Validation followed by a separate clone rereads accessors, so it can approve one value and retain another. A generic JSON clone can also erase or coerce exotic prototypes and unsupported values. The lossless-JSON traversal validates and materializes one captured value in the same operation. - -### Enforce invariants with prepended waterfall listeners - -A prepended listener is not permanently outermost: another plugin can prepend later, a short-circuit can skip inner work, and an outer wrapper can replace a downstream result. The same defect appears in prompt assembly, tool decisions, result commit, and turn continuation. - -The four owner-final APIs express the exact one-way power required: restore named canonical data, deny monotonically, observe immutable final outcome, or stop after ordinary continuation folding. - -### Put agent-scope policy inside vendored Cordis - -Cordis already supplies derived contexts, effect ownership, and receiver-based filtering. The harness-level primitive composes those domain-neutral mechanisms rather than teaching Cordis about agents, tools, prompts, or global-plus-agent merge rules. - -The lifecycle hardening remains correctly inside Cordis because effect pre-registration, parent ownership before child publication, and rejection of late effects protect every plugin under reentrant hot reload, not only agent scopes. +Hierarchical inheritance silently imports every parent-scoped tool and policy. Flat layers plus parent-owned disposal separate lifetime from composition: the parent owns the child without deciding the child's local world. This choice deliberately makes authorization a separate design. ## Consequences -The design buys one composition model across data, behavior, and lifetime. Its cost is per-scope state, transactional lifecycle machinery, owned runtime snapshots, disciplined dispatch, and four deliberately narrow owner-final APIs. +Contributors use the same registration methods at both deployment and agent scope; changing the calling context changes visibility and cleanup together. Model-visible tool lookup, execution, prompt assembly, policy, observation, and teardown agree on one agent key instead of maintaining parallel per-feature scope options. -### Benefits +The cost is explicit subject selection on reads and dispatch, asynchronous programmatic creation, disciplined handle disposal, and awareness that flat registration scope is not authority. Registries retain service-specific merge behavior, and only services that adopt the scope contract become agent-scoped automatically. -The main benefit is that plugin authors change context, not API. Registries and dispatchers then apply the same agent key across presentation, execution, observation, and cleanup. - -- Global plugins remain deployment-wide contributors and observers. -- Per-agent tools, prompt state, restrictions, and listeners use ordinary registration methods through `agent.ctx`. -- Model-visible schemas, executable lookup, Code Mode bindings, policy, and UI presentation resolve from one agent view. -- Create and resume expose no partially configured registry entry, while caller and AgentLoop ownership cover every await and rollback path. -- Agent teardown preserves the session and scoped listeners through loop exit and final flush, then releases IDs only after scope quiescence. -- Structured output composes independently per child without global mutation or middleware-order assumptions. - -### Costs and constraints - -The costs correspond to the four governing questions rather than one hidden framework abstraction. - -- **Registration and delivery:** registries maintain global and per-scope state; every scoped dispatcher must carry the real subject's key; the carrier is proxy-shaped and not identity-equal to its subject. -- **Lifecycle:** programmatic `create()` and `resume()` are asynchronous; caller sentinels, AgentLoop trackers, reservations, publication barriers, and shared quiescence promises cover construction and teardown races. -- **Boundary ownership:** public values are copied, frozen, bound, or retained by identity at their acceptance boundary; data that the boundary's owned representation cannot preserve fails instead of being coerced. -- **Owner-final policy:** prompt protection can reserve names, guards can only deny, final result observers cannot transform, and terminal stop may discard steering. -- **Flat scope:** a desired child-local contribution must be global or registered explicitly for the child; parent ownership alone does not import registrations. -- **Code Mode:** `run_code` remains protected transport infrastructure, so policy that forbids programs denies execution rather than removing the transport from an SDK-based prompt. - -The direct no-setup `ctx.agentLoop.create()` path remains synchronous for configuration and callers that already have complete options. Programmatic registry create/resume use the full unpublished transaction. - -### Deliberate boundaries - -The decision applies registration scope to tools, prompt state, scoped events, sessions, approvals, and in-process subagent composition. `agent.ctx` does not automatically scope every service call; filesystem policy, LLM interception, background subagent state, and other registries retain their existing subject or policy seams until their own designs adopt the rule. - -Security hardening remains separate work. This design does not sandbox same-process plugins, derive child authorization from a parent, freeze a grant set at agent creation, or introduce generic capability/output/termination tags. Those requirements need an explicit authority model rather than additional meaning attached to registration scope. +The decision applies to tools, prompt state, scoped events, session lifecycle and scoped session events, approvals, and in-process subagent composition. Filesystem policy, LLM interception, background backend state, and other registries retain their own subject or policy mechanisms until their designs explicitly adopt agent scope. diff --git a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md new file mode 100644 index 0000000000..0fbd3d647e --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -0,0 +1,969 @@ +# RFC: Agent-scope runtime design and correctness + +Status: implemented + +## Problem + +The [agent-scope contract](2026-07-08-agent-scope-contexts.md) defines the contributor-visible result: registrations made through `agent.ctx` form one flat local layer, operations resolve that layer by their real agent, setup remains unpublished, and teardown preserves the layer until work stops. The implementation must make those claims true inside a cooperative plugin framework and mutable JavaScript runtime. + +Four failure classes interact in the paths this change hardens: + +| Proof obligation | Failure if implemented locally or incompletely | +|---|---| +| Registration and dispatch select the same key | Prompt data resolves for one agent while behavior listeners run for another | +| Construction and teardown have continuous ownership | Reentrant unload publishes a partial world, leaks IDs, or revokes policy before final work | +| Covered validation and later use observe one accepted value | Stateful accessors or caller mutation make checked, executed, logged, and observed data disagree | +| Protocol invariants survive extensible middleware | Listener ordering removes required prompt state, re-allows denied work, commits a failed result, or forces another model step | + +Cordis already supplies derived contexts, effect ownership, receiver-based filtering, and waterfalls, but none alone establishes all four obligations. Context association is not a scope key, raw disposers are not await-idempotent, waterfall listeners can short-circuit or wrap each other, and JavaScript `readonly` types do not constrain runtime accessors. Async persistence, setup, publication callbacks, subagent providers, and worker messages add reentrancy and race boundaries around those primitives. + +## Decision + +The runtime implements agent scope as four coupled mechanisms rather than one generic framework feature: + +| Mechanism | Implementation decision | +|---|---| +| Layer and routing | A scope key tags registration effects; scope-aware contribution registries merge globals plus one layer; dispatch carriers filter listeners by the operation subject | +| Transactional lifetime | Scope, session, registry entry, driver, reservations, caller ownership, and AgentLoop ownership publish and unwind as one ordered transaction | +| Boundary ownership | The hardened acceptance-sensitive paths listed below capture caller fields once and retain stable identities or owner-controlled representations | +| Owner-final policy | Four narrow service-owned boundaries restore named prompt state, deny monotonically, observe final results, and stop terminal turns | + +The same mechanisms carry into in-process subagents and the workflow bridge. Subagents are the composition proof because child setup, structured output, readiness, cancellation, result settlement, and disposal exercise all four obligations concurrently. + +This RFC owns the implementation rationale, algorithms, race handling, and correctness enforcement. The [agent-scope contract](2026-07-08-agent-scope-contexts.md) owns contributor-facing behavior, tool-filter semantics, usage examples, and the security non-goal; this document links to that contract rather than redefining authority or public scope inheritance. + +## Implementation model: domain terms and Cordis mechanics + +Readers need four domain terms and four Cordis mechanics to follow the implementation. Readers already familiar with this codebase and Cordis can skim this section. + +### Recurring domain terms + +Four domain terms keep the rest of the RFC compact. A **Session** is an agent run's append-only event log, from which model history and durable replay are derived. **Lossless JSON** is the JSON subset that can be copied without changing meaning: primitives, dense arrays, and plain objects; cycles, sparse arrays, exotic prototypes, non-finite numbers, negative zero, `undefined`, `bigint`, functions, and symbols are rejected. An **end capability** is an actual callable tool implementation, whether the model sees it as a native schema or a Code Mode binding. **Code Mode** gives the model a generated SDK and a reserved `run_code` transport. Pure `code` presentation replaces native advertisement with that interface; `both` presentation retains native schemas alongside it. + +### Four Cordis mechanics + +Contexts select service access and registration origin, fibers own effects, waterfalls provide cooperative transformation, and dispatch receivers select listeners. + +| Cordis concept | Meaning in this RFC | +|---|---| +| Context | The object through which a plugin reaches services and registers contributions; a derived context can carry a different registration scope | +| Fiber and effect | The runtime owner and one owned piece of setup/cleanup; disposing the fiber unwinds its effects | +| Waterfall | Ordered around-middleware whose listener calls `next()` to include downstream work and may transform or short-circuit the result | +| Dispatch receiver | The `this` object used by Cordis listener filtering; a scope carrier encodes the operation's agent key | + +#### Context selects both service access and registration origin + +A Cordis `Context` is the object through which code calls services such as `ctx.tools`, `ctx.systemPrompt`, and `ctx.sessions`. A service can recover the context through which it was accessed, so the same method can register globally from a plain plugin context or locally from `agent.ctx` without adding an `agent` option to every registration API. Cordis implements contextual service access with a **traced receiver**: a proxy that carries the accessing context while forwarding calls to the concrete service object. + +```js +ctx.tools.register(globalTool) +agent.ctx.tools.register(agentOnlyTool) + +ctx.on('tools/result', globalObserver) +agent.ctx.on('tools/result', agentObserver) +``` + +A context also exposes the dependency view injected into the plugin that minted it. `agent.ctx` therefore carries the agent loop's deliberate service surface; it is not an ambient root context or a security boundary. + +#### Effects make cleanup follow ownership + +An effect is setup whose cleanup belongs to a fiber. Tool registration, prompt contribution, event subscription, and an agent scope are effects, so normal disposal, failure, and hot module reload all follow the same ownership graph. + +```js +ctx.effect(() => { + const resource = openResource() + return async () => { + await resource.close() + } +}) +``` + +Cordis also supports generator effects that nest child effects in a chosen teardown order. The lifecycle section explains why construction must become owner-visible before arbitrary callbacks run. + +#### Waterfalls remain cooperative extension points + +A waterfall listener wraps downstream work. Calling `next()` includes the remaining listeners and base implementation; returning directly skips that downstream portion. + +```js +ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { + const downstream = await next() + return { + ...downstream, + sections: [...downstream.sections, extraSection], + } +}) + +ctx.on('system-prompt/assemble', async () => replacementAssembly) +// The direct return skips this listener's downstream/base. An outer listener +// that already awaited next() still resumes around replacementAssembly. +``` + +This flexibility is intentional for ordinary policy, but it cannot express a fact that must remain true after every wrapper and short-circuit. [Owner-final policy](#owner-final-policy-four-narrow-boundaries) adds only the four final checkpoints that need stronger semantics. + +#### Dispatch receivers select scoped listeners + +Cordis filters listeners using the dispatch receiver, the object visible as `this` inside a function-style listener. `dsh-scope` builds a receiver carrying the operation's scope key, allowing global listeners plus listeners registered for that exact key while rejecting other agents' listeners. + +The receiver is live coordination state, not durable session data. For example, `tools/result` is a live final-outcome notification, while `tool/result` is an append-only session event used for replay and model history. + +## Registration and delivery: global plus exactly one agent layer + +Within services that adopt the agent-scope contract, one scope key controls both registered data and registered behavior. Contribution reads combine the deployment-global layer with exactly one agent layer, while scoped event dispatch admits global listeners plus the listeners for that same agent. + +Scope keys are opaque objects compared by identity; a live `Agent` is its own registration key. There is no name-based equality or parent traversal. + +### Scope mechanism: context, key, and lifetime + +The registration context selects the layer, the scope primitive binds that layer to cleanup, and the nearest scope tag—not an inherited convenience property—selects the key. + +#### The calling context selects visibility and cleanup + +A scope-aware registry method recovers the Cordis context through which its service receiver was accessed and calls `scopeOf(context)` once while installing the registration effect. An absent key selects the global store; a key selects the per-scope store. Cleanup closes over that accepted store and key, so later context mutation or a same-named replacement cannot redirect disposal. Event listeners follow a different Cordis path: `ctx.on()` retains the registering context, and targeted dispatch reads its scope while filtering listeners; `{ global: true }` deliberately bypasses that audience filter without changing cleanup ownership. + +The [public contract](2026-07-08-agent-scope-contexts.md#registration-origin-selects-visibility-and-cleanup) owns the visibility table, shadowing rule, and `{ global: true }` listener exception. Internally, registry resolution overlays one exact identity-keyed map on the global map and never traverses an ancestry relation: + +```text +resolveLayer(agentA): + visible = copy(global registrations) + visible.overlay(registrations from agentA.ctx) + return visible +``` + +The one-overlay algorithm is why the generic primitive needs only opaque identity and effect ownership; parent/child meaning stays outside `dsh-scope`. + +#### The scope primitive keeps layer and owner together + +`dsh-scope` exposes only the operations needed to mint a tagged ownership layer, read its key, target dispatch, and reach quiescent cleanup. For ordinary scope-aware registries, using a separate `{ scope }` option could express “stored in A's layer, disposed with B”; registration through the scoped context makes that mismatch unrepresentable. The `{ global: true }` listener option is an intentional audience exception, and the low-level `scopeTarget(base, key)` primitive still relies on its service-owned caller to supply matching facts. + +| Operation | Responsibility | +|---|---| +| `createScope(context, key)` | Mount an ownership fiber and return its tagged context | +| `scopeOf(context)` | Read the nearest inherited scope key | +| `scopeTarget(base, key)` | Build a scope-filtered dispatch receiver around the existing base receiver | +| `Scope.dispose()` | Return one shared idempotent promise that reaches cleanup quiescence | +| `Scope.rawDispose` | Expose the exact Cordis disposer for ordered generator composition | + +`Scope.dispose()` and `rawDispose` serve different callers. Cordis raw disposers are single-shot, so a repeated raw call need not wait for an earlier asynchronous teardown; the public method follows the backing fiber's in-flight cleanup and gives racing callers the same completion promise. Generator lifecycles use `rawDispose` because Cordis recognizes nested ownership by exact disposer identity. + +The primitive has one essential shape: + +```text +createScope(parentContext, key): + fiber = mount no-op plugin under parentContext + scopedContext = derive fiber.context with nearest-scope-tag = key + + rawDispose = fiber's exact disposer + dispose = memoized operation that: + invoke rawDispose if teardown has not started + follow fiber's in-flight cleanup until quiescent + + return { ctx: scopedContext, rawDispose, dispose } +``` + +Derived contexts inherit the nearest tag. Mounting a plugin under `agent.ctx` preserves the agent scope; deliberately creating another scope replaces the tag below it. + +#### `ctx.agent` is an association; `scopeOf()` selects the layer + +`agent.ctx.agent` gives setup code convenient access to the associated agent, but the nearest scope tag remains authoritative for resolution. A nested scope can inherit the ergonomic `agent` property while replacing the registration key. + +```js +const auditKey = {} +const auditScope = createScope(agent.ctx, auditKey) + +auditScope.ctx.agent === agent // true: inherited association +scopeOf(auditScope.ctx) === auditKey // true: nearest registration key + +await auditScope.dispose() +``` + +This separation keeps the generic scope package independent of the agent package. + +### Resolution contracts preserve domain semantics + +The shared scope selects two layers, but each registry retains its own merge rules and must keep presentation, lookup, and execution coherent within the view it owns. + +#### Registries retain domain-specific merge rules + +The shared primitive answers “which layer?” and “who owns cleanup?”; each service still defines how its values combine. Prompt sections, variables, and tools use scoped-over-global shadowing by name. Tool-schema providers are additive. Tool lookup and execution receive an agent or scope explicitly, while prompt assembly receives an `AssembleContext` whose `scope` selects the layer. + +Calling a read method through `agent.ctx` does not silently choose an agent subject. For example, `agent.ctx.systemPrompt.assemble()` without an assembly scope still requests the global view. Registration origin and operation subject remain explicit, allowing one shared service to act for any agent. + +#### The tool view is live and executable + +`ToolRegistry` owns one resolver rather than separate presentation and execution stores. It snapshots restriction definitions at registration, applies them to the current global map, overlays the matching scoped map, and derives lookup, dispatch, schemas, SDK bindings, timeouts, inspection, and UI presentation from that resolved map. A filtered global implementation therefore cannot remain executable through a second path. + +The [public RFC](2026-07-08-agent-scope-contexts.md#tool-restrictions-resolve-against-a-live-flat-view) owns the exact allow/deny/future-global/local-overlay behavior. The internal distinction needed here is that `ToolRegistry.knownNames()` validates restrictions against the pre-restriction end-capability universe, while the system-prompt provider validates `toolOrder` against the presentation mode's wire universe. + +Final prompt assembly can include schemas from other `systemPrompt.tools()` providers or assembly listeners. The coherent-view proof therefore covers `ToolRegistry`'s own schemas, bindings, lookup, execution, and presentation; another provider owns coherence for the unrelated schemas it contributes. + +Reserved `run_code` presentation sits outside both registration maps. The [Code Mode RFC](../feature/2026-06-15-code-mode.md) owns its mode, SDK, and `toolOrder` semantics; this design relies only on the fact that the transport is resolved separately from filterable end capabilities. + +### Dispatch contract follows the operation subject + +Service-owned dispatch paths derive or couple the scope key with the operation subject, and a carrier composes that key with the chosen base receiver's existing dispatch behavior. For agent events the agent is both base and operation subject; tool, approval, and prompt dispatch instead wrap their owning service while selecting listeners with the operation's agent key. The low-level primitives can still represent mismatched facts, so helper use and development invariants—rather than the type system alone—protect direct internal callers. + +#### The operation subject selects the listener set + +The [public dispatch rule](2026-07-08-agent-scope-contexts.md#scoped-events-follow-the-operations-real-subject) and generated [event catalog](../../../cordis-catalog/events.md) own listener visibility and the exhaustive event-family mapping. The implementation problem is to prevent each service from choosing its carrier, subject argument, and scope key independently. + +Fused helpers keep values that must agree together. `agentEvents(context, agent)` uses one agent as the subject, scope key, and first event argument. `assembleContextFor(agent)` sets both prompt facts and the scope selector. The session store captures its carrier when a session enters because later appends and flushes may occur without the original agent context. + +#### The carrier preserves base-receiver behavior + +Function-style listeners receive the carrier as `this`. Agent listeners may call methods on the agent base; service listeners rely on the owning service's contextual receiver behavior. The carrier is therefore a proxy that selects listeners while reading, writing, and invoking through the real base receiver. + +The implementation uses a dedicated surrogate proxy target with an immutable composed-filter slot. It combines the base receiver's existing `Context.filter` with the scope predicate instead of replacing it. Methods bind to the real base; callable carriers preserve call and construct shape; descriptor queries normalize configurable flags as required by Proxy invariants; and definitions through the carrier require an explicitly configurable descriptor. Stable built-in references protect the composed filter from accidental `.call` replacement. + +Those mechanics preserve observable JavaScript behavior, including private-field method identity: + +```js +class Base { + #count = 0 + increment() { this.#count += 1 } +} + +const base = new Base() +const key = {} +new Proxy(base, {}).increment() // TypeError: proxy lacks Base's private identity + +const carrier = scopeTarget(base, key) +carrier.increment() // works: method is bound to base +carrier === base // false: carrier has distinct identity +``` + +Together these constraints keep listener selection correct while preserving the base-receiver behavior listeners expect. + +The TypeScript-only `Scoped` marker requires a carrier at typed dispatch sites. Runtime marks and development invariants cover JavaScript, casts, and direct Cordis dispatch; they detect routing mistakes but do not confine hostile same-process code. + +## Lifecycle: compose privately, publish once, tear down in reverse + +Scope, session, registry entry, and driver form one transaction with two owners. Request fields are captured first; AgentLoop tracking and both identity reservations precede asynchronous work; the caller owns the prepared lifecycle before setup; publication proceeds in synchronous observable phases; and every teardown path reaches one reverse-order quiescence boundary. + +Two services split the public API from the implementation. `AgentRegistry`, reached as `ctx.agents`, stores live agents and is the front door for `create()` and `resume()`. Its registered `AgentFactory` is concretely implemented by `AgentLoop`, which constructs and drives agents using its own injected dependencies. The rest of this section calls that concrete co-owner the **AgentLoop factory**. + +| Phase | Public state | Ownership fact | +|---|---|---| +| Reserve | IDs unavailable to competitors | AgentLoop tracking and exact reservations cover the next await | +| Prepare or load | Persistence data is loading, or session, scope, and driver exist privately | Resume's load sentinel covers persistence; the complete caller lifecycle covers setup | +| Setup | `setup(agent.ctx)` may await and register | Neither ID is published | +| Publish and start | Session, agent, and lifecycle notifications appear in order | Liveness is checked between observable phases | +| Dispose | Driver drains, registries detach, scope unwinds, IDs release | All owner paths join one completion promise | + +The [public lifecycle contract](2026-07-08-agent-scope-contexts.md#creation-publishes-after-setup-disposal-revokes-after-work-stops) defines what callers observe. The following sections justify each ownership and ordering fact behind that contract. + +### Reservations precede awaiting; lifecycle ownership precedes setup + +AgentLoop tracking and exact identity reservations precede the first await. Resume adds a caller sentinel across persistence loading; create and resume both establish the complete caller-owned lifecycle before invoking setup. + +#### The prepared lifecycle is owned before setup callbacks + +The caller context owns the work it requested and receives the consumer-facing `AgentHandle`. The AgentLoop factory is a structural co-owner because a live agent continues to depend on its injected services. Either owner can deactivate the transaction; both converge on the same lifecycle disposer. + +| Owner mechanism | Covers | Retires when | +|---|---|---| +| Caller lifecycle sentinel | Caller-fiber loss from lifecycle preparation through live lifecycle | Shared lifecycle reaches quiescence | +| Resume load sentinel | Caller-fiber loss across persistence load and lifecycle handoff | Load rollback or the adopted lifecycle reaches quiescence | +| AgentLoop tracker | AgentLoop unload and structural dependency loss | Transaction and lifecycle settle | +| ID reservations | Competing agent/session insertion | Ordered teardown releases both IDs | + +A **sentinel** is an owner-visible effect that follows work whose final disposer is not yet available. It adopts the exact reservation disposers immediately, then follows the complete lifecycle disposer once preparation establishes it. + +Cordis must make construction owner-visible before setup can reenter teardown. An effect's cleanup wrapper enters its owner list before its setup body runs, a child fiber receives its parent-owned disposer before Cordis's child-plugin notification (`internal/plugin`) announces it, and a fiber already unloading rejects new effects after taking its cleanup snapshot. Teardown observers are contained independently so one callback cannot starve peers or interrupt cleanup. These are domain-neutral lifecycle rules; `dsh-scope` uses them by mounting a no-op plugin fiber as the ownership bucket for one scope. + +#### Caller ownership and factory dependency lookup stay separate + +Factory delegation carries two contexts because ownership and dependency origin are different facts. `ownerCtx` is the caller-bound context whose fiber and optional scope own the requested lifecycle. The factory method receiver is the accepted factory traced through that access so the concrete service retains its own injected dependency view. + +```text +callerCtx.agents.create(options) + ownerCtx = context carrying callerCtx's fiber and scope + factoryThis = concrete accepted factory traced through ownerCtx + Reflect.apply(capturedCreateAgent, factoryThis, [ownerCtx, options]) +``` + +`setFactory()` captures the concrete target and its `createAgent` and `resume` callbacks once. It canonicalizes an already traced service before retracing, avoiding a second proxy layer that would break raw-identity state. Plain factory objects receive the explicit `ownerCtx` without depending on Cordis tracing. + +#### Create and resume reserve identities before awaiting + +Programmatic create and resume reserve both agent and session IDs before any operation can await. Create prepares a new or seeded session; resume loads persisted data while a caller sentinel and AgentLoop load tracker already own the interval in which no `Agent` object exists. + +Reservations are capabilities, not advisory sets. Setup code cannot reserve, prepare, create, register, or enter a substitute under the same IDs. A session reservation prepares at most one exact object, and publication requires the matching factory-held capabilities. A failed or abandoned transaction therefore cannot publish a substitute or wedge an ID indefinitely. + +Resume transfers ownership rather than opening a gap: + +```text +resume(ownerCtx, request): + snapshot request identity, options, and setup callback + reserve agentId and sessionId + install caller sentinel adopting both reservation disposers + track load under AgentLoop + + persisted = await firstOf(persistence.load(sessionId), deactivated) + session = sessionReservation.prepare(reconstruct persisted data) + starting = startOwned(ownerCtx, session, reservations, setup) + caller sentinel follows starting.dispose + return await starting.result +``` + +If deactivation wins, a backend load may still settle internally but has no path back to publication. Preparation failure still returns a rollback-backed lifecycle result, so both owners can wait for actual cleanup instead of mistaking a rejected async result for successful installation. + +### Setup composes an unpublished world + +`setup(agentCtx)` may register tools, prompt state, restrictions, listeners, protections, or child plugins and may await their activation. The new agent is available as `agentCtx.agent`, but neither agent nor session is visible in its global registry. + +The complete rollback skeleton exists before setup runs. If setup throws, rejects, or loses either owner, the scope and prepared resources unwind and the IDs become reusable. After setup settles, a microtask checkpoint and liveness checks let a same-turn owner unload win before publication. + +Setup composes but cannot drive. `send`, `steer`, `inject`, and `cancel` reject until publication reaches the session-start boundary. The driver lock and inbox use runtime-private state, and only factory-held controls enable and start the loop; JavaScript casts cannot call a public start method or write directly into the queue. + +```text +startOwned(ownerCtx, snapshot, preparedSession): + world = prepareLifecycleWithCompleteRollback(ownerCtx, snapshot, preparedSession) + + result = async: + require world active + await firstOf(runOptionalSetup(snapshot.setup, world.agent.ctx), world.deactivated) + await oneMicrotask() + require caller, factory, owner fiber, and owner agent still active + world.publish(snapshot.source) + return handle(world.agent, world.dispose) + + on any error: + await world.dispose() + rethrow +``` + +### Publication is ordered, observable, and rollback-covered + +Publication is one synchronous sequence with liveness checks between three observable notification phases. Both registry entries exist before the first listener runs, but driving stays locked until immediately before `agent/session-start`. + +1. Enter the session store and capture its scope carrier. +2. Enter the agent registry without announcing it. +3. Recheck caller and factory liveness. +4. Emit `session/created`. +5. Recheck liveness. +6. Emit `agent/created`. +7. Recheck liveness. +8. Enable driving. +9. Emit `agent/session-start`. +10. Recheck liveness. +11. Start the driver. + +```text +publish(world): + world.beginSynchronousPublication() + try: + world.detachSession = sessions.enter(world.session, sessionReservation) + world.detachAgent = agents.enter(world.agent, agentReservation) + require callerAndFactoryActive + sessions.announce(world.session) + require callerAndFactoryActive + agents.announce(world.agent) + require callerAndFactoryActive + world.driver.enableDrivingVerbs() + emitNonVetoing(agent/session-start) + require callerAndFactoryActive + world.driver.start() + finally: + world.endSynchronousPublication() +``` + +#### Creation is paired, not atomic + +Observers run between publication steps, so the sequence is not described as atomic. Effects already performed by an earlier listener cannot be retracted if a later listener throws. Instead, each registry marks a creation announcement as begun before dispatch and emits exactly one matching disposal edge during rollback. An entered object that was never announced has no disposal notification because no observer was told it existed. + +A detach requested during `session/created` or `agent/created` is deferred until that dispatch unwinds. Stable captured carriers and exact-object guards prevent a later listener from observing `disposed` before `created` or a stale detach from deleting a replacement with the same ID. The outer publication barrier likewise prevents caller or AgentLoop teardown from removing the other registry entry or unwinding `agent.ctx` while an announcement remains on the stack. + +Creation listener synchronous throws remain vetoes. Returned promise rejections are observed and logged but not awaited: publication has no asynchronous gap in which such a result could roll back safely. Disposal notifications and `agent/session-start` are non-vetoing and independently contain both synchronous throws and returned-promise rejections so one listener cannot block cleanup or later observers. + +### Teardown stops work before revoking registrations + +Every owner path reaches one memoized reverse-order transaction. It marks the lifecycle inactive, waits for an in-progress synchronous publication phase, stops the driver through actual exit and final durability work, detaches the agent and session, unwinds the scope, and releases IDs last. + +Final turn events, the turn-ending flush, and any outstanding session flush started while the agent was idle therefore run while the session and scoped listeners still exist. `agent/disposed` observes an already quiescent and unregistered concrete agent while its session remains live; `session/disposed` follows after event feed detachment and store removal. Both use the stable carrier captured for their matching creation edge. + +```text +disposeOwnedAgent(world): + mark world inactive + await world.synchronousPublicationIfRunning() + await world.stopDriver() # loop exit plus agent-started flushes + world.detachAgent() + world.detachSession() + await world.scope.dispose() + world.releaseSessionReservation() + world.releaseAgentReservation() +``` + +`AgentHandle.dispose()` gives repeated and racing consumers the same completion promise. The lifecycle-long caller sentinel follows that promise even when handle disposal wins first, while the AgentLoop ledger independently stops new transactions and waits for every structurally dependent agent before the service disappears. + +AgentLoop co-ownership follows dependency shape, not a blanket “creator owns every returned value” rule. An AgentLoop-created agent continues to depend on the loop's services, so AgentLoop unload stops it. + +## Boundary ownership: hardened paths accept once and own the accepted value + +The acceptance-sensitive paths enumerated below read caller-owned fields once and retain only owner-controlled identities or snapshots before crossing asynchronous, reentrant, model-visible, or durable-log code. This is a boundary-by-boundary implementation property, not a blanket claim about every public API. The protection is independent of TypeScript: `readonly` annotations vanish at runtime, and JavaScript accessors can return a different value on every read. + +The shared shape distinguishes identity-bearing references from data. Agent objects and abort signals are retained by identity after one read. Boundaries whose contract requires lossless JSON—such as session events and subagent payloads—validate and materialize it in one traversal; other boundaries use their own owned representation, such as `structuredClone` for agent options. Scalars and callbacks are captured once, then each boundary applies the validation promised by its API before downstream use. + +```text +accept(input): + read every relevant top-level field exactly once + retain identity-bearing references without rereading them + validate acceptance-time fields from those captures + copy or pin data in the representation owned by this boundary + bind accepted callbacks once when method receiver state is intentional + expose only owner-controlled identities, frozen records, or detached results +``` + +Capture does not imply uniform eager callback type-checking. Agent `setup` is captured once and any invocation failure enters rollback; a tool guard is likewise captured, and an invalid cast becomes a normalized execution error. The invariant is that later work never rereads caller fields to choose a different value. + +| Boundary | Identity retained | Data detached or pinned | +|---|---|---| +| Tool and `SubagentProvider` registration | Original callback receiver | Name, flags, schemas, scalar config | +| Agent create/resume | Caller context, setup callback | IDs, options, session metadata and seed | +| Approval request | Agent and abort signal | Tool name, call ID, and reason | +| Tool execution | Agent, signal, registry-minted parent token | Call identity and arguments | +| Session append/load | Session identity | Header and event envelopes | +| Subagent start/result | Parent and signal | Prompt, filters, schema, options, result | + +Before agent setup can run, the concrete agent pins its accepted ID, options, and session and binds `ctx` once. Registry detach closures likewise close over their accepted keys instead of rereading mutable public fields. + +A stateful getter shows why validation and ownership must use the same capture: + +```js +let reads = 0 +const input = { + get name() { + reads += 1 + return reads === 1 ? 'safe_tool' : 'different_tool' + }, +} + +// Wrong: validation and storage observe different values. +validateName(input.name) +storeName(input.name) + +// Right: one accepted value drives both. +reads = 0 +const acceptedName = input.name +validateName(acceptedName) +storeName(acceptedName) +``` + +### Registered definitions are frozen snapshots + +Tool registration creates the stored definition identity once; changes occur through explicit unregister/register effects rather than mutation of a caller-retained object. Parameters are materialized in one traversal, callbacks bind once to the accepted definition receiver, and the stored record is deep-frozen. + +The first-party `defineTool()` helper applies the same boundary before registration. It captures each option once, materializes the authoring `SchemaSpec`, and derives both the wire schema and later execution/presentation validation from that owned spec. + +```text +defineTool(options): + accepted = read each option exactly once + parameterSpec = snapshotLosslessJson(accepted.parameters) + wireSchema = snapshotLosslessJson(convertToJsonSchema(parameterSpec)) + build execute and presentation validation over parameterSpec + +registerTool(context, definition): + accepted = read each definition field exactly once + stored = deepFreeze({ + accepted name, description, timeout, + parameters: snapshotLosslessJson(accepted.parameters), + execute: bind accepted.execute to definition, + presentation callbacks: bind accepted callbacks when present + }) + layerFor(scopeOf(context)).add(stored.name, stored) +``` + +`get()` and `visible()` return the frozen stored definitions; `schemas()` returns detached projections. Replacing `definition.execute` after registration has no effect, while a callback can deliberately read live state from its closure or original receiver. + +Factory and backend registration use different reentrancy orderings around the same ownership rule. `AgentFactory` registration claims its single slot before reading callback accessors. `SubagentProvider` registration first snapshots the provider fields, then its effect checks and enters the accepted name. Both capture callback identity and intentional receiver state once, and hot-reload cleanup closes over the accepted slot or key instead of rereading a mutable public property. + +### Durable session ownership carries the scope key + +The [session-immutability RFC](2026-06-11-dev-invariants-over-deep-readonly.md#session-owns-immutable-history) owns header, event, and snapshot semantics. Agent-scope correctness adds one requirement: the store keeps append observers, accepted registry IDs, and captured scope carriers in private owner state rather than caller-writable fields. Outside JavaScript therefore cannot rename a stored session or redirect later `session/event` delivery by mutating visible state. + +Approval requests follow the same async boundary at smaller scale: one capture preserves exact agent/signal identities, copies scalar fields, captures the session once, and drives `approval/asked`, scoped policy, cancellation, and `approval/decided` from that record. + +### Tool execution has pipeline-owned identity + +The [interception-seams RFC](../feature/2026-06-30-interception-seams.md) owns the public tool-pipeline contract. For agent-scope correctness, `ctx.tools.execute(input)` must turn caller-owned input into one pipeline-owned `ToolExecution` before any scoped policy or dispatch runs. It first reads `callId` and `name` once and requires strings; a failure there rejects because even an error result would lack trustworthy correlation identity. Once those strings are accepted, later input failures can become normal final error outcomes. + +Arguments are materialized once and deep-frozen. The registry assigns a frozen property-free `ToolExecutionToken`; callers cannot choose it. `token`, `callId`, `name`, `arguments`, `agent`, and optional opaque `parent` token become non-writable and non-configurable before policy. `signal` is the only operational field an around-dispatch wrapper may replace or remove. + +```text +prepareExecution(input): + callId = read input.callId exactly once + name = read input.name exactly once + require both are strings + + accepted = read arguments, agent, parent, and signal exactly once + require parent is absent or a registry-minted token + arguments = deepFreeze(snapshotLosslessJson(accepted.arguments)) + + execution = { + token: new frozen property-free object, + callId, name, arguments, + agent: accepted.agent, + parent: accepted.parent, + signal: accepted.signal + } + protect every field except signal + return execution +``` + +Stable execution identity prevents middleware from changing which tool or scope policy accepted. It also gives structured-output commit a safe `WeakMap` key when an adapter reuses a string call ID. Code Mode correlates an SDK sub-call with its enclosing `run_code` using only the outer execution's opaque token, never a mutable reference to the live outer object. + +Result boundaries apply the same ownership rule. Each transform returns data that is captured field-by-field, validated, materialized, and ultimately deep-frozen for final observers; malformed outcomes normalize to JSON-safe error results rather than reaching the session log as apparent success. + +## Owner-final policy: four narrow boundaries + +Waterfalls remain the ordinary extension mechanism; each of four protocol invariants runs after the last extension point capable of violating that specific invariant. Each owner-final API has the weakest one-way power that can preserve its guarantee. + +Here **canonical** means the named registry or tool-schema-provider output assembled before the waterfall—not “all output the service approves.” Protection restores only the names its owner declares. + +| Invariant | Cooperative extension point | Owner-final boundary | Guarantee | +|---|---|---|---| +| Named prompt/tool contribution | `system-prompt/assemble` waterfall | `systemPrompt.protect()` finalization | Canonical presence, absence, definition, and local anchor survive | +| Non-overridable tool denial | `tools/pre-execute` allow/deny/ask waterfall | Synchronous `tools.guard()` | A denial cannot become allow | +| Authoritative live outcome | Execute and post-execute waterfalls | Awaited `tools/result` notification | Observers receive one immutable final result | +| Terminal protocol completion | Continuation waterfall and pending steering | Serial `agent/turn-stop` | No middleware or late steering creates another step | + +### Prompt protection restores named canonical contributions + +`systemPrompt.protect({ sections, tools })` snapshots the requested names and restores their canonical registry or tool-schema-provider output after the complete assembly waterfall. Global and matching scoped protections compose by set union; a waterfall failure still fails assembly rather than triggering recovery. + +Protection covers both presence and absence. If the canonical assembly omits a protected name, finalization removes a listener-fabricated entry; this is how Code Mode keeps a native schema absent while preserving the SDK/transport form. Tool providers likewise expose one captured coherent record for schemas and optional known names, so a stateful getter cannot validate one name and display another. + +#### Global section protection reserves its name + +A globally protected section name cannot be shadowed by a scoped section. Scoped registration under an already protected name fails, and adding protection fails if a scoped shadow already exists. This check occurs before assembly because scoped-over-global merge would otherwise make the shadow itself appear canonical. + +Tool-schema protection does not create a blanket reservation for unrelated schema names. Providers are additive and may deliberately contribute other executable schemas; the owner-final guarantee covers only the named canonical contribution. + +#### Restoration preserves a useful local anchor + +Protection does not reset the whole assembly. It removes protected names from the waterfall result and reinserts each canonical entry before the first surviving later unprotected canonical neighbor, or at the end if none survives. Unprotected entries retain the order and definitions chosen by middleware. + +```text +assemble(context): + assembly = assemble registries for context.scope + canonical = snapshot protected section/tool inputs + transformed = await systemPromptAssembleWaterfall(assembly) + + for each protected canonical name: + remove every transformed entry with that name + if canonical includes the name: + insert before first surviving later canonical neighbor, else append + + return transformed +``` + +Code Mode globally protects `tools:sdk` and reserved `run_code`; structured output adds scoped protection for its instruction and capture schema. + +### Tool guards deny monotonically + +`ctx.tools.guard()` installs a global or scoped synchronous check after the complete `tools/pre-execute` waterfall and before dispatch. A guard returns a denial reason or `undefined`; it has no allow result. + +Pre-execute hooks still compose ordinary allow, deny, and ask decisions. An ask resolves through the optional approval service, where only `allowed-once` becomes allow and absence or any non-grant becomes deny. Guards run afterward, so listener order cannot convert their denial into dispatched work. + +```js +agent.ctx.on( + 'tools/pre-execute', + async () => ({ kind: 'allow' }), + { prepend: true }, +) + +agent.ctx.tools.guard(execution => + execution.name === 'bash' + ? 'reviewer agents are read-only' + : undefined, +) +``` + +Even a later prepended allow listener cannot bypass the guard. A denied call still becomes an error outcome that flows through result transformation and final observation. + +### `tools/result` observes the final live outcome + +For a successfully prepared execution, the live pipeline is `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute` → `tools/result`. Malformed non-identity input instead takes the error-shell path directly to final observation, as the algorithm below shows. The first, execute, and post stages are transformable waterfalls; `tools/result` is an awaited observe-only notification after every transform and outer error normalization. + +Every observer receives the same frozen execution and a separate deep-frozen snapshot of the owned result returned to the caller. Listener failures are contained independently, so they cannot change that returned result or starve peers. Routing uses `execution.agent`. + +`tools/result` is not the durable `tool/result` session event. The live notification also fires for direct programmatic executions and is the source of truth for in-process commit logic. The agent loop later appends the durable event for replay, UI reconstruction, and model history. + +```text +execute(input): + accept trustworthy callId and name + try to prepare pipeline-owned execution + on preparation failure: + create an identity-bearing error shell + ownedResult = owned error result + freeze execution + observerResult = deepFreeze(snapshotLosslessJson(ownedResult)) + await every tools/result observer independently with observerResult + return ownedResult + + gate = await tools/pre-execute(execution) + resolve ask through approval when needed + denial = policy denial or first guard denial + + if denied: + result = errorResult(denial) + else: + result = await tools/execute(execution, dispatchRegisteredTool) + + result = await tools/post-execute(execution, result) + ownedResult = normalize into owned lossless JSON + freeze execution + observerResult = deepFreeze(snapshotLosslessJson(ownedResult)) + await every tools/result observer independently with observerResult + return ownedResult +``` + +Waterfalls transform only at their named stages; guards only deny; final observers only observe. + +### `agent/turn-stop` makes continuation terminal + +Steering is input for another model step inside the current turn; queued prompts wait for a future turn. Ordinary continuation remains extensible: the loop computes a default, runs `agent/turn-continuation`, records any force-continue reason as steering, and treats pending steering as a reason to continue. + +The scoped serial `agent/turn-stop` checkpoint runs after that folding. A listener returns `{ action: 'stop' }` or abstains with `undefined`; malformed values and throws close the current turn with an error. A stop is terminal, so later listeners and steering cannot restore continuation. + +The loop uses `strictSerial` because ordinary Cordis serial dispatch treats `null` and `false` as abstentions. This terminal protocol permits only `undefined` to abstain, making accidental return values fail closed. + +Terminal state remains active through `turn/end` and the durability flush. Steering added by continuation, turn-close, or flush listeners is discarded after a terminal stop, while the ordinary queued-prompt FIFO remains untouched. + +```text +afterSuccessfulStep(turn): + decision = await agent/turn-continuation(defaultDecision) + record decision.reason as steering when present + if steering is pending: decision = continue + + terminal = await strictSerial(agent/turn-stop) + if terminal == stop: + discard steering + terminalStopped = true + decision = stop + + append turn/end + await session/flush + + if terminalStopped: + discard steering added by turn/end or flush listeners + else: + move leftover steering to the next-turn queue +``` + +This stronger control is reserved for terminal protocols such as a completed structured child; ordinary continuation policy remains cooperative. + +## Subagents: the composition proof + +In-process subagents add no second scoping model. They create a fresh flat child scope during unpublished setup, install ordinary scoped persona/filter/protocol registrations, own the child through a run handle, and use the same owner-final checkpoints for structured output. + +The roles and phases are explicit: + +| Role | Responsibility | +|---|---| +| Caller | Supplies parent, prompt, optional child configuration, and eventual disposal | +| `SubagentService` | Validates capabilities, owns the public wrapper, normalizes result and lifecycle telemetry | +| `SubagentProvider` backend | Chooses transport and creates one run | +| In-process driver | Owns child creation, setup, prompt drive, result read, cancellation, and teardown | +| Child `Agent` | Uses the ordinary agent lifecycle and its fresh `agent.ctx` | + +```text +recommended caller order: start -> await run.started -> await run.result -> await run.dispose() +internal observation: started and result may settle in either order; lifecycle publication waits for started +ownership: dispose may race any phase and joins one cleanup promise +``` + +The [agent-scope contract](2026-07-08-agent-scope-contexts.md#subagents-use-the-same-composition-rule) gives the contributor-facing example, and the [subagent capability RFC](../feature/2026-06-21-subagent-capability-seam.md) owns the public `SubagentRun` contract. This section follows only the in-process ownership and terminal-protocol implementation. + +### The child world uses ordinary registrations + +A child persona is a scoped `deployment:persona` section. Its tool filter is a scoped restriction over the live global tool layer. Structured output is a bundle of scoped tool, prompt, protection, guard, and listener registrations. + +```js +let structured +const setup = childCtx => { + if (persona !== undefined) { + childCtx.systemPrompt.section({ + name: 'deployment:persona', + order: 0, + text: persona, + }) + } + if (toolFilter !== undefined) childCtx.tools.restrict(toolFilter) + if (schema !== undefined) { + structured = attachStructuredRuntime(childCtx, schema) + } +} +``` + +The driver creates one run-owner fiber under `parent.ctx` and calls the child factory through it. Parent teardown, `spawn` backend teardown, and manual run disposal reach the same node, but the child still receives a new registration key. Lifetime inheritance therefore does not imply registration inheritance. + +### Structured output is a child-owned terminal protocol + +A structured child registers a real-schema `structured_output` tool and instruction in its own scope. Concurrent children can use different schemas without a global placeholder, reference count, or remove-for-everyone pass. + +The [Code Mode RFC](../feature/2026-06-15-code-mode.md) owns advertised wire routes and SDK behavior. The correctness distinction here is execution nesting: a native capture has one tool execution, while an SDK capture is an inner execution whose parent token identifies the enclosing `run_code`. Tool mode is presentation rather than an execution allowlist, so a direct unadvertised capture still follows the native commit path; a deployment that forbids that route uses an execution guard. + +Named protection restores this child's canonical capture contribution and instruction without erasing unrelated schemas deliberately added by another assembly provider. + +#### Native calls commit once; Code Mode SDK calls commit twice + +The capture body validates and stages a cloned value by stable `ToolExecution` identity. The scoped final-result observer commits a native capture only if that exact execution's final result succeeds. + +A schema-validation failure becomes the ordinary `INVALID_ARGS` tool result, so the model can correct the value and call the capture tool again within the same turn. + +```text +structured_output.body(value, execution): + validate value against this child's schema + staged[execution] = clone(value) + return ordinary success + +on tools/result(execution, finalResult): + if execution is staged: + value = staged.remove(execution) + if finalResult succeeded: + captured = value +``` + +For a Code Mode SDK call, successful inner observation records a pending value against the opaque outer `run_code` token. Commit waits for the outer transport's own successful final result because an inner side effect can succeed while the program or its post-policy still fails. + +```text +on tools/result(innerStructuredCall, innerResult): + if innerStructuredCall is staged: + value = staged.remove(innerStructuredCall) + if innerResult succeeded: + pending = { outerToken: innerStructuredCall.parent, value } + +on tools/result(outerRunCodeCall, outerResult): + if pending.outerToken == outerRunCodeCall.token: + value = pending.value + pending = none + if outerResult succeeded: + captured = value +``` + +Once capture is staged against an outer transport or committed, the scoped guard denies later calls in that response. After commit, `agent/turn-stop` ends the turn after ordinary continuation and steering fold. A child that otherwise completes cleanly without a committed capture returns an error rather than being re-prompted; requesting a schema makes output mandatory, not guaranteed. + +### The run protocol separates acceptance, readiness, result, and disposal + +`SubagentService.start()` returns synchronously, but `run.started` is the publication boundary. Callers treat the child as live only after readiness, consume `result`, and always dispose the run. + +Pre-readiness cancellation of an in-process run deactivates the run-owner fiber, prevents publication, rejects `started`, resolves `result` as `aborted`, and emits neither subagent lifecycle edge. + +`SubagentProvider` registration captures name, capability flags, the `inheritsParentContext` conversation-history descriptor, and the bound start callback once. The descriptor says whether completed parent turns seed the child's conversation; it says nothing about scope, services, tools, or authority. + +Starting a run captures every request field once. Parent and abort signal remain identity references; prompt, filter, schema, and options are detached lossless JSON; fixed `persona` and absolute `maxDepth` values validate before backend ownership. The in-process backend separately snapshots its optional session seed, and the service snapshots the terminal result when it settles. + +Depth validation repeats at each public entry while one helper owns the accepted domain: + +```text +tool-subagent plugin load: + assertSubagentMaxDepth(config.maxDepth) + +SubagentService.start(request): + capture and validate request.maxDepth + +startInProcessRun(request): + capture and validate request.maxDepth + parentDepth = validated depthOf(parent) + childDepth = parentDepth + 1 + reject if childDepth is not a safe integer + reject if maxDepth exists and childDepth > maxDepth +``` + +Only `undefined` means parent depth zero. Present depth and cap values must be non-negative safe integers and must not be negative zero; derived overflow rejects even when no request cap exists. + +The service does not expose the backend-owned run handle directly. It captures `id`, `started`, `result`, and methods once; binds methods to that handle; wraps result in one detached frozen record; and installs a shared disposal promise before calling untrusted backend cleanup. Once a callable backend disposer has been captured, a malformed later field triggers rollback; if no callable disposer can be captured, rollback is impossible and acceptance fails immediately. A backend disposer that directly returns the wrapper's reentrant promise is rejected as a cycle instead of hanging. + +```text +startInProcessRun(backendContext, acceptedRequest): + install backend ownership + attach accepted abort signal + create run-owner fiber under accepted parent.ctx + create child through runOwner.ctx.agents with unpublished setup + + started = child creation publication + result = after started: + send accepted prompt + await child idle + derive owned terminal result + dispose = dispose run owner and await quiescence + +SubagentService.start(...): + backendRun = backend.start(detached request) + serviceRun = freeze accepted id, readiness, bound methods, normalized result + observe result immediately + after readiness: + emit subagent/start, then buffered/eventual subagent/end + on readiness failure: + emit neither lifecycle edge +``` + +The service observes result settlement immediately even while readiness is pending, preventing an early rejection from becoming temporarily unhandled. Lifecycle listeners receive one frozen payload; their throws and returned-promise rejections are contained independently and cannot veto the run. + +## Workflow integration preserves the subagent contract + +The [dynamic-workflows RFC](../feature/2026-07-05-dynamic-workflows.md) owns workflow behavior. The agent-scope concern is whether the worker bridge preserves the same readiness, terminal-claim, and bounded-cleanup boundaries across a message port. It never announces an unready child, never lets cleanup rewrite an already chosen result, and never suppresses disposal merely because another terminal fact already won. + +The worker executes the workflow script and exchanges protocol messages; the host owns `SubagentService`, which invokes `SubagentProvider` backends and returns normalized run wrappers that the host retains. Their lifetimes follow dependency shape: an AgentLoop-created agent stops when its loop unloads, while a workflow run captures its holder-bound `SubagentService` at start, so unloading the workflow engine prevents new runs without revoking an already returned run. + +Three state dimensions remain separate: + +| Dimension | Question | Winning rule | +|---|---|---| +| Admission | May a worker message still start or announce a child? | Closed admission refuses the exact run and cleans it up | +| Terminal claim | Which external result does the workflow expose? | Earlier accepted external cancellation wins; otherwise first result/death claim wins | +| Physical cleanup | Which registered children and worker resources remain? | Every path may still dispose survivors through per-call gates | + +### Child admission waits for readiness + +After `SubagentService.start()` returns its normalized wrapper, the host registers that exact wrapper before awaiting, attaches result observers immediately, and rechecks admission both then and when `started` settles. A closed boundary claims cancellation and disposal for that exact entry, removes it only when disposal settles, and reports `ChildStartError` only while the worker reply channel remains open. + +The backend's nested `start()` may synchronously reenter workflow cancellation before the service wrapper reaches the host registry. The immediate post-start check and exact-wrapper identity guard close that interval; a backend that later fulfills its own readiness cannot resurrect workflow admission. + +```text +after subagents.start returns its run wrapper: + register exact wrapper for cancellation + observe and snapshot result immediately + if admission closed: refuse and clean exact wrapper + else await run.started + + on ready: + if admission closed: refuse and clean exact run + else send ChildStarted, then buffered/eventual outcome + + on readiness failure: + send ChildStartError only if the worker reply channel remains open + dispose exact wrapper if still registered +``` + +### Each terminal contender claims before its own callbacks + +Each terminal path records the state it owns before invoking its own callback fanout. External `cancel()` records the accepted cancellation reason before invoking child cancellation. On the Result path, the worker queues its `Result` message before settlement cleanup messages on the same port, and the host records the winning result before any Result-triggered abort or cancellation. Reentry therefore observes the fact that already won instead of rewriting it. + +```text +on workflow Result: + cancellationWasAlreadyAccepted = external cancellation is in flight + claim chosen result: + if earlier external cancellation and result is not cancelled: + cancelled result + else: + worker result + + if not cancellationWasAlreadyAccepted: + abort shared child-request signal + cancel every registered child through its at-most-once gate + settle chosen result +``` + +The worker may also send a later `ChildCancel`; host fanout and the worker message share one per-call cancellation gate, so an arbitrary backend's `cancel()` need not be idempotent. Each callback is contained independently. + +### Worker death, exit, and disposal remain separate + +The first worker death signal closes message admission, claims a death result unless an earlier terminal fact won, cancels and disposes registered children, and synthesizes missing lifecycle ends. A queued message can arrive between Node's `error` and `exit`, so the logical admission barrier—not physical exit—prevents late child creation or narration. + +Physical exit performs a final disposal-only sweep without repeating explicit cancellation. A cancellation grace period bounds how long the host waits for cooperative settlement before terminating the worker; a grace result can already be chosen while exit cleanup still needs to dispose surviving child handles. The bound is real: after grace expires, public disposal may return after invoking child disposal and reaping host resources even if a slow backend disposer has not reached quiescence. + +Public `handle.dispose()` claims its shared promise before invoking cancellation or child callbacks. Each `disposeChild` likewise claims its call-ID promise before invoking the backend disposer. Public-first reentry joins the public promise; worker-first reentry lets the holder traversal join the already claimed child promise. Settled `dispose()` still drives a host-side reap before awaiting quiescence, so a fire-and-forget child cannot remain alive merely because workflow result settlement already occurred. + +Together these rules ensure `workflow/agent-start` names only ready children, external result precedence is stable, and every surviving child reaches disposal. + +## Correctness enforcement + +The runtime rule is checked at four escape boundaries: API shape couples related subjects, TypeScript marks typed dispatch, development invariants inspect actual dispatch, and repository gates keep declarations aligned with enforcement. + +### API shape couples values that must agree + +`agentEvents(context, agent)` couples carrier, subject, and first event argument. `assembleContextFor(agent)` couples prompt facts with scope selection. `SessionStore.flush(session)` owns lookup of the carrier captured when the session entered. + +```text +assembleContextFor(agent): + return { agent, scope: agent } + +agentEvents(context, agent): + carrier = scopeTarget(agent, agent) + return dispatcher that always injects agent as the event subject +``` + +These helpers make a mismatch harder to express than the correct spelling. + +### Type markers cover every scoped event declaration + +Scoped agent, approval, tool, prompt, session, and subagent lifecycle events declare a `Scoped` receiver. TypeScript rejects a bare subject at typed dispatch sites, including subagent lifecycle events scoped to the delegating parent. + +The marker is compile-time only; JavaScript, casts, and direct Cordis dispatch can bypass it. + +### Development invariants inspect actual dispatch + +The invariants plugin observes Cordis's internal dispatch before listener delivery. Every scoped event requires a marked carrier, and events whose arguments expose the subject require the carrier key to be the same object. + +Session and subagent payloads do not expose their owner key directly, so their service centralizes key selection and the invariant proves carrier presence. Additional invariants reject an assembly whose `agent` and `scope` disagree and a turn opened before `agent/session-start`. + +Dedicated `dsh-scope` unit tests cover the carrier's advanced Proxy behavior: private-field method binding, call/construct shape, primordial filter invocation, own-key/descriptor consistency, and explicit configurable definitions. These are implementation tests, not checks performed by the invariants plugin. + +### Repository gates keep declarations and dispatchers aligned + +`verify-scoped-dispatch` compares declared scoped events with the runtime invariant table, and the generated event matrix requires every declaration to name a recognized dispatcher. Source JSDoc generates the [event catalog](../../../cordis-catalog/events.md), which remains the exhaustive signature and mode reference. + +## Alternatives considered + +The [agent-scope contract](2026-07-08-agent-scope-contexts.md#alternatives-considered) owns the rejected public architectures: explicit agent parameters, event-only filtering, per-agent service graphs, and hierarchical registration inheritance. This RFC records the implementation alternatives rejected after choosing the public contract. + +### Publish the agent before running setup + +Early publication lets setup find the agent in global registries but lets observers act on a partially configured world. Rollback can remove entries but cannot retract external effects from listeners that already ran. + +The unpublished setup callback already receives `agent.ctx` and `ctx.agent`, so early global lookup is unnecessary. + +### Allow only synchronous setup + +Synchronous setup cannot honestly compose child plugins whose activation is asynchronous. TypeScript also permits a promise-returning callback where a void return is expected, so a synchronous-looking type would not reliably contain accidental async work. + +Awaited setup makes the transaction explicit and keeps first publication and prompt assembly behind it. + +### Validate caller data, then clone it + +Validation followed by a separate clone rereads accessors, so it can approve one value and retain another. A generic JSON clone can also erase or coerce exotic prototypes and unsupported values. The lossless-JSON traversal validates and materializes one captured value in the same operation. + +### Enforce invariants with prepended waterfall listeners + +A prepended listener is not permanently outermost: another plugin can prepend later, a short-circuit can skip inner work, and an outer wrapper can replace a downstream result. The same defect appears in prompt assembly, tool decisions, result commit, and turn continuation. + +The four owner-final APIs express the exact one-way power required: restore named canonical data, deny monotonically, observe immutable final outcome, or stop after ordinary continuation folding. + +### Put agent-scope policy inside vendored Cordis + +Cordis already supplies derived contexts, effect ownership, and receiver-based filtering. The harness-level primitive composes those domain-neutral mechanisms rather than teaching Cordis about agents, tools, prompts, or global-plus-agent merge rules. + +The lifecycle hardening remains correctly inside Cordis because effect pre-registration, parent ownership before child publication, and rejection of late effects protect every plugin under reentrant hot reload, not only agent scopes. + +## Consequences + +The implementation makes the contributor contract locally checkable at each escape boundary. Its cost is explicit runtime machinery for key coherence, continuous ownership, accepted-value stability, and post-middleware finality. + +### Correctness properties + +The mechanisms compose into five properties: + +- Registry layers and event carriers derive from one opaque key, while fused helpers couple subjects that must agree. +- Reservations, sentinels, provider tracking, publication barriers, and reverse teardown cover every asynchronous or reentrant ownership interval. +- At the hardened boundaries listed above, accepted identities and snapshots prevent runtime accessors or later mutation from splitting validation, execution, logging, and observation. +- Prompt protection, guards, final-result observation, and terminal stop each have only the one-way power their invariant requires. +- In-process subagents and workflow runs preserve readiness, terminal precedence, and disposal under provider callbacks, worker death, and racing owners. + +### Costs and constraints + +The proof is not free: + +- Registries keep global and per-scope state, and every scoped dispatcher must preserve the operation subject through a proxy-shaped carrier. +- Programmatic create and resume require reservation capabilities, two-owner tracking, rollback state, ordered publication, and a shared quiescence promise. +- Acceptance boundaries copy, freeze, bind, or retain values according to their contract, increasing allocation and validation work. +- Owner-final behavior uses four explicit APIs instead of relying on ordinary listener ordering. +- Runtime invariants, generated dispatch checks, and focused Proxy/lifecycle/race tests remain necessary because TypeScript cannot enforce direct JavaScript dispatch or runtime reentrancy. + +The direct no-setup `ctx.agentLoop.create()` path remains synchronous for configuration and callers that already have complete options. Programmatic registry create/resume use the full unpublished transaction. + +### Limits of the proof + +The proof covers services and event families that explicitly adopt the agent-scope helpers. It does not make every service call scope-aware, strengthen the ordering contract of a custom agent registered outside AgentLoop, or force an arbitrary external subagent backend to reach quiescence after a workflow grace deadline. + +The [security and authority non-goal](2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals) is part of the public contract. These mechanisms prove composition and ownership behavior inside one trusted process; they do not prove confinement or parent-to-child non-escalation. diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md index a5f294f76d..47d9ec0473 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -24,7 +24,9 @@ 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. -**Why node:worker_threads**: one run = one worker thread, no pooling — a run is heavyweight (many children), so thread spin-up (~tens of ms) is noise. The script runs in a vm context INSIDE the worker, keeping the script-visible surface exactly the hook contract above (a bare worker realm would leak `setTimeout`/`fetch`/`process` as accidental API), and every `agent()` bridges to `ctx.subagents` by message-port RPC — children are I/O-bound LLM loops and stay on the host loop; the thread isolates the SCRIPT, the only part that can spin. What the thread buys: `start()` never blocks the host (an in-process engine runs the initial synchronous slice inline and cannot kill a spin past the first await — it could only ABANDON such a script, leaving the spin on the host loop), the post-cancel grace ends in a REAL `worker.terminate()`, and the value boundary is serialization by construction. isolated-vm was rejected for actual sandboxing: maintenance mode, `--no-node-snapshot` on EVERY consumer process (including published bins) on Node ≥ 20, node-gyp source-build fallback. Key mechanics (details in the package README): meta shape-validation and a body pre-parse stay HOST-side (preserving the seam's synchronous throws), a ready→go handshake keeps a run cancelled before start from ever executing the body, `cancel()` drives both child-cancel channels host-side (the shared request signal AND each child's explicit `cancel()` — a wedged worker cannot relay its own cancel RPCs), a host-side child registry backs worker-death reaping and `dispose()` quiescence, the wire protocol is enum-keyed payload maps private to the package, and on a termination path `agentsStarted` degrades to the host-observed count. Coverage puts the worker-side session on an in-process `MessageChannel` (real-Worker code is invisible to main-process v8) and proves the built `lib/worker.js` — a second tsdown entry, sanctioned in the workspace-constraints gate by the `"./worker"` subpath export — under plain node in the built-bin smoke gate. +**Why node:worker_threads**: one run uses one unpooled worker because a workflow run is already heavyweight relative to thread startup. The script runs in a vm context inside the worker, keeping the script-visible surface to the hook contract instead of exposing a bare worker realm, while `agent()` bridges by message-port RPC to I/O-bound child loops on the host. This keeps `start()` from blocking the host on the script's synchronous slice, makes the post-cancel deadline end in a real `worker.terminate()`, and gives cross-thread values a serialization boundary by construction. isolated-vm was rejected for its maintenance state, required `--no-node-snapshot` consumer flag on Node ≥ 20, and node-gyp fallback. + +Host-side meta validation and body pre-parsing preserve the seam's synchronous errors, and private enum-keyed payload maps define the wire protocol. Readiness admission, the two child-cancellation channels, worker-death reaping, result precedence, and disposal quiescence preserve the subagent run contract across that wire; the [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#workflow-integration-preserves-the-subagent-contract) owns those race algorithms. Coverage uses an in-process `MessageChannel` for worker-side logic that main-process V8 coverage cannot see and separately proves the built `lib/worker.js`—a second tsdown entry sanctioned by the `"./worker"` subpath export—under plain Node in the built-bin smoke gate. **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. @@ -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-is-a-child-owned-terminal-protocol) owns the assembly, commit, guard, and terminal-stop correctness algorithms. ## Deferred (documented non-goals of this cut) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 84a3877298..ad34cbd76d 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -31,7 +31,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist, but concrete driving remains locked until the immediately following `agent/session-start`; that non-vetoing notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves. -Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` is a serial surface-mutation checkpoint, while `agent/turn-stop` is the owner-final exception: it runs after ordinary continuation and steering folding, and its terminal state remains through turn close and flush so steering from those later listeners cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale is in [the agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#owner-final-policy-boundaries). +Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` is a serial surface-mutation checkpoint, while `agent/turn-stop` is the owner-final exception: it runs after ordinary continuation and steering folding, and its terminal state remains through turn close and flush so steering from those later listeners cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale is in the [agent-scope runtime-design RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#owner-final-policy-four-narrow-boundaries). Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md). From a34801df4bcf079fc8f8580b80b474387cc8ef7b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:54:37 +0800 Subject: [PATCH 50/64] fix(agent-loop): own queued message input --- docs/cordis-catalog/events.md | 28 ++-- docs/core-data-structures/core.md | 13 +- docs/event-producer-consumer.md | 26 ++-- .../2026-07-12-agent-scope-runtime-design.md | 3 + packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/agent.ts | 47 +++++-- packages/core/agent-loop/src/loop.ts | 7 +- .../agent-loop/tests/coverage-edges.spec.ts | 35 ++--- .../agent-loop/tests/review-fixes.spec.ts | 123 +++++++++++++++++- packages/core/agent/README.md | 4 +- packages/core/agent/src/types.ts | 24 +++- 11 files changed, 240 insertions(+), 72 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index d59bc617d0..b69ed834b3 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -23,7 +23,7 @@ An agent's fully composed scoped world was published in the AgentRegistry. Its s Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:307`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:316`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -35,7 +35,7 @@ An agent was removed from the registry. The concrete AgentLoop lifecycle emits t Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:322`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:331`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -47,7 +47,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:596`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:608`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -61,7 +61,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:428`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:440`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -73,11 +73,11 @@ Waterfall: decide what happens to ONE drained queued message before it becomes a Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:446`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:458`](../../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. +A message entered the agent's inbox (queued or steering). Content and the resolved source are the detached, deeply-frozen values retained by the inbox; the `info` wrapper is frozen too, so one listener cannot rewrite what another listener observes. `source` has defaults applied and is not the caller's raw options. ```ts cordis-catalog 'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void @@ -85,7 +85,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:350`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:362`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -97,7 +97,7 @@ Waterfall: shape the step's call configuration — model switching, sampling ove Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:475`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:487`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -113,7 +113,7 @@ The seed is a frozen empty list; a contributing listener returns a NEW array — Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:527`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:539`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -125,7 +125,7 @@ The agent's session lifecycle began, fired once before its first turn. `source` Types: [Agent](../core-data-structures/core.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:371`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:383`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -137,7 +137,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:336`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:345`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -149,7 +149,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:542`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:554`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -161,7 +161,7 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:560`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:572`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -173,7 +173,7 @@ Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` wat Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:579`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:591`](../../packages/core/agent/src/types.ts) ## `approval/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 7937018b9f..bd533fe5c4 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -267,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 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b8bcde4c9a..17e9e74f50 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,19 +7,19 @@ 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:307`](../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:322`](../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:596`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:428`](../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:446`](../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:350`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:475`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:527`](../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:371`](../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:336`](../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:542`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:560`](../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:579`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`strictSerial (serial)`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:331`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:608`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:440`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:458`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:362`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:487`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:539`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:383`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:345`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:554`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:572`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:591`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`strictSerial (serial)`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:72`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index 0fbd3d647e..c94f0cee63 100644 --- a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -419,6 +419,7 @@ Capture does not imply uniform eager callback type-checking. Agent `setup` is ca |---|---|---| | Tool and `SubagentProvider` registration | Original callback receiver | Name, flags, schemas, scalar config | | Agent create/resume | Caller context, setup callback | IDs, options, session metadata and seed | +| Agent send/steer | None | Content blocks and resolved message source | | Approval request | Agent and abort signal | Tool name, call ID, and reason | | Tool execution | Agent, signal, registry-minted parent token | Call identity and arguments | | Session append/load | Session identity | Header and event envelopes | @@ -426,6 +427,8 @@ Capture does not imply uniform eager callback type-checking. Agent `setup` is ca Before agent setup can run, the concrete agent pins its accepted ID, options, and session and binds `ctx` once. Registry detach closures likewise close over their accepted keys instead of rereading mutable public fields. +`send()` and running `steer()` resolve the message source once and materialize `{ content, source }` as one detached, deeply frozen lossless-JSON record before `agent/queued` or inbox insertion. The notification and FIFO share that accepted content and source; its metadata wrapper is frozen separately, so neither retained caller references nor an earlier notification listener can rewrite what a later listener, the session log, or the model sees. Invalid content or source throws synchronously without notification, enqueue, or loop wakeup; idle `steer()` delegates to the same `send()` boundary. The later `agent/prompt-submit` waterfall can still replace a queued prompt by returning new content; ownership forbids in-place mutation, not the explicit rewrite protocol. + A stateful getter shows why validation and ownership must use the same capture: ```js diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index daf7e10d76..8f6b9604c5 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -44,7 +44,7 @@ Agents listed in config are auto-created at startup. `cwd` applies only to fresh - `ReactLoopAgent` — the concrete `Agent` implementation. Its inbox is a JavaScript native-private field, and one prepared session can be claimed by only one concrete driver. Everything observable happens through session events and the `agent/*` event taxonomy. -`Inbox`, `runLoop`, and the instance-bound enable/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. +`Inbox`, `runLoop`, and the instance-bound enable/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()` and running `steer()` materialize content plus resolved source once as detached, deeply frozen lossless JSON, then share that accepted record between `agent/queued` and the inbox; malformed data throws before either boundary. ### Loop lifecycle (`loop.ts`) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 6ea60c4958..16f10168b6 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -12,8 +12,8 @@ import type { AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek- import type { Agent } from '@deepseek-ai/dsh-agent' import { deepFreeze } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import type { Session } from '@deepseek-ai/dsh-session' -import { Inbox } from './inbox.ts' +import { snapshotJsonValue, type Session } from '@deepseek-ai/dsh-session' +import { Inbox, type InboxMessage } from './inbox.ts' import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts' /** Agents whose rollback-covered publication enabled driving. */ @@ -213,6 +213,26 @@ export class ReactLoopAgent implements Agent { return options?.source ?? { kind: 'user' } } + /** + * Accept one public send/steer payload as the exact detached record shared by + * the live notification and inbox. Lossless-JSON materialization reads every + * nested field once; deep freeze prevents an observer from rewriting queued + * work before the loop drains it. + */ + private acceptInboxMessage(content: ContentBlock[], options?: SendOptions): InboxMessage { + const source = this.resolveSource(options) + const accepted = snapshotJsonValue({ content, source }) + if (accepted === undefined) { + throw new TypeError('agent message content and source must be losslessly JSON-serializable') + } + return deepFreeze(accepted) + } + + /** Reject a driving operation once teardown has synchronously closed the agent. */ + private assertNotDisposed(): void { + if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`) + } + /** Reject every driving verb while creation setup still owns the agent. */ private assertDriveEnabled(action: string): void { if (driveEnabledAgents.has(this)) return @@ -221,24 +241,29 @@ export class ReactLoopAgent implements Agent { send(content: ContentBlock[], options?: SendOptions): void { this.assertDriveEnabled('send') - if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`) - const source = this.resolveSource(options) - this.#inbox.enqueue({ content, source }) - agentEvents(this.loopCtx, this).emit('agent/queued', content, { source, steering: false }) + this.assertNotDisposed() + const accepted = this.acceptInboxMessage(content, options) + // Materialization invokes caller getters, which may reenter handle disposal. + this.assertNotDisposed() + this.#inbox.enqueue(accepted) + const info = deepFreeze({ source: accepted.source, steering: false }) + agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info) } steer(content: ContentBlock[], options?: SendOptions): void { this.assertDriveEnabled('steer') - if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`) + this.assertNotDisposed() if (this._status !== 'running') { this.send(content, options); return } - const source = this.resolveSource(options) - this.#inbox.steer({ content, source }) - agentEvents(this.loopCtx, this).emit('agent/queued', content, { source, steering: true }) + const accepted = this.acceptInboxMessage(content, options) + this.assertNotDisposed() + this.#inbox.steer(accepted) + const info = deepFreeze({ source: accepted.source, steering: true }) + agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info) } inject(content: ContentBlock[], options?: SendOptions): void { this.assertDriveEnabled('inject') - if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`) + this.assertNotDisposed() const source = this.resolveSource(options) if (isTurnOpen(this.session)) { // A turn is open in the LOG (decided from the log, not agent status — diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 8c30e42257..756c007c45 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -291,11 +291,15 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH // the previous turn/end), where the persistence backend drops it as a // crash tail (the turn-enclosure RFC). Report via agent/error + the logger only; the // driver survives and moves on. + /* v8 ignore start -- defensive internal-corruption backstop: public + * send/steer input is accepted as lossless JSON before enqueue, and + * runTurn contains every failure after turn/start. */ const err = toError(error) ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${err.message}`) try { events.emit('agent/error', turn, 0, err) } catch { /* contained: a throwing agent/error listener must not kill the driver */ } + /* v8 ignore stop */ } // Reset the cancel marker UNCONDITIONALLY here, after the turn returns and @@ -730,9 +734,10 @@ async function runTurn( // `closeStep()` IS idempotent (guarded by `stepOpen`) — it may have run // already in a step branch, so running it again is a safe no-op. Absent // turn/start means the append threw BEFORE its push (a non-serializable - // trigger — impossible for our fixed trigger); nothing was opened, so rethrow + // trigger outside the public lossless-JSON boundary); nothing was opened, so rethrow // to the runLoop backstop. const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn) + /* v8 ignore next -- defensive internal-corruption path; public inbox input is lossless JSON */ if (!turnStartLogged) throw error closeStep() // Choose the close reason. Disposal wins only if no error was already diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 7a044bfb57..ee58fcd66e 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -35,31 +35,24 @@ function send(agent: ReactLoopAgent, text: string) { agent.send([{ type: 'text', text }]) } -describe('turn boundary listener throws (handled in-turn, loop survives)', () => { - it('a pre-push turn/start failure (non-serializable source) is rethrown to the runLoop backstop', async () => { - // A non-serializable message source makes the turn/start append throw BEFORE - // the event is pushed (Session.append validates before push), so turn/start - // never enters the log. runTurn sees no logged turn/start and rethrows; the - // runLoop backstop reports via agent/error (step 0) + the logger and the - // driver survives. This is the ONLY path that reaches the backstop. - const adapter = new MockAdapter([textResponse('turn 2')]) +describe('inbox acceptance', () => { + it('rejects non-serializable content or source synchronously before notification or enqueue', async () => { + const adapter = new MockAdapter([textResponse('turn 1')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + let queued = 0 + ctx.on('agent/queued', () => { queued += 1 }) - const errors: { turn: number; step: number; message: string }[] = [] - ctx.on('agent/error', (_a, turn, step, error) => void errors.push({ turn, step, message: error.message })) + expect(() => { + agent.send([{ type: 'text', text: 'first', bad: 1n } as never]) + }).toThrow(/losslessly JSON-serializable/) + expect(() => { + agent.send([{ type: 'text', text: 'first' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never }) + }).toThrow(/losslessly JSON-serializable/) + expect(queued).toBe(0) + expect(agent.session.events).toHaveLength(0) - // A non-serializable source (BigInt) on the queued message. - agent.send([{ type: 'text', text: 'first' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never }) - await waitForIdle(ctx, agent) - - expect(errors).toHaveLength(1) - expect(errors[0]!.step).toBe(0) - expect(errors[0]!.message).toMatch(/non-JSON-serializable/) - // No turn boundary was written (the turn/start append threw before push). - expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) - - // loop survives: a well-formed second turn runs normally. + // The rejected value never woke or poisoned the loop; a valid message runs. send(agent, 'second') await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index 3cfa743b65..4c68ac53bd 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService, { CallId, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm' +import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' @@ -437,6 +437,127 @@ describe('MEDIUM: misc registry and config fixes', () => { const steeringSources = agent.session.events.flatMap(e => e.type === 'steering/message' ? [e.data.source] : []) expect(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }]) }) + + it('send() owns content and source before notification and delivery', async () => { + const adapter = new MockAdapter([textResponse('done')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('owned-send'), { model: 'mock' }) + const content = [{ type: 'text' as const, text: 'accepted-send' }] + const source = { kind: 'plugin' as const, plugin: 'accepted-source' } + let notifiedContent: ContentBlock[] | undefined + let notifiedSource: MessageSource | undefined + let notifiedInfoFrozen = false + ctx.on('agent/queued', (subject, acceptedContent, info) => { + if (subject !== agent || info.steering) return + // Retain the exact notification references: cloning here would test the + // listener's copy rather than the event/inbox ownership boundary. + notifiedContent = acceptedContent + notifiedSource = info.source + notifiedInfoFrozen = Object.isFrozen(info) + }) + + agent.send(content, { source }) + content[0]!.text = 'caller-mutated-send' + source.plugin = 'caller-mutated-source' + await waitForIdle(ctx, agent) + + expect(notifiedContent).toEqual([{ type: 'text', text: 'accepted-send' }]) + expect(notifiedSource).toEqual({ kind: 'plugin', plugin: 'accepted-source' }) + expect(Object.isFrozen(notifiedContent)).toBe(true) + expect(Object.isFrozen(notifiedContent?.[0])).toBe(true) + expect(Object.isFrozen(notifiedSource)).toBe(true) + expect(notifiedInfoFrozen).toBe(true) + const recorded = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : []) + expect(recorded).toContainEqual({ + content: [{ type: 'text', text: 'accepted-send' }], + source: { kind: 'plugin', plugin: 'accepted-source' }, + }) + const request = JSON.stringify(adapter.requests[0]!.messages) + expect(request).toContain('accepted-send') + expect(request).not.toContain('caller-mutated-send') + }) + + it('send() rechecks disposal after materializing caller getters', async () => { + const adapter = new MockAdapter([textResponse('unused')]) + const ctx = await harness(adapter) + const handle = await ctx.agents.create({ + agentId: AgentId('reentrant-send-dispose'), + sessionId: SessionId('reentrant-send-dispose-session'), + agentOptions: { model: 'mock' }, + }) + const { agent } = handle + let queued = 0 + ctx.on('agent/queued', subject => void (queued += Number(subject === agent))) + const content = [{ + type: 'text' as const, + get text() { + void handle.dispose() + return 'accepted-after-dispose' + }, + }] + + expect(() => { agent.send(content) }).toThrow(/agent "reentrant-send-dispose" is disposed/) + await handle.dispose() + + expect(queued).toBe(0) + expect(agent.session.events).toHaveLength(0) + expect(adapter.requests).toHaveLength(0) + }) + + it('running steer() owns content and source before notification and delivery', async () => { + const adapter = new MockAdapter([toolCallResponse('c1', 'gate', {}), textResponse('done')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('owned-steer'), { model: 'mock' }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.tools.register(defineTool({ + name: 'gate', + description: '', + parameters: {}, + async execute() { + entered.resolve(undefined) + await release.promise + return [{ type: 'text', text: 'tool done' }] + }, + })) + let notifiedContent: ContentBlock[] | undefined + let notifiedSource: MessageSource | undefined + let notifiedInfoFrozen = false + ctx.on('agent/queued', (subject, acceptedContent, info) => { + if (subject !== agent || !info.steering) return + notifiedContent = acceptedContent + notifiedSource = info.source + notifiedInfoFrozen = Object.isFrozen(info) + }) + + agent.send([{ type: 'text', text: 'start' }]) + await entered.promise + expect(agent.status).toBe('running') + const content = [{ type: 'text' as const, text: 'accepted-steer' }] + const source = { kind: 'plugin' as const, plugin: 'accepted-source' } + agent.steer(content, { source }) + content[0]!.text = 'caller-mutated-steer' + source.plugin = 'caller-mutated-source' + const idle = waitForIdle(ctx, agent) + release.resolve(undefined) + await idle + + expect(notifiedContent).toEqual([{ type: 'text', text: 'accepted-steer' }]) + expect(notifiedSource).toEqual({ kind: 'plugin', plugin: 'accepted-source' }) + expect(Object.isFrozen(notifiedContent)).toBe(true) + expect(Object.isFrozen(notifiedContent?.[0])).toBe(true) + expect(Object.isFrozen(notifiedSource)).toBe(true) + expect(notifiedInfoFrozen).toBe(true) + const recorded = agent.session.events.flatMap(event => event.type === 'steering/message' ? [event.data] : []) + expect(recorded).toContainEqual({ + turn: 1, + content: [{ type: 'text', text: 'accepted-steer' }], + source: { kind: 'plugin', plugin: 'accepted-source' }, + }) + const request = JSON.stringify(adapter.requests[1]!.messages) + expect(request).toContain('accepted-steer') + expect(request).not.toContain('caller-mutated-steer') + }) }) describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () => { diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index ad34cbd76d..3663808847 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -39,8 +39,8 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: -- `agent.send(content, options?)` — queue a message; starts a turn when idle -- `agent.steer(content, options?)` — steer a running turn (inject between steps); behaves like `send` when idle +- `agent.send(content, options?)` — queue a message; starts a turn when idle. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). +- `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle - `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)) - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 0e914888b9..419cc9d1fe 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -202,12 +202,21 @@ export 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 @@ -335,11 +344,14 @@ declare module 'cordis' { */ 'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void /** - * A message entered the agent's inbox (queued or steering). `source` is - * the resolved source (defaults applied), not the caller's raw options. + * A message entered the agent's inbox (queued or steering). Content and the + * resolved source are the detached, deeply-frozen values retained by the + * inbox; the `info` wrapper is frozen too, so one listener cannot rewrite + * what another listener observes. `source` has defaults applied and is not + * the caller's raw options. * @param agent - the agent whose inbox received the message. - * @param content - the enqueued content blocks, verbatim. - * @param info - the resolved source plus whether it entered as steering. + * @param content - the accepted content blocks retained by the inbox. + * @param info - the accepted source plus whether it entered as steering. * 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 From 11a074b6644736234c88c4ff21ca36edeab400b7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:17:38 +0800 Subject: [PATCH 51/64] docs(rfc): add agent-scope diagrams --- .../2026-07-08-agent-scope-contexts.md | 38 ++++++++++++++ .../2026-07-12-agent-scope-runtime-design.md | 49 +++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md index 9f380e6dc1..024eb05f82 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -27,6 +27,24 @@ The contract has four parts: The scope is deliberately flat. Resolution never walks parent or sibling scopes. Parent ownership links lifetimes without importing registrations. +For scope-aware registries and default listener routing, the whole mechanism can be read from left to right: the registering context chooses a layer, while the agent named by an operation chooses which one local layer joins the deployment-global layer. + +```mermaid +flowchart LR + plain["Plain plugin context
cleanup follows the plugin"] -->|"registers into"| globalLayer["Deployment-global layer"] + agentAContext["agentA.ctx
cleanup follows Agent A"] -->|"registers into"| agentALayer["Agent A layer"] + agentBContext["agentB.ctx
cleanup follows Agent B"] -->|"registers into"| agentBLayer["Agent B layer"] + + operationA["Operation for Agent A"] -->|"selects"| agentAView["Agent A view
eligible globals plus A local only"] + globalLayer --> agentAView + agentALayer --> agentAView + operationB["Operation for Agent B"] -->|"selects"| agentBView["Agent B view
eligible globals plus B local only"] + globalLayer --> agentBView + agentBLayer --> agentBView +``` + +The missing cross-edges describe registry resolution and default listener routing: Agent A's registered values and ordinary scoped listeners do not enter Agent B's view, and a parent's layer does not enter a child's view merely because the parent owns the child's lifetime. For scope-filtered events, `{ global: true }` is the explicit opt-in exception; it can observe across scopes while cleanup still follows the registering agent. Registry-membership notifications are a separate unfiltered event class described below. + The companion [runtime-design RFC](2026-07-12-agent-scope-runtime-design.md) explains how the implementation preserves this contract under Cordis dispatch, JavaScript mutation and reentrancy, asynchronous setup, rollback, and racing disposal. ### Registration origin selects visibility and cleanup @@ -103,6 +121,26 @@ The returned promise resolves only after setup, ordered lifecycle notification, The calling Cordis context and AgentLoop are structural co-owners. Unloading either disposes the agent, so creation through a short-lived plugin context intentionally gives the agent that shorter lifetime. +The lifecycle keeps the local layer private until setup succeeds and keeps it alive until final work has drained: + +```mermaid +flowchart TB + request["Create or resume"] --> reserve["Reserve agent and session IDs"] + reserve --> privateWorld["Load or build private session, scope, and driver"] + privateWorld --> setup["Await setup through agent.ctx"] + setup --> publish["Publish session and agent, then start the loop"] + publish --> live["Return the live handle"] + + privateWorld -->|"load or preparation failure, or owner loss"| rollback["Rollback startup
no handle escapes"] + setup -->|"setup failure or owner loss"| rollback + publish -->|"publication failure or owner loss"| rollback + live -->|"handle disposal, owner unload, or AgentLoop unload"| settle["Quiesce prepared or running work"] + rollback --> settle + settle --> detach["Detach any published agent, then session"] + detach --> revoke["Dispose any created agent scope"] + revoke --> release["Release acquired IDs"] +``` + Contributors should put agent-local activation inside `setup` and always dispose the returned handle. Code that needs to observe a live agent waits for `create()`/`resume()` to resolve rather than polling the registries during setup. ## Tool restrictions resolve against a live flat view diff --git a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index c94f0cee63..988a30968f 100644 --- a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -249,6 +249,37 @@ Two services split the public API from the implementation. `AgentRegistry`, reac | Publish and start | Session, agent, and lifecycle notifications appear in order | Liveness is checked between observable phases | | Dispose | Driver drains, registries detach, scope unwinds, IDs release | All owner paths join one completion promise | +The implementation treats success, rollback, handle disposal, caller unload, and AgentLoop unload as entrances to one owned transaction rather than separate cleanup algorithms: + +```mermaid +flowchart TB + caller["Caller context owner"] --> transaction["Owned create or resume transaction"] + factory["AgentLoop structural owner"] --> transaction + + subgraph creation["Create or resume"] + transaction --> reserve["Reserve both IDs and install trackers"] + reserve --> prepare["Load persistence or prepare the session"] + prepare --> lifecycle["Install the complete caller-owned lifecycle"] + lifecycle --> setup["Await unpublished setup"] + setup --> enter["Enter session and agent registries"] + enter --> announce["Emit session/created, then agent/created"] + announce --> start["Enable driving, emit agent/session-start, start driver"] + end + + transaction -.->|"reservation, load, or preparation failure before lifecycle handoff"| earlyRollback["Release acquired tracking and reservations"] + start --> live["Live handle"] + lifecycle -.->|"failure or owner loss before a handle escapes"| dispose["Join the lifecycle cleanup boundary"] + live -->|"dispose or either owner unloads"| dispose + + subgraph teardown["Reverse-order teardown"] + dispose --> barrier["Wait for synchronous publication to unwind"] + barrier --> drain["Stop driver and complete final flushes"] + drain --> detach["Detach agent, then session"] + detach --> scope["Dispose agent scope to quiescence"] + scope --> release["Release session and agent IDs"] + end +``` + The [public lifecycle contract](2026-07-08-agent-scope-contexts.md#creation-publishes-after-setup-disposal-revokes-after-work-stops) defines what callers observe. The following sections justify each ownership and ordering fact behind that contract. ### Reservations precede awaiting; lifecycle ownership precedes setup @@ -429,6 +460,24 @@ Before agent setup can run, the concrete agent pins its accepted ID, options, an `send()` and running `steer()` resolve the message source once and materialize `{ content, source }` as one detached, deeply frozen lossless-JSON record before `agent/queued` or inbox insertion. The notification and FIFO share that accepted content and source; its metadata wrapper is frozen separately, so neither retained caller references nor an earlier notification listener can rewrite what a later listener, the session log, or the model sees. Invalid content or source throws synchronously without notification, enqueue, or loop wakeup; idle `steer()` delegates to the same `send()` boundary. The later `agent/prompt-submit` waterfall can still replace a queued prompt by returning new content; ownership forbids in-place mutation, not the explicit rewrite protocol. +The inbox path makes that accepted-value boundary concrete. Getter evaluation happens during materialization, so liveness is rechecked before the accepted record crosses into an inbox FIFO: + +```mermaid +flowchart TB + callerInput["Caller-owned content and source"] --> initialCheck["Require a live, drive-enabled agent"] + initialCheck --> accept["Resolve source once; materialize and deep-freeze one record"] + accept -->|"invalid lossless JSON"| invalidReject["Throw synchronously; no inbox insertion, agent/queued, or loop wakeup"] + accept -->|"accepted"| liveness["Recheck disposal after caller getters"] + liveness -->|"disposed reentrantly"| disposedReject["Throw disposed; do not insert or announce the message"] + liveness -->|"still live"| inbox["Insert the record into the queued or steering FIFO"] + inbox -->|"same frozen content and source"| queued["Emit agent/queued with a frozen metadata wrapper"] + inbox -->|"if later drained, read the same owned record"| drain["Loop-owned delivery"] + inbox -->|"cancel before drain"| cancelled["Clear the pending record without delivery"] + inbox -->|"disposal wins before drain"| disposed["Stop delivery; the disposed agent may retain the pending record"] + drain -->|"queued prompt"| prompt["agent/prompt-submit may block or explicitly replace"] + drain -->|"steering consumed by an active turn"| steering["Append steering/message"] +``` + A stateful getter shows why validation and ownership must use the same capture: ```js From 50873b8bd0c03fccefc2b063c72cf7073db955aa Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:10:04 +0800 Subject: [PATCH 52/64] docs(rfc): remove redundant lifecycle diagram --- .../2026-07-12-agent-scope-runtime-design.md | 31 ------------------- 1 file changed, 31 deletions(-) diff --git a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index 988a30968f..318c07ad31 100644 --- a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -249,37 +249,6 @@ Two services split the public API from the implementation. `AgentRegistry`, reac | Publish and start | Session, agent, and lifecycle notifications appear in order | Liveness is checked between observable phases | | Dispose | Driver drains, registries detach, scope unwinds, IDs release | All owner paths join one completion promise | -The implementation treats success, rollback, handle disposal, caller unload, and AgentLoop unload as entrances to one owned transaction rather than separate cleanup algorithms: - -```mermaid -flowchart TB - caller["Caller context owner"] --> transaction["Owned create or resume transaction"] - factory["AgentLoop structural owner"] --> transaction - - subgraph creation["Create or resume"] - transaction --> reserve["Reserve both IDs and install trackers"] - reserve --> prepare["Load persistence or prepare the session"] - prepare --> lifecycle["Install the complete caller-owned lifecycle"] - lifecycle --> setup["Await unpublished setup"] - setup --> enter["Enter session and agent registries"] - enter --> announce["Emit session/created, then agent/created"] - announce --> start["Enable driving, emit agent/session-start, start driver"] - end - - transaction -.->|"reservation, load, or preparation failure before lifecycle handoff"| earlyRollback["Release acquired tracking and reservations"] - start --> live["Live handle"] - lifecycle -.->|"failure or owner loss before a handle escapes"| dispose["Join the lifecycle cleanup boundary"] - live -->|"dispose or either owner unloads"| dispose - - subgraph teardown["Reverse-order teardown"] - dispose --> barrier["Wait for synchronous publication to unwind"] - barrier --> drain["Stop driver and complete final flushes"] - drain --> detach["Detach agent, then session"] - detach --> scope["Dispose agent scope to quiescence"] - scope --> release["Release session and agent IDs"] - end -``` - The [public lifecycle contract](2026-07-08-agent-scope-contexts.md#creation-publishes-after-setup-disposal-revokes-after-work-stops) defines what callers observe. The following sections justify each ownership and ordering fact behind that contract. ### Reservations precede awaiting; lifecycle ownership precedes setup From e8fed4fb66fcb38c768ec9a3abb4a7a92a70bb08 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:57:42 +0800 Subject: [PATCH 53/64] fix(session): contain post-commit observers --- docs/cookbook/extension-cookbook.md | 2 +- docs/cordis-catalog/events.md | 6 +- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 4 +- ...-18-agent-lifecycle-and-ownership-seams.md | 4 +- .../2026-06-30-event-domain-semantics.md | 4 +- .../2026-07-05-reconstructable-requests.md | 2 +- .../2026-07-12-agent-scope-runtime-design.md | 16 +- .../feature/2026-07-06-approval-seam.md | 2 +- ...-20-remove-agent-boundary-mirror-events.md | 2 +- packages/core/agent-loop/src/agent.ts | 36 +- packages/core/agent-loop/src/loop.ts | 114 ++----- packages/core/agent-loop/tests/agent.spec.ts | 6 +- .../agent-loop/tests/coverage-edges.spec.ts | 15 +- packages/core/agent-loop/tests/loop.spec.ts | 13 +- .../agent-loop/tests/review-fixes.spec.ts | 194 +++++++---- packages/core/session/README.md | 10 +- packages/core/session/src/index.ts | 315 +++++++++++++----- packages/core/session/tests/scoped.spec.ts | 49 +++ packages/core/session/tests/session.spec.ts | 315 +++++++++++++++++- packages/support/invariants/README.md | 4 +- packages/support/invariants/src/index.ts | 204 +++++++++--- .../invariants/tests/invariants.spec.ts | 105 +++++- packages/ui/acp/README.md | 2 +- packages/ui/acp/src/index.ts | 60 ++-- packages/ui/acp/tests/dispose.spec.ts | 6 +- packages/ui/acp/tests/turns.spec.ts | 63 ++-- packages/ui/user-approval/README.md | 2 +- packages/ui/user-approval/src/index.ts | 63 +--- .../ui/user-approval/tests/approval.spec.ts | 6 +- scripts/gen-doc-graphs.ts | 15 + 31 files changed, 1166 insertions(+), 475 deletions(-) diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index f30d38e971..a16cda8799 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -56,7 +56,7 @@ export function apply(ctx: Context) { ## A client-driver plugin (external protocol bridge) -A *client driver* is a UI plugin whose "user" is another program speaking a wire protocol rather than a human at a terminal. It owns the process's stdio (so it must run with **no stdout logger** — every non-protocol byte corrupts the stream), creates/resumes agents on demand through the `dsh-agent` factory seam, translates harness events (`session/event`, `agent/*`) into outbound protocol messages, and translates inbound requests back into `agent.send()` / `agent.cancel()`. Two harness-specific contracts make it correct: resolve each request exactly once 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: resolve each request exactly once from the durable `turn/end` session event, using `agent/status` only as defensive reconciliation against the canonical log, and tear each agent down through its `AgentHandle.dispose()` (which stops the loop, `await`s its exit, and unregisters), not just `cancel()` — disposal must *reach* quiescence, not merely request it. `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. diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index b69ed834b3..21e69f7a25 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -265,7 +265,7 @@ Source: [`packages/core/session/src/index.ts:64`](../../packages/core/session/sr ### `session/event` — emit -An event was appended to a session log (sync, fire-and-forget). This is the per-append feed a UI or invariant plugin tails. 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. +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, event: SessionEvent): void @@ -273,7 +273,7 @@ An event was appended to a session log (sync, fire-and-forget). This is the per- Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:78`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:83`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -283,7 +283,7 @@ Awaited durability checkpoint. The agent loop awaits `ctx.sessions.flush(session 'session/flush'(this: Scoped, session: Session): Promise | void ``` -Source: [`packages/core/session/src/index.ts:96`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:101`](../../packages/core/session/src/index.ts) ## `skill/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 877c10e080..67a6b7a725 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -224,7 +224,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [SessionRegistrationReservation](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:667`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:761`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 17e9e74f50..4f2aec7b44 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -27,8 +27,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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: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:78`](../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:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `skill/provider-added` | `emit` | [`packages/skill/skill/src/index.ts:132`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | | `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:138`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:134`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | diff --git a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md index b727e357ee..80ca18b56f 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md +++ b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md @@ -18,7 +18,7 @@ A new `cancel()` verb on the `Agent` interface — the single public stop primit `ctx.agents.create`/`resume` (and the `AgentFactory` interface) return `AgentHandle = { agent: Agent; dispose(): Promise }`. 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 detaching the session store's private append observer 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. +**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 store-owned append observer's 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 diff --git a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md index 9375a6e248..f0ab95ca80 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md +++ b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md @@ -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. diff --git a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md index 73967c14b6..0b82e52581 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -24,7 +24,7 @@ Prefix-cache stability is corollary #1, not the headline: an append-only log pro **The loop, transmission-stateless.** Per step: render assembly (every step — value comparison needs no change-signal discipline, and a section that varies per step surfaces as a *logged* header event per step instead of a silent bust) → on the instance's FIRST step only, the `agent/session-prefix` waterfall — request-ONLY messages fronting the entire derived history (a frozen empty seed, contributions returned as an extension of `next()`; the home for session-stable openers that must NOT become history — a skills catalog, an AGENTS.md digest), deep-frozen and cached on the instance so reuse is structural and the prefix cannot drift mid-session — → `agent/pre-step`, carrying the composed prefix (compaction's surface mutations land before derivation, and its pressure gate counts the prefix this instance will actually send — never a previous instance's logged one, which could under-gate a resumed/forked instance whose contributor grew) → **messages snapshot, then `step/start` appended as the next operation in the same synchronous frame** → seed the call config (first request of the instance: from `AgentOptions`, so explicit options always beat the logged baseline — fork model-overrides and resume reconfiguration stay correct; afterwards: from the folded header) → the `agent/request` waterfall, re-typed `(agent, turn, step, config: LlmCallConfig, next) → LlmCallConfig` — a frozen seed and a returned replacement are ALL a listener shapes; durable content flows through the log channels (`inject()`, steering, prompt-submit `additionalContext`, sections via `system-prompt/assemble`) — → the header event the request owes the log, carrying the prefix as `messagePrefix` (no session event carries it, so the header is its only durable record; resume = a new instance = a recompose, anchored by its `'resume'` snapshot) → build `GenerateOptions` from `messagePrefix + snapshot` + header, deep-freeze (`deepFreeze` exempts the `AbortSignal`, the one live control channel — freezing one breaks `AbortController.abort()`), dispatch. The loop's per-instance bookkeeping is one boolean plus the cached prefix: whether this instance has logged its anchoring snapshot, and what it composed. -**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. diff --git a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index 318c07ad31..35d1b50c16 100644 --- a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -499,7 +499,19 @@ Factory and backend registration use different reentrancy orderings around the s ### Durable session ownership carries the scope key -The [session-immutability RFC](2026-06-11-dev-invariants-over-deep-readonly.md#session-owns-immutable-history) owns header, event, and snapshot semantics. Agent-scope correctness adds one requirement: the store keeps append observers, accepted registry IDs, and captured scope carriers in private owner state rather than caller-writable fields. Outside JavaScript therefore cannot rename a stored session or redirect later `session/event` delivery by mutating visible state. +The [session-immutability RFC](2026-06-11-dev-invariants-over-deep-readonly.md#session-owns-immutable-history) owns header, event, and snapshot semantics. Agent-scope correctness adds one requirement: the store keeps append publication, accepted registry IDs, and captured scope carriers in private owner state rather than caller-writable fields. Outside JavaScript therefore cannot rename a stored session or redirect later `session/event` delivery by mutating visible state. + +An entered session treats append as one synchronous acceptance-and-publication boundary: + +1. Capture the current store attachment and its private attachment epoch, keep the attachment live, then materialize and deep-freeze the caller's event data. +2. Reject if caller getters changed either value; the epoch catches even a transient attach-then-detach that restores the original hook lookup. The event must not become live without the store hooks that accepted it. +3. Resolve the exact scoped `session/event` callback list before commit. Cordis runs `internal/dispatch` during this step, so development invariants can still reject a bad candidate while the log is unchanged. Resolution uses a throwaway mutable argument array; replacing its accepted session or event rejects before commit, and product callbacks later receive a fresh fixed tuple. +4. Push the event into the log. This is the commit point. +5. Invoke the captured callbacks with per-listener containment and best-effort non-throwing failure reporting, then release the attachment barrier and honor any detach requested during acceptance or publication. + +The boundary rejects a reentrant `append()` until the outer callback list drains. Without that guard, an early observer could append event N+1 before a later persistence observer had received event N, reversing delivery relative to the log. Detach is deferred for the same interval, so no event can commit after `session/disposed` or lose its publication hooks. Once the push occurs, synchronous observer throws and returned-promise rejections are logged and contained rather than escaping as a false append failure or starving later observers. + +`SessionStore.flush()` uses the same pre-dispatch fixed-tuple check but remains an awaited durability barrier rather than an observe-only publication. It starts every captured listener synchronously, converts a synchronous throw into that listener's rejected result so later listeners still start, waits for every result to settle, and only then rejects with the first failed listener in registration order. One broken backend therefore cannot make the caller return while another backend is still flushing. Approval requests follow the same async boundary at smaller scale: one capture preserves exact agent/signal identities, copies scalar fields, captures the session once, and drives `approval/asked`, scoped policy, cancellation, and `approval/decided` from that record. @@ -915,7 +927,7 @@ The marker is compile-time only; JavaScript, casts, and direct Cordis dispatch c ### Development invariants inspect actual dispatch -The invariants plugin observes Cordis's internal dispatch before listener delivery. Every scoped event requires a marked carrier, and events whose arguments expose the subject require the carrier key to be the same object. +The invariants plugin uses Cordis's internal dispatch as the pre-delivery enforcement point. Every scoped event requires a marked carrier, and events whose arguments expose the subject require the carrier key to be the same object. For `session/event`, callback resolution also precedes the log push: the plugin validates and stages the exact candidate there, then advances its live trace only when the same committed event reaches its contained post-commit listener. A later internal check can therefore veto without advancing either log or trace. Both halves of this oracle are explicitly global, so mounting the plugin under a scoped context cannot stage a foreign event without also applying its committed transition. Session and subagent payloads do not expose their owner key directly, so their service centralizes key selection and the invariant proves carrier presence. Additional invariants reject an assembly whose `agent` and `scope` disagree and a turn opened before `agent/session-start`. diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md index 1257554d30..0579bf2629 100644 --- a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md @@ -49,7 +49,7 @@ The `escalation-rejected` twin ends in `{"outcome": "rejected"}` instead: nothin #### The seam: mechanism and policy split -`ApprovalService.request(req)` always resolves to a closed `ApprovalOutcome` — `allowed-once` / `rejected` / `cancelled` / `unavailable` — and never rejects. The service synchronously snapshots and shallow-freezes the accepted request before its first asynchronous boundary: scalar fields are copied while the agent and `AbortSignal` remain exact identity capabilities, so later caller mutation cannot redirect scope, payload, cancellation, or either audit event. The service dispatches the `approval/request` waterfall, races the captured signal (abort settles `cancelled`; a late answer is discarded, never double-audited), contains a throwing answerer as `unavailable`, normalizes a rogue non-vocabulary return to `unavailable`, and lands the log-only audit pair `approval/asked`/`approval/decided` (paired by the branded `ApprovalRequestId`) on the captured agent's captured session log. A session observer runs after an event enters the append-only log; if one throws, the service recognizes the recorded event, contains the callback failure, and completes the pair. 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. The one precondition: `request()` throws (before appending anything) when the agent's session has no open turn — the audit pair must be turn-enclosed, the turn being the durable log's commit/replay boundary (a bare event between turns is dropped as crash tail on reload); every ask path runs mid-turn already, and idle asks are a deferred design. +After request validation and a successful `approval/asked` append, the answerer phase always resolves to a closed `ApprovalOutcome` — `allowed-once` / `rejected` / `cancelled` / `unavailable`. The service synchronously snapshots and shallow-freezes the accepted request before its first asynchronous boundary: scalar fields are copied while the agent and `AbortSignal` remain exact identity capabilities, so later caller mutation cannot redirect scope, payload, cancellation, or either audit event. The service dispatches the `approval/request` waterfall, races the captured signal (abort settles `cancelled`; a late answer is discarded, never double-audited), contains a throwing answerer as `unavailable`, normalizes a rogue non-vocabulary return to `unavailable`, and lands the log-only audit pair `approval/asked`/`approval/decided` (paired by the branded `ApprovalRequestId`) on the captured agent's captured session log. Request acceptance and either pre-commit audit append may still reject; returning a decision that could not be logged would violate the pair. Session owns post-commit observer containment, so a callback failure cannot turn an authoritative audit append into a rejected request or suppress the matching event. Grants are one-shot by definition: `allowed-once` authorizes the single asked-about action, never a class of future ones, and the service stores nothing between requests. `request()` also throws before appending anything when the agent's session has no open turn — the audit pair must be turn-enclosed, the turn being the durable log's commit/replay boundary (a bare event between turns is dropped as crash tail on reload); every ask path runs mid-turn already, and idle asks are a deferred design. 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. diff --git a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md index e4b72ab5d1..dbef22f3a3 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md +++ b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md @@ -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. diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 16f10168b6..a449e6c907 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -275,39 +275,21 @@ export class ReactLoopAgent implements Agent { // No turn open: wrap the injection in a one-shot turn so every event stays // turn-enclosed (the durability/replay boundary is the turn). const turn = lastTurnNumber(this.session) + 1 - // Once turn/start enters the log, a turn/end is OWED no matter what — even - // if a throwing `session/event` listener escapes from the turn/start append - // (Session.append pushes the event BEFORE notifying listeners) or the - // context/message append throws (non-serializable content, throwing - // listener). The finally re-checks the log via isTurnOpen() and closes the - // turn if one was actually opened, so the log never carries a permanently - // open injection turn that would corrupt later turns/replay. (If the - // turn/start append throws BEFORE pushing — non-serializable trigger, which - // can't happen for our fixed trigger — no turn was opened and none is owed.) + // Once turn/start enters the log, a turn/end is owed even if the message + // append fails acceptance or pre-commit validation. The finally re-checks + // the log and closes only a turn that actually opened; post-commit observers + // are contained by Session and cannot create a false append failure. try { this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) this.session.append('context/message', { content, source }, { surfaceOp: 'append' }) } finally { - // Close the turn if turn/start made it into the log. Contain a throwing - // turn/end listener: Session.append pushes before notifying, so a throw - // here still leaves turn/end in the log (the turn is balanced) — swallow - // it so it neither replaces the original exception nor skips the flush - // decision below. (It surfaces through the flush path is not needed; the - // turn-balance contract is what matters and it holds.) + // Close the turn if turn/start made it into the log. A pre-commit veto + // must escape rather than being mistaken for a committed turn/end. if (isTurnOpen(this.session)) { - try { - this.session.append('turn/end', { turn, reason: { kind: 'completed' } }) - } catch { - // turn/end is already in the log (pushed before the listener threw), - // so the turn is balanced; the throw is the listener's bug. - } + this.session.append('turn/end', { turn, reason: { kind: 'completed' } }) } - // Decide the durability checkpoint from the LOG, not a flag: a turn was - // recorded iff this turn's turn/start is logged (it may have been closed - // by a throwing-listener turn/end above, which still counts). A - // `turnRecorded` boolean set after append('turn/end') would be skipped by - // a throwing turn/end listener, losing the flush for a balanced in-memory - // turn (crash before the next turn/dispose would drop the idle injection). + // Decide the durability checkpoint from the log: an accepted one-shot + // turn must be flushed even when its message append was the failing step. const turnRecorded = this.session.events.some(e => e.type === 'turn/start' && e.data.turn === turn) // Checkpoint the one-shot turn for durability, exactly as the loop does at // every turn/end. The loop is NOT running (we are idle), so nothing else diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 756c007c45..982fa02cab 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -291,15 +291,14 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH // the previous turn/end), where the persistence backend drops it as a // crash tail (the turn-enclosure RFC). Report via agent/error + the logger only; the // driver survives and moves on. - /* v8 ignore start -- defensive internal-corruption backstop: public - * send/steer input is accepted as lossless JSON before enqueue, and - * runTurn contains every failure after turn/start. */ + // Acceptance and internal dispatch validation can reject before + // turn/start commits. Report that supported pre-turn failure without + // inventing a turn/end for a turn that never opened. const err = toError(error) ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${err.message}`) try { events.emit('agent/error', turn, 0, err) } catch { /* contained: a throwing agent/error listener must not kill the driver */ } - /* v8 ignore stop */ } // Reset the cancel marker UNCONDITIONALLY here, after the turn returns and @@ -346,34 +345,14 @@ async function runTurn( let errorReported = false let terminalStopped = false - // Close the open step exactly once (idempotent via stepOpen). Step boundaries - // are durable session events only — there is no agent/* step emit to mirror - // them (see the agent event-domain rule). A throwing step/end session-event - // listener must not abort finalization and strand the turn open (turn/end - // balance > notifying one bad listener); it is contained and surfaced as a - // turn error below. - const closeStep = (): boolean => { - if (!stepOpen) return false + // Close the open step exactly once (idempotent via stepOpen). Post-commit + // session/event observers are contained by Session; a pre-commit validator + // failure still escapes so the outer recovery path may retry the boundary or + // fail loudly without pretending an uncommitted step/end exists. + const closeStep = (): void => { + if (!stepOpen) return + session.append('step/end', { turn, step }) stepOpen = false - // Session.append pushes step/end BEFORE notifying session/event listeners, - // so a throwing listener leaves step/end in the log (balance holds) but - // would otherwise abort finalization. Contain it and surface it as a turn - // error below. - let failure: unknown - try { - session.append('step/end', { turn, step }) - } catch (error: unknown) { - failure = error - } - // A throwing step/end session-event listener surfaces as a turn error via - // failTurn (idempotent). This prevents a throwing listener from producing a - // silent "completed" turn when the step itself succeeded, AND keeps - // finalization going when closeStep runs from the outer catch. - if (failure !== undefined) { - failTurn(toError(failure)) - return true - } - return false } // Record a step/turn failure exactly once: set the error reason (carrying the @@ -385,12 +364,9 @@ async function runTurn( const failTurn = (err: CodedError): void => { if (errorReported) return errorReported = true - // The turn is always still open here: the only failure that can reach - // failTurn once turn/end is appended would be a throwing turn-boundary - // listener, and turn boundaries are durable session events with no agent/* - // mirror to throw. A throwing `turn/end` session-event listener is already - // contained inside closeTurn (append pushes before notifying, so the - // boundary is durable). So set the error reason for closeTurn to append. + // The turn is still open here. Post-commit observers cannot escape append, + // and a pre-commit turn/end veto leaves no closing boundary to overwrite. + // Set the reason that the next successful closeTurn will append. reason = { kind: 'error', step, ...errorData(err) } try { events.emit('agent/error', turn, step, err) @@ -400,30 +376,17 @@ async function runTurn( } } - // Close the turn. Called exactly once per turn — the normal loop exit and the - // outer catch are mutually exclusive paths, and this never throws (the append - // is contained below), so there is no re-entry to guard against (unlike - // closeStep, which the cancel branches and the outer catch can both reach). - // Turn boundaries are durable session events only — there is no agent/* turn - // emit to mirror them (see the agent event-domain rule). + // Close the turn. Post-commit observer failures are contained by Session; + // pre-commit validation failures escape to recovery instead of being mistaken + // for a committed boundary. Turn boundaries are durable session events only. const closeTurn = (): void => { - // Session.append pushes turn/end BEFORE notifying session/event listeners, - // so a throwing listener leaves turn/end in the log (the turn is balanced) - // but would otherwise escape — from the outer catch it would propagate to - // the runLoop backstop. Contain it: the boundary is durable either way, and - // finalization must not abort on a bad listener. - try { - session.append('turn/end', { turn, reason }) - } catch (error: unknown) { - ctx.logger.warn(`agent "${agent.id}": session/event listener threw on turn/end at turn ${turn}: ${toError(error).message}`) - } + session.append('turn/end', { turn, reason }) } try { // --- Turn boundary. Once turn/start is appended, a turn/end is owed no - // matter what throws below; the catch + closeTurn guarantee it (the catch - // decides "owed" from the log via isTurnOpen, so even a throwing turn/start - // listener — append pushes before notifying — still gets its turn/end). + // matter what throws below; the catch + closeTurn guarantee it. A pre-commit + // veto leaves no turn/start in the log and therefore owes no turn/end. session.append('turn/start', { turn, trigger }) // Each drained queued message runs the `agent/prompt-submit` waterfall before // it becomes a `user/message` — a hook can rewrite the prompt or block it. @@ -581,20 +544,19 @@ async function runTurn( // messages are snapshotted HERE, in the same synchronous frame as the // step/start append directly below — so the snapshot is exactly the // derivation over the log prefix strictly before step/start's seq. - // Anything appended later — by a step/start session/event listener, an - // agent/request-window inject(), any concurrent task — lands after the - // boundary and joins the NEXT request. An external reconstructor + // Anything appended later by the request-window inject seam or a + // concurrent task lands after the boundary and joins the NEXT request. + // session/event itself is observe-only: append reentrancy is rejected + // until the current callback list drains. An external reconstructor // recovers these exact messages by folding the surface over // events[0..stepStartSeq). const boundaryMessages = session.deriveMessages() - // Mark the step open BEFORE the append: Session.append pushes the event - // to the log before notifying session/event listeners, so a THROWING - // step/start listener leaves step/start in the log. Setting stepOpen first - // means the outer catch's closeStep() then appends the balancing step/end - // (turn stays enclosed) instead of stranding an open step under turn/end. - stepOpen = true session.append('step/start', { turn, step }) + // Only a committed step/start creates a balancing obligation. A + // pre-commit veto throws before this assignment; post-commit observers + // are contained inside Session.append(). + stepOpen = true // Cancel landing in the step-start window: a synchronous `session/event` // step/start listener can cancel after the step is already open. Check @@ -647,7 +609,7 @@ async function runTurn( // Steering that arrived during streaming/tool execution. const steered = drainSteering(agent, handle.inbox, turn) - if (closeStep()) break + closeStep() const defaultDecision: ContinuationDecision = { action: stepOutcome.hadToolCalls || steered ? 'continue' : 'stop' } let decision: ContinuationDecision @@ -721,23 +683,11 @@ async function runTurn( // Normal / inline-error loop exit: close the turn. closeTurn() } catch (error: unknown) { - // Decide whether this turn was ever opened from the LOG, not a flag. - // Session.append pushes the event BEFORE notifying session/event listeners, - // so a throwing listener on the `turn/start` append leaves turn/start in the - // log even though execution never reached the lines after that append. - // Gating on a "turn started" boolean would skip turn/end and leave a - // permanently OPEN turn that poisons the next turn/replay (the turn-enclosure RFC). We - // check the log for THIS turn's turn/start: present means a turn/end is owed - // and the normal-exit `closeTurn()` did NOT run (we are here because a throw - // preceded it — the two `closeTurn()` sites are on mutually exclusive paths), - // so this catch appends turn/end with the disposed/error reason chosen below. - // `closeStep()` IS idempotent (guarded by `stepOpen`) — it may have run - // already in a step branch, so running it again is a safe no-op. Absent - // turn/start means the append threw BEFORE its push (a non-serializable - // trigger outside the public lossless-JSON boundary); nothing was opened, so rethrow - // to the runLoop backstop. + // Decide whether this turn opened from the LOG, not a speculative flag. A + // pre-commit validator or acceptance failure leaves no turn/start and owes + // no turn/end, so it propagates to runLoop's backstop. Once turn/start is + // present, this path balances any committed step and records the failure. const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn) - /* v8 ignore next -- defensive internal-corruption path; public inbox input is lossless JSON */ if (!turnStartLogged) throw error closeStep() // Choose the close reason. Disposal wins only if no error was already diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index be1a0fe5b5..d132d862a8 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -187,10 +187,8 @@ describe('ReactLoopAgent', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let flushes = 0 ctx.on('session/flush', () => { flushes += 1 }) - // A session/event listener that throws on the synthetic turn/end. Append - // pushes before notifying, so turn/end is in the log (turn balanced) but the - // throw must NOT skip the durability checkpoint — the flush decision is made - // from the log, not a flag set after the (throwing) append. + // Session contains a throwing post-commit turn/end observer. The accepted + // boundary still triggers the idle injection's durability checkpoint. let threw = false ctx.on('session/event', (_s, event) => { if (!threw && event.type === 'turn/end') { threw = true; throw new Error('boom turn/end') } diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index ee58fcd66e..23e5670f85 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' @@ -122,13 +123,15 @@ describe('tool JSON parse', () => { }) describe('toError normalization', () => { - it('normalizes non-Error throws from a turn/start session-event listener via toError', async () => { + it('normalizes non-Error throws from pre-commit dispatch validation via the runLoop backstop', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let threwOnce = false - ctx.on('session/event', (_session, event) => { + ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event') return + const event = args[1] as SessionEvent if (event.type === 'turn/start' && !threwOnce) { threwOnce = true throw 'naked string error' // non-Error throw, normalized via toError @@ -141,11 +144,9 @@ describe('toError normalization', () => { send(agent, 'go') await waitForIdle(ctx, agent) expect(errors).toHaveLength(1) - expect(errors[0]!.message).toBe('naked string error') - // A non-Error throw is wrapped in a HarnessError with code UNKNOWN, so the - // turn-end error reason carries a routable code instead of degrading. - const turnEnd = agent.session.events.find(e => e.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' && turnEnd.data.reason.code).toBe('UNKNOWN') + expect(errors[0]).toMatchObject({ message: 'naked string error', code: 'UNKNOWN' }) + expect(adapter.requests).toEqual([]) + expect(agent.session.events.some(event => event.type === 'turn/start' || event.type === 'turn/end')).toBe(false) }) it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => { diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 5815217fc4..f1c3e9af9d 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -810,10 +810,10 @@ describe('agent loop', () => { ]) }) - it('stops the turn when a step/end session-event listener failure has recorded an error', async () => { + it('contains a step/end observer failure without changing continuation', async () => { const adapter = new MockAdapter([ toolCallResponse('c1', 'echo', { text: 'x' }), - textResponse('should not run'), + textResponse('continued after tool call'), ]) const ctx = await harness(adapter) ctx.tools.register(defineTool({ @@ -826,9 +826,8 @@ describe('agent loop', () => { })) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let threw = false - // A throwing step/end session-event listener is the surviving boundary-listener - // failure path (step boundaries have no agent/* mirror): closeStep contains it - // and surfaces it as a turn error rather than stranding the turn open. + // Post-commit session observers cannot control the loop. The tool call still + // drives the second model request, and the turn completes normally. ctx.on('session/event', (_session, event) => { if (event.type === 'step/end' && !threw) { threw = true; throw new Error('bad step/end listener') } }) @@ -836,9 +835,9 @@ describe('agent loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - expect(adapter.requests).toHaveLength(1) + expect(adapter.requests).toHaveLength(2) const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('error') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed') }) it('chains queued messages into consecutive turns', async () => { diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index 4c68ac53bd..d4742da3e6 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -10,10 +10,7 @@ import { prepareReactLoopAgent } from '../src/agent.ts' import * as Invariants from '@deepseek-ai/dsh-invariants' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' -/** - * Regression tests for the findings of the first architecture review - * (Codex + sub-agent, post phase-1). Each describe block names the finding. - */ +/** Regression tests for agent-loop boundary, identity, and lifecycle contracts. */ async function harness(adapter: MockAdapter) { const ctx = new Context() @@ -682,7 +679,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete }) }) -describe('P1-6: a step/start session-event listener sees the event already in the log', () => { +describe('step boundary publication order', () => { it('the step/start event is in session.events when its session/event listener fires', async () => { const adapter = new MockAdapter([textResponse('done')]) const ctx = await harness(adapter) @@ -713,7 +710,7 @@ describe('P1-6: a step/start session-event listener sees the event already in th }) }) -describe('P1-5: a started turn (and any open step) is always closed on a boundary throw', () => { +describe('turn and step boundary recovery', () => { // Harness with the invariants plugin loaded as an oracle: it throws on // append if the log goes unbalanced (turn/end while a step is open, // turn/start while a turn is open, etc.), so a regression surfaces as an @@ -744,19 +741,13 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar } } - it('a throwing step/start session-event listener closes the open step then the turn (step/end before turn/end)', async () => { - const adapter = new MockAdapter([textResponse('never reached')]) + it('a throwing step/start observer cannot change a successful turn', async () => { + const adapter = new MockAdapter([textResponse('request completed')]) const ctx = await balancedHarness(adapter) const agent = ctx.agentLoop.create(AgentId('a-stepstart'), { model: 'mock' }) - // Step boundaries have no agent/* mirror; a throwing step/start session-event - // listener is the surviving step-boundary-listener failure. The loop marks - // the step open BEFORE appending step/start (Session.append pushes before - // notifying, so a post-push listener throw still leaves stepOpen=true), so - // the outer catch's closeStep() appends the balancing step/end — the turn - // stays enclosed. The invariants oracle (balancedHarness) rejects any - // imbalance, so a green run proves turn/start → step/start → step/end → - // turn/end nesting holds. + // Session owns post-commit containment. The loop sees a successful append, + // runs the request, and balances the ordinary step and turn boundaries. let threw = false ctx.on('session/event', (_s, event) => { if (event.type === 'step/start' && !threw) { threw = true; throw new Error('boom step-start') } @@ -769,8 +760,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar const e = [...agent.session.events] const c = boundaryCounts(agent) - expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 1 }) - expect(errors.map(x => x.message)).toEqual(['boom step-start']) + expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 0 }) + expect(errors).toEqual([]) // step/end precedes turn/end (the invariants oracle would reject // turn/end-while-step-open, but assert the order explicitly too). const stepEndIdx = e.findIndex(x => x.type === 'step/end') @@ -779,6 +770,101 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar expect(stepEndIdx).toBeLessThan(turnEndIdx) }) + it('a pre-commit step/start validation failure does not invent a step boundary', async () => { + const adapter = new MockAdapter([textResponse('never reached')]) + const ctx = await balancedHarness(adapter) + const agent = ctx.agentLoop.create(AgentId('a-stepstart-veto'), { model: 'mock' }) + let rejected = false + ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event') return + const event = args[1] as SessionEvent + if (event.type === 'step/start' && !rejected) { + rejected = true + throw new Error('reject step-start before commit') + } + }) + const errors: Error[] = [] + ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + expect(adapter.requests).toEqual([]) + expect(boundaryCounts(agent)).toMatchObject({ + turnStart: 1, + turnEnd: 1, + stepStart: 0, + stepEnd: 0, + errors: 1, + }) + expect(errors.map(error => error.message)).toEqual(['reject step-start before commit']) + }) + + it('a one-shot turn/end validation failure preserves the earlier turn error on retry', async () => { + const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider failed' } }] + const adapter = new MockAdapter([errorStream]) + const ctx = await balancedHarness(adapter) + const agent = ctx.agentLoop.create(AgentId('a-turnend-veto'), { model: 'mock' }) + let rejected = false + ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event') return + const event = args[1] as SessionEvent + if (event.type === 'turn/end' && !rejected) { + rejected = true + throw new Error('reject first turn-end') + } + }) + const errors: Error[] = [] + ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + expect(errors.map(error => error.message)).toEqual(['provider failed']) + expect(boundaryCounts(agent)).toMatchObject({ + turnStart: 1, + turnEnd: 1, + stepStart: 1, + stepEnd: 1, + errors: 1, + }) + const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ + kind: 'error', + message: 'provider failed', + }) + }) + + it('a one-shot step/end validation failure keeps the step open until retry succeeds', async () => { + const adapter = new MockAdapter([textResponse('completed before close validation')]) + const ctx = await balancedHarness(adapter) + const agent = ctx.agentLoop.create(AgentId('a-stepend-veto'), { model: 'mock' }) + let rejected = false + ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event') return + const event = args[1] as SessionEvent + if (event.type === 'step/end' && !rejected) { + rejected = true + throw new Error('reject first step-end') + } + }) + const errors: Error[] = [] + ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + expect(adapter.requests).toHaveLength(1) + expect(errors.map(error => error.message)).toEqual(['reject first step-end']) + expect(boundaryCounts(agent)).toMatchObject({ + turnStart: 1, + turnEnd: 1, + stepStart: 1, + stepEnd: 1, + errors: 1, + }) + }) + it('a throwing agent/error listener during a step-error path still balances the turn, loop survives', async () => { // First turn: model stream ends with a finish-error → step error path → // failTurn emits agent/error, whose listener throws. The turn must still @@ -841,13 +927,9 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar }) it('preserves reason disposed when a pre-step listener disposes then throws (outer-catch disposed branch)', async () => { - // Reach the OUTER catch while disposed: an `agent/pre-step` listener requests - // disposal AND throws. The throw escapes the pre-step `await` (line ~419) to - // the loop's outer catch — BEFORE the post-pre-step disposal check at ~422 - // gets to run — so the catch sees `isDisposed() && !errorReported` and must - // PRESERVE reason=disposed rather than overwrite it with the listener's throw - // (disposal is not a failure). This is the surviving path to that sub-branch - // now that there is no turn-boundary emit to throw from. + // A pre-step listener requests disposal and then throws before the ordinary + // post-listener disposal check. The outer catch sees disposal already won + // and must preserve reason=disposed rather than rewrite it as a plugin error. const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await balancedHarness(adapter) let agent!: ReactLoopAgent @@ -883,16 +965,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar expect(errorEmits).toHaveLength(0) }) - it('a throwing session/event listener on the turn/start append still balances the turn', async () => { - // Session.append pushes the event BEFORE notifying session/event listeners, - // so a listener throwing on turn/start leaves turn/start IN THE LOG. The - // loop must therefore still owe (and append) a turn/end — deciding "owed" - // from the log via isTurnOpen, not a "turn started" flag that the throw - // skipped. Otherwise the turn stays permanently open and poisons the next - // turn/replay (the turn-enclosure RFC). (Uses the plain harness — NOT the invariants - // oracle — because the throwing listener is itself a session/event - // subscriber.) - const adapter = new MockAdapter([textResponse('turn 2')]) + it('a throwing turn/start observer cannot starve the loop or later turns', async () => { + const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a-preturn'), { model: 'mock' }) @@ -906,12 +980,9 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar send(agent, 'go') await waitForIdle(ctx, agent) - // The error was surfaced exactly once via agent/error. - expect(errors.map(e => e.message)).toEqual(['boom turn/start append']) - // The turn is BALANCED: turn/start is in the log (it was pushed before the - // listener threw), so a turn/end was owed and appended — no open turn. The - // last turn-boundary event being turn/end is exactly the loop's isTurnOpen - // check (no open turn remains). + expect(errors).toEqual([]) + // Session contains the observer failure per listener, so the committed turn + // remains visible to later observers and executes normally. const types = [...agent.session.events].map(e => e.type) expect(types.filter(t => t === 'turn/start')).toHaveLength(1) expect(types.filter(t => t === 'turn/end')).toHaveLength(1) @@ -922,15 +993,10 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar // loop survives: a second turn runs normally. send(agent, 'second') await waitForIdle(ctx, agent) - expect(adapter.requests).toHaveLength(1) + expect(adapter.requests).toHaveLength(2) }) - it('a throwing step/end session-event listener during a successful step ends the turn as error, not completed', async () => { - // closeStep() must surface a throwing step/end listener via failTurn so the - // turn ends with reason error, not a silent "completed" with the throw - // swallowed. Regression test for the closeStep() catch that previously - // swallowed the throw in the normal (no-tool, no-steering) path. (Step - // boundaries have no agent/* mirror; the session-event listener is the path.) + it('a throwing step/end observer cannot rewrite the turn outcome', async () => { const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')]) const ctx = await balancedHarness(adapter) const agent = ctx.agentLoop.create(AgentId('a-stepend-throw'), { model: 'mock' }) @@ -946,11 +1012,10 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar await waitForIdle(ctx, agent) const c = boundaryCounts(agent) - // step opened and closed; exactly one error turn-end; turn balanced. - expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 1 }) - expect(errors.map(e => e.message)).toEqual(['boom step-end']) + expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 0 }) + expect(errors).toEqual([]) expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason) - .toEqual({ kind: 'error', step: 1, message: 'boom step-end' }) + .toEqual({ kind: 'completed' }) // step/end precedes turn/end (ordering contract) const e = [...agent.session.events] @@ -968,14 +1033,11 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar expect(c2.stepStart).toBe(c2.stepEnd) }) - it('a throwing session/event listener on step/end during finalization still appends turn/end', async () => { + it('a throwing step/end observer cannot interrupt error finalization', async () => { // A finish-error stream opens a step then fails it, driving finalization - // through closeStep() with the step open. closeStep appends step/end; a - // session/event listener throwing on THAT must not abort the catch before - // closeTurn — step/end is already logged (balance holds) and the throw is - // contained + surfaced via failTurn, so turn/end is still appended. (The - // failed step itself also routes through failTurn; the step/end-listener - // throw is the second, contained, failure.) + // through closeStep() with the step open. Session contains the observer + // failure after committing step/end, so closeTurn still records the model + // failure and balances the turn. const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }] const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) const ctx = await harness(adapter) @@ -996,7 +1058,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar expect(e.some(x => x.type === 'step/end')).toBe(true) expect(e.some(x => x.type === 'turn/end')).toBe(true) expect(e.at(-1)?.type).toBe('turn/end') - expect(errors.length).toBeGreaterThanOrEqual(1) // surfaced via agent/error + expect(errors.map(error => error.message)).toEqual(['provider 500']) // loop survives. send(agent, 'again') @@ -1005,12 +1067,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar }) it('a throwing session/event listener on turn/end is contained (turn still balanced, loop survives)', async () => { - // closeTurn appends turn/end; Session.append pushes it BEFORE notifying - // session/event listeners, so a throwing listener leaves turn/end in the log - // (the turn is balanced) but must not escape — from the normal-path closeTurn - // it would otherwise propagate; the append is contained so the loop continues. - // Turn boundaries are durable session events only (no agent/* mirror), so this - // session/event append-notify throw is the sole turn-end-listener failure path. + // Session contains the observer failure after committing turn/end, so the + // boundary stays authoritative and the loop continues normally. const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a-turnendappend'), { model: 'mock' }) @@ -1036,7 +1094,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar }) }) -describe('P1-7: tool/result is logged under the originating call.id, not result.callId', () => { +describe('tool result call identity', () => { it('the loop records tool/result under the model call.id even when a post-execute listener replaces content', async () => { // Model emits a tool-call with id "c1", then a final text turn. const adapter = new MockAdapter([ @@ -1116,7 +1174,7 @@ describe('surface: assistant/message omits sourceEventSeqs when no chunks stream -describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { +describe('disposal and cancellation during pre-step assembly', () => { it('disposal during system-prompt assembly drops the about-to-start step as disposed', { timeout: 30000 }, async () => { // Block `system-prompt/assemble` on a promise. Start disposal (which // calls stop() synchronously, setting status=disposed), then release the diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 7389d6fade..085ebfda06 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -9,31 +9,31 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Public API - `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log: the constructor reads each array entry once, then recursively validates and copies every nested value in one pass so validation and storage cannot observe different getter results or erase an exotic prototype before checking it. `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage, seed boundary) as the immutable `SessionHeader`: the store rejects an exotic metadata shell, reads every accepted field once, and constructs a detached, deep-frozen header. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` and persisted `seedLength` to preserve them. Disposed with the calling fiber. -- `ctx.sessions.flush(session: Session): Promise` Dispatch the awaited `session/flush` durability checkpoint with the carrier captured at enter — THE flush entry point (the loop's turn-end checkpoint and idle injection call it; never dispatch a raw `ctx.parallel`). Rejects a prepared, detached, or stale same-id object instead of inventing a subject-less carrier. +- `ctx.sessions.flush(session: Session): Promise` Dispatch the awaited `session/flush` durability checkpoint with the carrier captured at enter — THE flush entry point (the loop's turn-end checkpoint and idle injection call it; never dispatch a raw `ctx.parallel`). Every captured listener starts, the call waits for all of them to settle, and a failure rejects only after the other listeners finish. Rejects a prepared, detached, or stale same-id object instead of inventing a subject-less carrier. - `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata. - `ctx.sessions.get(id: SessionId): Session | undefined` - `ctx.sessions.list(): Session[]` #### Advanced: ordered-teardown lifecycle primitives -`create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before the store-owned append observer detaches — `create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect: +`create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before the store attachment and publication hooks are removed — `create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect: - `ctx.sessions.prepare(id?, options?): Session` — read `options.seed`/`options.meta` once, validate and detach the metadata/header, and construct the `Session` WITHOUT entering it into the store. Same options as `create`. - `ctx.sessions.reserve(id): SessionRegistrationReservation` — hold an unpublished id under the calling fiber and construct its one owned Session through `reservation.prepare(options?)`. `release` is the exact owner effect disposer, so the agent lifecycle can adopt it and keep the ID reserved until scope cleanup quiesces. Until that release, bare `prepare`/`create`/`enter` calls for the id reject; the factory later presents the exact capability to `enter`, making setup-time publication structurally impossible without leaking an abandoned reservation across HMR disposal. -- `ctx.sessions.enter(session, reservation?): () => void` — claim the ID across caller-controlled filter/carrier evaluation, then install the module-private `session/event` observer and add the exact session under its accepted key; a reentrant same-ID entry cannot be overwritten. Returns the idempotent, exact-object-guarded DETACH disposer, which clears notification, carrier, and accepted-key state without letting a stale capability delete a replacement. Does NOT emit `session/created` (the caller installs the disposer first, then calls `announce`, so a throwing listener rolls the attach back). It re-checks the id because public `prepare`/`enter` calls may be interleaved. A factory passes the opaque capability from `reserve(id)` so setup cannot enter the reserved session or publish a same-id replacement before the owning transaction. +- `ctx.sessions.enter(session, reservation?): () => void` — claim the ID across caller-controlled filter/carrier evaluation, then install the module-private append publication hooks and add the exact session under its accepted key; a reentrant same-ID entry cannot be overwritten. Returns the idempotent, exact-object-guarded DETACH disposer, which clears publication, carrier, and accepted-key state without letting a stale capability delete a replacement. Does NOT emit `session/created` (the caller installs the disposer first, then calls `announce`, so a throwing listener rolls the attach back). It re-checks the id because public `prepare`/`enter` calls may be interleaved. A factory passes the opaque capability from `reserve(id)` so setup cannot enter the reserved session or publish a same-id replacement before the owning transaction. - `ctx.sessions.announce(session): void` — begin the one allowed `session/created` announcement for an entered session; repeat and reentrant calls reject before dispatch. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, so another creation listener cannot observe `session/disposed` before its own `session/created` callback. Detach emits `session/disposed` exactly once, including rollback after a partially delivered creation notification; a never-announced entry emits neither edge. `dsh-agent-loop` is the canonical consumer: after unpublished agent setup it enters both session and agent before announcing either, then nests loop stop, agent removal, session detach, and scope unwind in one ordered lifecycle. The final flush therefore settles before this package detaches the session, whether teardown starts from an `AgentHandle` or owner-fiber unload. ### Live service events -The store pairs announced creation with disposal, publishes each append, and provides an awaited durability checkpoint. Disposal listener failures, including returned-promise rejections, are contained per observer so teardown cannot be interrupted. Exact `session/*` signatures, modes, and scope-carrier behavior live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the append-only payload vocabulary is separately generated into the [persistence catalog](../../../docs/persistence-catalog.md). Persistence consumers write behind from the append notification and drain on the store-owned flush entry point rather than dispatching the event directly. +The store pairs announced creation with disposal, publishes each append, and provides an awaited durability checkpoint. Before the log push it resolves the exact scoped `session/event` callback list, including development-time internal dispatch checks; substitution of the accepted session/event tuple rejects while the log is unchanged. The push is then the commit point, and callback throws or returned-promise rejections are logged and contained per observer. A committed append therefore returns normally, later observers still run, and teardown cannot interrupt an in-flight acceptance/publication boundary. Exact `session/*` signatures, modes, and scope-carrier behavior live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the append-only payload vocabulary is separately generated into the [persistence catalog](../../../docs/persistence-catalog.md). Persistence consumers write behind from the append notification and drain on the store-owned flush entry point rather than dispatching the event directly. ### Class: `Session` Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. -- `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. **Throws** if `data` or surface metadata is not losslessly JSON-serializable (BigInt, function, symbol, undefined, `-0`, non-finite number, circular ref, or an exotic object like Map/Set/Date/class instance). One recursive validate-and-copy pass reads each nested value exactly once and produces the detached value that enters the log, so validation and durability cannot diverge through a stateful getter or a prototype-erasing clone. The accepted event and every nested value are deep-frozen before publication; the returned event and observer notification share that immutable owned record. A third parameter `opts: SurfaceIntent` carries surface metadata: `surfaceOp` and `sourceEventSeqs` are each read once, then the former controls how the event enters the surface linked list and the latter records provenance. Runtime validation accepts only `'append'` or the exact `{ op: 'replace', start, end }` record with non-negative safe-integer bounds, and provenance must be an array of non-negative safe integers; non-surface events reject either field. The marker is **required** for the five `SurfaceEventType` events (every message-producing event must declare how it joins the surface) and rejected by the compiler for non-surface types. The contract is enforced two ways: the typed overload handles a specific event literal, AND runtime checks cover widened unions and raw seed/load logs so invalid metadata can never silently enter or disappear from `deriveMessages()`. +- `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. **Throws** if `data` or surface metadata is not losslessly JSON-serializable (BigInt, function, symbol, undefined, `-0`, non-finite number, circular ref, or an exotic object like Map/Set/Date/class instance). One recursive validate-and-copy pass reads each nested value exactly once and produces the detached value that enters the log, so validation and durability cannot diverge through a stateful getter or a prototype-erasing clone. The accepted event and every nested value are deep-frozen before publication; the returned event and observer notification share that immutable owned record. An entered session pins its attachment from materialization through observer delivery, rejects if a caller getter changes that attachment, and rejects a reentrant append until the outer callback list drains; these rules prevent an event from bypassing persistence or being delivered out of log order. The log push is the commit point: a synchronous observer throw or returned-promise rejection is logged per observer and cannot turn the committed append into a caller-visible failure or starve later observers. A third parameter `opts: SurfaceIntent` carries surface metadata: `surfaceOp` and `sourceEventSeqs` are each read once, then the former controls how the event enters the surface linked list and the latter records provenance. Runtime validation accepts only `'append'` or the exact `{ op: 'replace', start, end }` record with non-negative safe-integer bounds, and provenance must be an array of non-negative safe integers; non-surface events reject either field. The marker is **required** for the five `SurfaceEventType` events (every message-producing event must declare how it joins the surface) and rejected by the compiler for non-surface types. The contract is enforced two ways: the typed overload handles a specific event literal, AND runtime checks cover widened unions and raw seed/load logs so invalid metadata can never silently enter or disappear from `deriveMessages()`. - `session.deriveMessages(): Message[]` — the LLM message history, CACHED: each surface node is projected exactly once, when first seen (O(new nodes) per call; a surface rewrite rebuilds via `surface.replaceGeneration`). Returns a fresh array snapshot per call over SHARED, deep-frozen `Message` objects — cloned once off the log at projection time, so a consumer can never mutate logged data (mutation throws). The surface is the single source of derived history — there is no raw-log fallback. - `session.deriveEventMessage(event): Message | null` — the per-event projection `deriveMessages()` folds: one event's derived message (an unfrozen clone), or `null` when it produces none (a non-surface event, or an empty-content `assistant/message` hosting only usage). External reconstructors and the dev invariant fold the same function over a log prefix's surface, so no two paths can disagree about what a request's messages were (the reconstructability RFC). - `session.surface: SurfaceManager` — the derived surface, lazily rebuilt from `surfaceOp` markers in the log. Processes only new events (delta) on each access — the log is append-only, so prior events never change. `surface.replaceGeneration` is the rewrite signal: bumped by every folded `replace` and by `invalidate()`, never reset, so an incremental consumer comparing generations cannot be fooled. diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index d8b3afb139..3cd1c61451 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -64,7 +64,12 @@ declare module 'cordis' { 'session/disposed'(this: Scoped, session: Session): void /** * 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 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 @@ -279,7 +284,62 @@ function renderThrown(value: unknown): string { } } -const appendObservers = new WeakMap void>() +/** Best-effort reporting that cannot re-expose an already-contained failure. */ +function warnContained(ctx: Context, message: string): void { + try { + ctx.logger.warn(message) + } catch { + // contained: logger failure must not turn an observe-only callback failure + // back into a caller-visible error or an unhandled promise rejection. + } +} + +type SessionCallback = (...args: unknown[]) => unknown + +/** Resolve one listener snapshot, including Cordis's internal dispatch checks. */ +function collectSessionCallbacks(ctx: Context, args: unknown[]): SessionCallback[] { + return [...ctx.events.dispatch('emit', args)] as SessionCallback[] +} + +/** Reject pre-commit dispatch instrumentation that substituted accepted values. */ +function assertDispatchTuple(name: string, actual: unknown[], expected: unknown[]): void { + if (actual.length !== expected.length || actual.some((value, index) => value !== expected[index])) { + throw new Error(`${name} internal dispatch replaced the accepted callback tuple`) + } +} + +/** Invoke one resolved observe-only listener snapshot with per-listener containment. */ +function invokeContainedSessionObservers( + ctx: Context, + name: 'session/event' | 'session/disposed', + id: SessionId, + args: unknown[], + callbacks: SessionCallback[], +): void { + for (const callback of callbacks) { + try { + const returned: unknown = callback(...args) + void Promise.resolve(returned).catch((error: unknown) => { + warnContained(ctx, `session "${id}": ${name} listener rejected: ${renderThrown(error)}`) + }) + } catch (error: unknown) { + warnContained(ctx, `session "${id}": ${name} listener threw: ${renderThrown(error)}`) + } + } +} + +interface SessionAppendHooks { + /** Keep the store attachment live through acceptance and publication. */ + begin(): void + /** Resolve the exact observer list before commit; returns its contained publisher. */ + prepareObservation(event: SessionEvent): () => void + /** Release the attachment barrier and honor a deferred detach. */ + end(): void +} + +const appendHooks = new WeakMap() +/** Identity token replaced on every store attachment or detachment. */ +const attachmentEpochs = new WeakMap() /** * An event-sourced session: an append-only log of {@link SessionEvent}s. @@ -289,6 +349,8 @@ const appendObservers = new WeakMap void>() */ export class Session { private log: SessionEvent[] = [] + /** True throughout one event's materialization, validation, commit, and publication. */ + private appendInProgress = false /** * Derived surface — a cached linked list of message-producing events. @@ -393,8 +455,11 @@ export class Session { /** * Append one typed event to the log and synchronously notify observers via - * the store-owned, module-private append observer. The hot path never blocks - * on I/O — persistence plugins buffer asynchronously. + * the store-owned, module-private publication hooks. The hot path never blocks + * on I/O — persistence plugins buffer asynchronously. Once the event enters + * the log, the append is committed: observer failures are logged and + * contained per listener, so they do not change the return value or prevent + * later listeners from observing the same accepted event. * * @param type - The event type (key of {@link SessionEventMap}). * @param data - The event payload; must be JSON-serializable. @@ -416,7 +481,9 @@ export class Session { * copies each nested value once, so a stateful getter cannot supply one value * to validation and another to storage. The event log is the durable source * of truth, so a bad event fails at the append site rather than later during - * a backend flush. + * a backend flush. A synchronous internal dispatch validation failure or an + * append reentered while this acceptance/publication boundary is open also + * rejects before the log changes. */ append( type: T, @@ -426,60 +493,87 @@ export class Session { if (typeof type !== 'string') { throw new TypeError('session event type must be a string') } - const surfaceOpts: SurfaceIntent | undefined = opts[0] - const sourceEventSeqs = surfaceOpts?.sourceEventSeqs - const surfaceOp = surfaceOpts?.surfaceOp - // Surface-eligible events MUST carry a surfaceOp marker — the surface is the - // sole source of derived history, so a marker-less message event would be - // logged yet vanish from deriveMessages(). The typed `opts` overload makes - // the marker mandatory only when `T` is a SPECIFIC SurfaceEventType literal; - // when `T` widens to the SessionEventType union (a caller iterating raw - // events: `for (const e of log) append(e.type, e.data)`), the conditional - // rest collapses to optional and the compiler stops enforcing it. Re-check - // at runtime so that loophole can't silently drop history. - const surfaceMetadata = { - ...sourceEventSeqs !== undefined ? { sourceEventSeqs } : {}, - ...surfaceOp !== undefined ? { surfaceOp } : {}, + if (this.appendInProgress) { + throw new Error('session append cannot reenter while another append is being accepted or published') } - // The caller still owns the data and metadata objects and could mutate them - // after append. Materialize each accepted value exactly once while checking - // its JSON vocabulary, so the log cannot drift and a stateful getter cannot - // show one value to validation and another to a prototype-erasing clone. The - // returned event carries these SAME snapshots. - // - // Surface metadata accessors are read once into one plain record; the - // recursive snapshot then reads each nested value once as it copies it. - // Build the event shape with conditional surface fields via spreading. - // The result is cast through `unknown` because the conditional spreads - // produce an intersection type that the assignability checker can't - // narrow to a specific discriminated-union member when T is generic. - // This is a safe internal boundary: data and surface metadata are - // materialized below before the event enters the log. - const dataSnapshot = snapshotJsonValue(data) - if (dataSnapshot === undefined) { - throw new Error(`session event "${type}" carries non-JSON-serializable data`) + const hooks = appendHooks.get(this) + const attachmentEpoch = attachmentEpochs.get(this) + this.appendInProgress = true + try { + // Start before reading caller-owned fields: a getter may request detach + // or try to append reentrantly. The attachment and sequence boundary stay + // stable until this exact acceptance attempt has either failed or reached + // every post-commit observer. + hooks?.begin() + const surfaceOpts: SurfaceIntent | undefined = opts[0] + const sourceEventSeqs = surfaceOpts?.sourceEventSeqs + const surfaceOp = surfaceOpts?.surfaceOp + // Surface-eligible events MUST carry a surfaceOp marker — the surface is the + // sole source of derived history, so a marker-less message event would be + // logged yet vanish from deriveMessages(). The typed `opts` overload makes + // the marker mandatory only when `T` is a SPECIFIC SurfaceEventType literal; + // when `T` widens to the SessionEventType union (a caller iterating raw + // events: `for (const e of log) append(e.type, e.data)`), the conditional + // rest collapses to optional and the compiler stops enforcing it. Re-check + // at runtime so that loophole can't silently drop history. + const surfaceMetadata = { + ...sourceEventSeqs !== undefined ? { sourceEventSeqs } : {}, + ...surfaceOp !== undefined ? { surfaceOp } : {}, + } + // The caller still owns the data and metadata objects and could mutate them + // after append. Materialize each accepted value exactly once while checking + // its JSON vocabulary, so the log cannot drift and a stateful getter cannot + // show one value to validation and another to a prototype-erasing clone. The + // returned event carries these SAME snapshots. + // + // Surface metadata accessors are read once into one plain record; the + // recursive snapshot then reads each nested value once as it copies it. + // Build the event shape with conditional surface fields via spreading. + // The result is cast through `unknown` because the conditional spreads + // produce an intersection type that the assignability checker can't + // narrow to a specific discriminated-union member when T is generic. + // This is a safe internal boundary: data and surface metadata are + // materialized below before the event enters the log. + const dataSnapshot = snapshotJsonValue(data) + if (dataSnapshot === undefined) { + throw new Error(`session event "${type}" carries non-JSON-serializable data`) + } + const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata) + if (surfaceMetadataSnapshot === undefined) { + throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`) + } + assertSurfaceMetadataShape( + type, + (surfaceMetadataSnapshot as { surfaceOp?: unknown }).surfaceOp, + (surfaceMetadataSnapshot as { sourceEventSeqs?: unknown }).sourceEventSeqs, + ) + if (appendHooks.get(this) !== hooks || attachmentEpochs.get(this) !== attachmentEpoch) { + throw new Error('session attachment changed while append input was being accepted') + } + const event = { + type, + seq: this.log.length, + time: Date.now(), + data: dataSnapshot, + ...surfaceMetadataSnapshot, + } as unknown as SessionEvent + const acceptedEvent = deepFreeze(event) + // Resolve dispatch before the log push. Cordis runs internal/dispatch + // while producing this list; if instrumentation rejects the carrier, the + // append still fails before commit. The resolved callbacks themselves are + // observe-only and run with per-listener containment after the push. + const publish = hooks?.prepareObservation(acceptedEvent as unknown as SessionEvent) + this.log.push(acceptedEvent as unknown as SessionEvent) + this.eventsSnapshot = undefined + publish?.() + return acceptedEvent + } finally { + try { + hooks?.end() + } finally { + this.appendInProgress = false + } } - const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata) - if (surfaceMetadataSnapshot === undefined) { - throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`) - } - assertSurfaceMetadataShape( - type, - (surfaceMetadataSnapshot as { surfaceOp?: unknown }).surfaceOp, - (surfaceMetadataSnapshot as { sourceEventSeqs?: unknown }).sourceEventSeqs, - ) - const event = { - type, - seq: this.log.length, - time: Date.now(), - data: dataSnapshot, - ...surfaceMetadataSnapshot, - } as unknown as SessionEvent - const acceptedEvent = deepFreeze(event) - this.log.push(acceptedEvent as unknown as SessionEvent) - this.eventsSnapshot = undefined - appendObservers.get(this)?.(acceptedEvent as unknown as SessionEvent) - return acceptedEvent } /** Cached fold of the request-header events — see {@link requestHeader}. */ @@ -674,7 +768,9 @@ export class SessionStore extends Service { private announced = new WeakSet() /** Entries currently dispatching `session/created`; detach waits for dispatch to unwind. */ private announcing = new WeakSet() - /** A detach requested reentrantly from `session/created`. */ + /** Entries accepting or publishing an append; detach waits for the boundary to unwind. */ + private appending = new WeakSet() + /** A detach requested reentrantly from creation or append publication. */ private pendingDetach = new WeakSet() /** Unpublished identities held across factory load/setup transactions. */ private reservations = new Map() @@ -746,7 +842,7 @@ export class SessionStore extends Service { * fills `version`/`id`/`createdAt`). * * For an agent whose session must be torn down IN ORDER with its loop (so the - * loop's final flush is captured before the store-owned observer detaches), do NOT use this + * loop's final flush is captured before the store attachment ends), do NOT use this * — fold the session lifecycle into the agent's own effect via * {@link prepare} + {@link enter} + {@link announce} (see `dsh-agent-loop`'s * `startOwned`). @@ -763,7 +859,7 @@ export class SessionStore extends Service { // Single effect owned by the calling fiber. Yield the detach BEFORE // announcing so a throwing `session/created` listener rolls the attach back // (the generator effect disposes already-yielded disposers on a throw) - // instead of leaking the store entry + append observer. + // instead of leaking the store entry and its publication hooks. this.ctx.effect(function* (this: SessionStore) { yield this.enter(session) this.announce(session) @@ -777,7 +873,7 @@ export class SessionStore extends Service { * Pairs with {@link enter} + {@link announce}: a caller that owns a composite * `ctx.effect` (the agent factory) folds the session lifecycle into that ONE * effect so a fiber unload tears the session + agent down as a single ORDERED - * chain rather than as racing sibling effects — which would detach the append observer + * chain rather than as racing sibling effects — which would remove the publication hooks * before the loop's closing `session/flush`, dropping the closing events. * * @param id - the session id; omitted, the store mints `session-`. @@ -827,9 +923,9 @@ export class SessionStore extends Service { } /** - * Enter a {@link prepare}d session into the store: wire the module-private - * append observer to `session/event` and add it to the store. Returns the - * DETACH disposer (observer + store removal). Does NOT emit `session/created` — + * Enter a {@link prepare}d session into the store: install the module-private + * append publication hooks and add it to the store. Returns the DETACH + * disposer (hooks + store removal). Does NOT emit `session/created` — * the caller yields this disposer inside its effect and THEN calls * {@link announce}, so a throwing `session/created` listener rolls the attach * back instead of leaking it. @@ -845,7 +941,7 @@ export class SessionStore extends Service { * @param session - a {@link prepare}d session not yet in the store. * @param reservation - the exact unpublished-id capability when a factory * reserved this session across setup. - * @returns the detach disposer (observer + store removal). When called from + * @returns the detach disposer (publication hooks + store removal). When called from * a synchronous `session/created` listener, removal and disposal wait until * that creation dispatch unwinds. * @throws if a session with this id is already in the store. @@ -863,7 +959,7 @@ export class SessionStore extends Service { if (this.store.has(id) || this.enteringIds.has(id)) { throw new Error(`session "${id}" already exists`) } - if (appendObservers.has(session)) throw new Error(`session "${id}" is already attached to a store`) + if (appendHooks.has(session)) throw new Error(`session "${id}" is already attached to a store`) this.enteringIds.add(id) // The carrier is decided HERE, once, from the ENTERING context's scope tag // (`this.ctx` is the caller's context — the tracker mechanism): every @@ -889,20 +985,39 @@ export class SessionStore extends Service { } /* v8 ignore next 1 -- enteringIds prevents a same-store commit during carrier construction */ if (this.store.has(id)) throw new Error(`session "${id}" already exists`) - if (appendObservers.has(session)) throw new Error(`session "${id}" is already attached to a store`) + if (appendHooks.has(session)) throw new Error(`session "${id}" is already attached to a store`) this.carriers.set(session, carrier) const emitCtx = this.ctx - appendObservers.set(session, (event) => { emitCtx.emit(carrier, 'session/event', session, event) }) + appendHooks.set(session, { + begin: () => { this.appending.add(session) }, + prepareObservation(event) { + // Cordis removes carrier/name in place and exposes the remaining array + // to internal/dispatch. Resolve with a throwaway array so an internal + // checker cannot replace the tuple later observers receive. + const dispatchArgs: unknown[] = [carrier, 'session/event', session, event] + const callbackArgs: unknown[] = [session, event] + const callbacks = collectSessionCallbacks(emitCtx, dispatchArgs) + assertDispatchTuple('session/event', dispatchArgs, callbackArgs) + return () => { invokeContainedSessionObservers(emitCtx, 'session/event', id, callbackArgs, callbacks) } + }, + end: () => { + this.appending.delete(session) + if (this.pendingDetach.has(session) && !this.announcing.has(session)) { + this.detachEntered(session, id, carrier) + } + }, + }) + attachmentEpochs.set(session, {}) this.acceptedIds.set(session, id) this.store.set(id, session) let entered = true const detach = (): void => { if (!entered) return entered = false - // A creation listener may own the advanced detach capability. Keep the - // entry and its event observer live until the synchronous creation - // dispatch unwinds, then publish the paired disposal edge. - if (this.announcing.has(session)) { + // A lifecycle listener may own the advanced detach capability. Keep the + // entry and its publication hooks live until synchronous creation or append + // publication unwinds, then publish the paired disposal edge. + if (this.announcing.has(session) || this.appending.has(session)) { this.pendingDetach.add(session) return } @@ -920,7 +1035,8 @@ export class SessionStore extends Service { * remains the exact-identity backstop against future mutation paths */ if (this.store.get(id) !== session || this.acceptedIds.get(session) !== id) return const wasAnnounced = this.announced.delete(session) - appendObservers.delete(session) + appendHooks.delete(session) + attachmentEpochs.set(session, {}) this.acceptedIds.delete(session) this.carriers.delete(session) this.store.delete(id) @@ -943,38 +1059,40 @@ export class SessionStore extends Service { // throw. Rollback must still pair that partial creation with disposal, and // a listener cannot recursively create a second lifecycle edge. this.announced.add(session) - const args: unknown[] = [carrier, 'session/created', session] + const dispatchArgs: unknown[] = [carrier, 'session/created', session] + const callbackArgs: unknown[] = [session] this.announcing.add(session) try { - for (const callback of this.ctx.events.dispatch('emit', args)) { + const callbacks = collectSessionCallbacks(this.ctx, dispatchArgs) + assertDispatchTuple('session/created', dispatchArgs, callbackArgs) + for (const callback of callbacks) { // Synchronous throws intentionally propagate and veto publication; the // yielded detach then emits the paired disposal edge. An async function // is nevertheless assignable to a void listener, so observe its returned // promise: rejection is too late to roll back and must be logged instead // of becoming unhandled. - const returned: unknown = callback(...args) + const returned: unknown = callback(...callbackArgs) void Promise.resolve(returned).catch((error: unknown) => { - this.ctx.logger.warn(`session "${id}": session/created listener rejected: ${renderThrown(error)}`) + warnContained(this.ctx, `session "${id}": session/created listener rejected: ${renderThrown(error)}`) }) } } finally { this.announcing.delete(session) - if (this.pendingDetach.has(session)) this.detachEntered(session, id, carrier) + if (this.pendingDetach.has(session) && !this.appending.has(session)) { + this.detachEntered(session, id, carrier) + } } } /** Emit the paired teardown notification with per-listener containment. */ private emitDisposed(session: Session, carrier: Scoped, id: SessionId): void { - const args: unknown[] = [carrier, 'session/disposed', session] - for (const callback of this.ctx.events.dispatch('emit', args)) { - try { - const returned: unknown = callback(...args) - void Promise.resolve(returned).catch((error: unknown) => { - this.ctx.logger.warn(`session "${id}": session/disposed listener rejected: ${renderThrown(error)}`) - }) - } catch (error: unknown) { - this.ctx.logger.warn(`session "${id}": session/disposed listener threw: ${renderThrown(error)}`) - } + const dispatchArgs: unknown[] = [carrier, 'session/disposed', session] + const callbackArgs: unknown[] = [session] + try { + const callbacks = collectSessionCallbacks(this.ctx, dispatchArgs) + invokeContainedSessionObservers(this.ctx, 'session/disposed', id, callbackArgs, callbacks) + } catch (error: unknown) { + warnContained(this.ctx, `session "${id}": session/disposed dispatch threw: ${renderThrown(error)}`) } } @@ -986,10 +1104,27 @@ export class SessionStore extends Service { * raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the * scoped-dispatch invariant can pin it. * @param session - the session whose buffered events must reach durable storage. - * @returns resolves when every flush listener has settled; rejects if one rejects. + * @returns resolves when every flush listener has settled; after all settle, + * rejects with the first registered listener failure if any listener failed. */ async flush(session: Session): Promise { - await this.ctx.parallel(this.liveEntryFor(session).carrier, 'session/flush', session) + const { carrier } = this.liveEntryFor(session) + const dispatchArgs: unknown[] = [carrier, 'session/flush', session] + const callbackArgs: unknown[] = [session] + const callbacks = collectSessionCallbacks(this.ctx, dispatchArgs) + assertDispatchTuple('session/flush', dispatchArgs, callbackArgs) + const results = await Promise.allSettled(callbacks.map((callback) => { + try { + return callback(...callbackArgs) + } catch (error: unknown) { + // Preserve the listener's exact rejection value; flush is a caller-owned + // failure boundary, and Cordis listeners may throw arbitrary values. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + return Promise.reject(error) + } + })) + const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected') + if (failure !== undefined) throw failure.reason } /** Return the exact live session's accepted id and carrier; detached/prepared objects reject. */ diff --git a/packages/core/session/tests/scoped.spec.ts b/packages/core/session/tests/scoped.spec.ts index 80dd8510d2..80cc08e629 100644 --- a/packages/core/session/tests/scoped.spec.ts +++ b/packages/core/session/tests/scoped.spec.ts @@ -108,6 +108,40 @@ describe('sessions.flush()', () => { await expect(ctx.sessions.flush(session)).rejects.toThrow('disk full') }) + it('does not let a synchronous flush failure starve later listeners', async () => { + const ctx = await mount() + const flushed: Session[] = [] + ctx.on('session/flush', () => { throw new Error('disk full') }) + ctx.on('session/flush', (session) => { flushed.push(session) }) + const session = ctx.sessions.create() + + await expect(ctx.sessions.flush(session)).rejects.toThrow('disk full') + expect(flushed).toEqual([session]) + }) + + it('waits for slower flush listeners before reporting another listener failure', async () => { + const ctx = await mount() + const gate = Promise.withResolvers() + let slowStarted = false + let settled = false + ctx.on('session/flush', () => Promise.reject(new Error('disk full'))) + ctx.on('session/flush', () => { + slowStarted = true + return gate.promise + }) + const session = ctx.sessions.create() + + const flushing = ctx.sessions.flush(session) + void flushing.finally(() => { settled = true }).catch(() => undefined) + await Promise.resolve() + expect(slowStarted).toBe(true) + expect(settled).toBe(false) + + gate.resolve(undefined) + await expect(flushing).rejects.toThrow('disk full') + expect(settled).toBe(true) + }) + it('rejects a never-entered session instead of inventing a carrier', async () => { const ctx = await mount() const scope = await mintScope(ctx, 'owner') @@ -120,6 +154,21 @@ describe('sessions.flush()', () => { expect(flushed).toEqual([]) }) + it('rejects internal dispatch substitution before flush callbacks run', async () => { + const ctx = await mount() + const session = ctx.sessions.create() + const replacement = ctx.sessions.create() + const flushed: Session[] = [] + ctx.on('internal/dispatch', (_mode, name, args) => { + if (name === 'session/flush') args[0] = replacement + }) + ctx.on('session/flush', (candidate) => { flushed.push(candidate) }) + + await expect(ctx.sessions.flush(session)) + .rejects.toThrow('session/flush internal dispatch replaced the accepted callback tuple') + expect(flushed).toEqual([]) + }) + it('clears a detached carrier and rejects stale flushes', async () => { const ctx = await mount() const scope = await mintScope(ctx, 'owner') diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 6edeac0084..761f89ed2b 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -702,7 +702,7 @@ describe('SessionStore', () => { const session = ctx.sessions.create() expect(created).toEqual([session]) - // The store-owned append observer is module-private. A JavaScript caller + // The store-owned append publication hooks are module-private. A JavaScript caller // may create an unrelated property with the old implementation's name, // but cannot suppress the durable event feed. expect(Reflect.set(session, 'onAppend', undefined)).toBe(true) @@ -1120,7 +1120,7 @@ describe('SessionStore', () => { expect(disposed.map(session => session.id)).toEqual(['fixed']) // A subsequent create of the SAME id succeeds (the already-exists check is - // not wedged) and its store-owned observer is correctly wired (events observable). + // not wedged) and its store-owned publication hooks are correctly wired. const events: SessionEvent[] = [] ctx.on('session/event', (_session, event) => void events.push(event)) const session = ctx.sessions.create(SessionId('fixed')) @@ -1129,6 +1129,277 @@ describe('SessionStore', () => { expect(events).toHaveLength(1) }) + it('contains session/event observer failures after the append commit point', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + const session = ctx.sessions.create(SessionId('contained-event')) + const heard: SessionEvent[] = [] + let committedBeforeNotify = false + ctx.on('session/event', (observedSession, event) => { + committedBeforeNotify = observedSession.events.at(-1) === event + throw new Error('sync event observer') + }) + ctx.on('session/event', () => Promise.reject(new Error('async event observer')) as never) + ctx.on('session/event', (_observedSession, event) => { heard.push(event) }) + + let appended!: SessionEvent + expect(() => { + appended = session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + }).not.toThrow() + expect(committedBeforeNotify).toBe(true) + expect(session.events).toEqual([appended]) + expect(heard).toEqual([appended]) + await Promise.resolve() + await Promise.resolve() + + expect(warnings).toEqual([ + 'session "contained-event": session/event listener threw: Error: sync event observer', + 'session "contained-event": session/event listener rejected: Error: async event observer', + ]) + }) + + it('runs internal dispatch validation on one frozen candidate before commit and resets after a veto', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('dispatch-veto')) + const validations: Array<{ event: SessionEvent; logLength: number; frozen: boolean }> = [] + const observed: SessionEvent[] = [] + let reject = true + ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event') return + const [observedSession, event] = args as [Session, SessionEvent] + validations.push({ + event, + logLength: observedSession.events.length, + frozen: Object.isFrozen(event) && Object.isFrozen(event.data), + }) + if (reject) { + reject = false + throw new Error('reject first candidate') + } + }) + ctx.on('session/event', (_observedSession, event) => { observed.push(event) }) + + expect(() => session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + })).toThrow('reject first candidate') + expect(session.events).toEqual([]) + expect(observed).toEqual([]) + + const appended = session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + expect(validations.map(({ logLength, frozen }) => ({ logLength, frozen }))).toEqual([ + { logLength: 0, frozen: true }, + { logLength: 0, frozen: true }, + ]) + expect(validations.map(({ event }) => event.seq)).toEqual([0, 0]) + expect(validations[1]!.event).toBe(appended) + expect(session.events).toEqual([appended]) + expect(observed).toEqual([appended]) + }) + + it('resolves session/event dispatch before commit so instrumentation failure cannot hide a logged event', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('dispatch-check')) + const observed: SessionEvent[] = [] + ctx.on('internal/dispatch', (_mode, name) => { + if (name === 'session/event') throw new Error('dispatch instrumentation rejected the carrier') + }) + ctx.on('session/event', (_observedSession, event) => { observed.push(event) }) + + expect(() => session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + })).toThrow('dispatch instrumentation rejected the carrier') + expect(session.events).toEqual([]) + expect(observed).toEqual([]) + }) + + it('rejects prepend or append instrumentation that replaces the accepted observer tuple', async () => { + for (const prepend of [true, false]) { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId(`dispatch-tuple-${prepend}`)) + const replacementSession = new Session(SessionId('replacement')) + const replacementEvent = { + type: 'turn/end', + seq: 99, + time: 1, + data: { turn: 99, reason: { kind: 'completed' } }, + } as SessionEvent + const observed: Array<{ session: Session; event: SessionEvent }> = [] + let replace = true + ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event' || !replace) return + args[0] = replacementSession + args[1] = replacementEvent + }, { prepend }) + ctx.on('session/event', (observedSession, event) => { + observed.push({ session: observedSession, event }) + }) + + expect(() => session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + })).toThrow('session/event internal dispatch replaced the accepted callback tuple') + expect(session.events).toEqual([]) + expect(observed).toEqual([]) + + replace = false + const appended = session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + expect(session.events).toEqual([appended]) + expect(observed).toEqual([{ session, event: appended }]) + } + }) + + it('rejects if a bare session becomes attached while caller data is materialized', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = new Session(SessionId('attach-during-append')) + const observed: SessionEvent[] = [] + let sessionEventDispatches = 0 + let detach!: () => void + ctx.on('internal/dispatch', (_mode, name) => { + if (name === 'session/event') sessionEventDispatches += 1 + }) + ctx.on('session/event', (_observedSession, event) => { observed.push(event) }) + const data = { + get todos(): TodoItem[] { + detach = ctx.sessions.enter(session) + ctx.sessions.announce(session) + return [] + }, + } + + expect(() => session.append('todo/write', data)) + .toThrow('session attachment changed while append input was being accepted') + expect(ctx.sessions.get(session.id)).toBe(session) + expect(session.events).toEqual([]) + expect(sessionEventDispatches).toBe(0) + expect(observed).toEqual([]) + + const appended = session.append('todo/write', { todos: [] }) + expect(session.events).toEqual([appended]) + expect(sessionEventDispatches).toBe(1) + expect(observed).toEqual([appended]) + detach() + }) + + it('rejects a transient attach and detach while caller data is materialized', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = new Session(SessionId('attach-detach-during-append')) + const lifecycle: string[] = [] + const observed: SessionEvent[] = [] + ctx.on('session/created', () => { lifecycle.push('created') }) + ctx.on('session/disposed', () => { lifecycle.push('disposed') }) + ctx.on('session/event', (_observedSession, event) => { observed.push(event) }) + const data = { + get todos(): TodoItem[] { + const detach = ctx.sessions.enter(session) + ctx.sessions.announce(session) + detach() + return [] + }, + } + + expect(() => session.append('todo/write', data)) + .toThrow('session attachment changed while append input was being accepted') + expect(ctx.sessions.get(session.id)).toBeUndefined() + expect(session.events).toEqual([]) + expect(lifecycle).toEqual(['created', 'disposed']) + expect(observed).toEqual([]) + }) + + it('contains a reentrant observer append without reordering later observers', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + const session = ctx.sessions.create(SessionId('reentrant-observer')) + const heard: SessionEvent[] = [] + ctx.on('session/event', (observedSession) => { + observedSession.append('todo/write', { todos: [] }) + }) + ctx.on('session/event', (_observedSession, event) => { heard.push(event) }) + + const appended = session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + expect(session.events).toEqual([appended]) + expect(heard).toEqual([appended]) + expect(warnings).toEqual([ + 'session "reentrant-observer": session/event listener threw: Error: session append cannot reenter while another append is being accepted or published', + ]) + }) + + it('keeps observer failures contained when warning output itself throws', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + ctx.logger.warn = (() => { throw new Error('logger unavailable') }) as typeof ctx.logger.warn + const session = ctx.sessions.create(SessionId('throwing-logger')) + const heard: SessionEvent[] = [] + ctx.on('session/event', () => { throw new Error('sync observer') }) + ctx.on('session/event', () => Promise.reject(new Error('async observer')) as never) + ctx.on('session/event', (_observedSession, event) => { heard.push(event) }) + + let appended!: SessionEvent + expect(() => { + appended = session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + }).not.toThrow() + await Promise.resolve() + await Promise.resolve() + + expect(session.events).toEqual([appended]) + expect(heard).toEqual([appended]) + }) + + it('defers detach through dispatch resolution, commit, and observer publication', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const order: string[] = [] + const session = ctx.sessions.prepare(SessionId('detach-during-append')) + const detach = ctx.sessions.enter(session) + ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event') return + const session = args[0] as Session + order.push(`resolve:${ctx.sessions.get(session.id) === session ? 'live' : 'detached'}`) + detach() + }) + ctx.on('session/event', (session) => { + order.push(`observe:${ctx.sessions.get(session.id) === session ? 'live' : 'detached'}`) + }) + ctx.on('session/disposed', (session) => { + order.push(`dispose:${ctx.sessions.get(session.id) === session ? 'live' : 'detached'}`) + }) + ctx.sessions.announce(session) + + const appended = session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + + expect(session.events).toEqual([appended]) + expect(order).toEqual(['resolve:live', 'observe:live', 'dispose:detached']) + expect(ctx.sessions.get(session.id)).toBeUndefined() + }) + it('observes async session/created rejection without rolling back or starving peers', async () => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -1181,6 +1452,46 @@ describe('SessionStore', () => { 'session "contained-disposal": session/disposed listener rejected: Error: async disposed', ]) }) + + it('contains internal dispatch failure after session detachment', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + const heard: Session[] = [] + ctx.on('internal/dispatch', (_mode, name) => { + if (name === 'session/disposed') throw new Error('disposed dispatch instrumentation') + }) + ctx.on('session/disposed', (session) => { heard.push(session) }) + const session = ctx.sessions.prepare(SessionId('disposed-dispatch')) + const detach = ctx.sessions.enter(session) + ctx.sessions.announce(session) + + expect(() => { detach() }).not.toThrow() + expect(ctx.sessions.get(session.id)).toBeUndefined() + expect(heard).toEqual([]) + expect(warnings).toEqual([ + 'session "disposed-dispatch": session/disposed dispatch threw: Error: disposed dispatch instrumentation', + ]) + }) + + it('does not let internal dispatch replace the disposed callback tuple', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const replacement = new Session(SessionId('replacement-disposed')) + const heard: Session[] = [] + ctx.on('internal/dispatch', (_mode, name, args) => { + if (name === 'session/disposed') args[0] = replacement + }) + ctx.on('session/disposed', (session) => { heard.push(session) }) + const session = ctx.sessions.prepare(SessionId('fixed-disposed-tuple')) + const detach = ctx.sessions.enter(session) + ctx.sessions.announce(session) + + detach() + + expect(heard).toEqual([session]) + }) }) describe('todo/write event', () => { diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 4f50ec42f3..c4b909773d 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -6,6 +6,8 @@ Dev-mode event-contract assertions. This pure-listener plugin checks relationshi Session itself owns immutable log storage in every composition: it takes one lossless JSON snapshot of each accepted event, deep-freezes that record, and exposes the log through immutable array snapshots. The invariants plugin checks the cross-record and cross-seam rules that storage immutability cannot express. +Session-log assertions run during Cordis `internal/dispatch`, while `Session.append()` is resolving the `session/event` callback snapshot but before it pushes the candidate into the log. A valid transition is staged by exact event identity and applied to the live trace only when that same committed event reaches the plugin's contained post-commit listener. A later internal dispatch check can therefore veto without advancing either the log or the invariant trace, while ordinary `session/event` observer failures remain observe-only. + ## Plugin A functional plugin — register the module namespace (this is what loading by name in `cordis.yml` does): @@ -19,7 +21,7 @@ declare const ctx: Context await ctx.plugin(Invariants) ``` -`inject`: `['sessions']` — it reads `ctx.sessions.list()` at apply time to rebuild trace state for sessions that already exist, so a hot reload mid-turn does not falsely reject the next event. It registers only listeners and has no configuration. +`inject`: `['sessions']` — it reads `ctx.sessions.list()` at apply time to rebuild trace state for sessions that already exist, so a hot reload mid-turn does not falsely reject the next event. The oracle listeners are explicitly global so pre-commit staging and post-commit application keep the same audience even if the plugin is mounted under a scoped context; their cleanup still belongs to that mounting fiber. The plugin has no configuration. ## Invariants asserted diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 8d8f52f3d7..20396c4f5d 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -21,7 +21,7 @@ import type { Context } from 'cordis' import { carrierKeyOf, isScopeCarrier } from '@deepseek-ai/dsh-scope' import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt' import type { ToolExecution } from '@deepseek-ai/dsh-tools' -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm' import type { CallId, GenerateOptions } from '@deepseek-ai/dsh-llm' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' @@ -70,6 +70,23 @@ interface SessionTrace { surface: number[] } +/** One accepted event's deferred mutation of a live session trace. */ +interface SessionTraceTransition { + /** Scalar state after the event commits. */ + scalars: Pick + /** The event's mutation of the open step's pending call set. */ + pendingCalls: + | { kind: 'none' } + | { kind: 'add' | 'delete'; callId: CallId } + | { kind: 'clear' } + /** The event's mutation of the derived surface order. */ + surface: + | { kind: 'none' | 'append' } + | { kind: 'replace'; start: number; count: number } + /** The committed event sequence to add to the known-sequence set. */ + seq: number +} + /** Event payload prefix for scoped seams whose first argument names its agent. */ interface AgentSubject { agent: Agent @@ -84,14 +101,19 @@ function requireOpenStep(trace: SessionTrace, kind: string, turn: number, step: } } -/** Assert one appended event against the per-session invariants. */ -function checkEvent(trace: SessionTrace, event: SessionEvent): void { +/** Validate one candidate event without mutating the committed session trace. */ +function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTransition { // seq is strictly monotonic — the spine of replay equivalence. lastSeq // starts at -1, so the first event (seq 0) passes. if (event.seq <= trace.lastSeq) { throw new InvariantError(`seq must strictly increase: saw ${event.seq} after ${trace.lastSeq}`) } - trace.lastSeq = event.seq + let openTurn = trace.openTurn + let openStep = trace.openStep + let nextTurn = trace.nextTurn + let nextStep = trace.nextStep + let pendingCalls: SessionTraceTransition['pendingCalls'] = { kind: 'none' } + let surface: SessionTraceTransition['surface'] = { kind: 'none' } // --- Surface invariants --- // Surface metadata (sourceEventSeqs, surfaceOp) is only valid on @@ -133,7 +155,7 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void { // positional range — every shadowed node must appear in sourceEventSeqs. if (se.surfaceOp !== undefined) { if (se.surfaceOp === 'append') { - trace.surface.push(event.seq) + surface = { kind: 'append' } } else { const { start, end } = se.surfaceOp const startIdx = trace.surface.indexOf(start) @@ -155,9 +177,7 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void { if (missing.length > 0) { throw new InvariantError(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`) } - // Apply the replace to the tracked surface: the new node takes the - // range's position so order stays in sync for later replaces. - trace.surface.splice(startIdx, shadowed.length, event.seq) + surface = { kind: 'replace', start: startIdx, count: shadowed.length } } } @@ -176,8 +196,8 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void { if (event.data.turn !== trace.nextTurn) { throw new InvariantError(`turn/start expected turn ${trace.nextTurn}, got ${event.data.turn}`) } - trace.openTurn = event.data.turn - trace.nextStep = 1 + openTurn = event.data.turn + nextStep = 1 break } case 'turn/end': { @@ -187,8 +207,8 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void { if (trace.openStep !== null) { throw new InvariantError(`turn/end ${event.data.turn} while step ${trace.openStep} is still open`) } - trace.openTurn = null - trace.nextTurn += 1 + openTurn = null + nextTurn += 1 break } case 'step/start': { @@ -202,16 +222,16 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void { if (event.data.step !== trace.nextStep) { throw new InvariantError(`step/start expected step ${trace.nextStep} in turn ${event.data.turn}, got ${event.data.step}`) } - trace.openStep = event.data.step + openStep = event.data.step break } case 'step/end': { requireOpenStep(trace, 'step/end', event.data.turn, event.data.step) // A result must arrive in the step that issued the call; orphan calls // (a step that errored before its result) do not carry to the next step. - trace.pendingCalls.clear() - trace.openStep = null - trace.nextStep += 1 + pendingCalls = { kind: 'clear' } + openStep = null + nextStep += 1 break } case 'assistant/chunk': { @@ -224,7 +244,7 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void { } case 'tool/call': { requireOpenStep(trace, 'tool/call', event.data.turn, event.data.step) - trace.pendingCalls.add(event.data.callId) + pendingCalls = { kind: 'add', callId: event.data.callId } break } case 'tool/result': { @@ -233,9 +253,10 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void { // does NOT hold: a call may have no result — a throwing tool-execution // pipeline step ends the turn with no tool/result, which is legal.) const syntheticInterrupted = event.data.isError && event.data.error?.code === 'interrupted' - if (!trace.pendingCalls.delete(event.data.callId) && !syntheticInterrupted) { + if (!trace.pendingCalls.has(event.data.callId) && !syntheticInterrupted) { throw new InvariantError(`tool/result for ${event.data.callId} with no prior tool/call in this step`) } + pendingCalls = { kind: 'delete', callId: event.data.callId } break } // Turn-enclosure (the turn-enclosure RFC): EVERY session event not handled by a boundary @@ -255,8 +276,52 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void { break } } - // Track every seq seen — used above to validate sourceEventSeqs references. - trace.knownSeqs.add(event.seq) + return { + scalars: { lastSeq: event.seq, openTurn, openStep, nextTurn, nextStep }, + pendingCalls, + surface, + seq: event.seq, + } +} + +/** Apply one already-validated transition after its event commits. */ +function applyTransition(trace: SessionTrace, transition: SessionTraceTransition): void { + Object.assign(trace, transition.scalars) + switch (transition.pendingCalls.kind) { + case 'none': + break + case 'add': + trace.pendingCalls.add(transition.pendingCalls.callId) + break + case 'delete': + trace.pendingCalls.delete(transition.pendingCalls.callId) + break + case 'clear': + trace.pendingCalls.clear() + break + /* v8 ignore next -- validateEvent produces this closed transition union */ + default: + assertNever(transition.pendingCalls, 'session trace pending-call transition') + } + switch (transition.surface.kind) { + case 'none': + break + case 'append': + trace.surface.push(transition.seq) + break + case 'replace': + trace.surface.splice(transition.surface.start, transition.surface.count, transition.seq) + break + /* v8 ignore next -- validateEvent produces this closed transition union */ + default: + assertNever(transition.surface, 'session trace surface transition') + } + trace.knownSeqs.add(transition.seq) +} + +/** Validate and apply one event while rebuilding an already-committed log. */ +function replayEvent(trace: SessionTrace, event: SessionEvent): void { + applyTransition(trace, validateEvent(trace, event)) } /** Legal agent status transitions (the only state machine the loop guarantees). */ @@ -284,6 +349,11 @@ function checkTransition(from: AgentStatus | undefined, to: AgentStatus): void { */ export function apply(ctx: Context): void { const traces = new WeakMap() + const stagedTransitions = new WeakMap() // Agent status has no stored history to replay; the first observation after // (re-)apply seeds the baseline, so a reload never produces a false positive. const lastStatus = new WeakMap() @@ -304,14 +374,14 @@ export function apply(ctx: Context): void { const trace = freshTrace() traces.set(session, trace) for (const event of session.events) { - checkEvent(trace, event) + replayEvent(trace, event) } return trace } // Every store-created session (the only kind that emits session/event) is - // seeded first — via ctx.sessions.list() at apply or session/created — so - // the fallback is a defensive guard, never hit in practice. + // seeded first — via ctx.sessions.list() at apply or session/created — so the + // fallback is a defensive guard, never hit in practice. /* v8 ignore next -- traceFor's fallback: session/event always follows a seed */ const traceFor = (session: Session): SessionTrace => traces.get(session) ?? seedSession(session) @@ -322,16 +392,51 @@ export function apply(ctx: Context): void { // A newly created session may arrive seeded/forked (the constructor copies // the seed WITHOUT emitting session/event), so replay its log here too. - ctx.on('session/created', (session) => { seedSession(session) }) + ctx.on('session/created', (session) => { seedSession(session) }, { global: true }) ctx.on('session/event', (session, event) => { - checkEvent(traceFor(session), event) - }) + // Session resolves dispatch before committing, so internal/dispatch has + // already staged this exact event. A later dispatch veto skips every + // session/event callback and therefore leaves the live trace unchanged. + const staged = stagedTransitions.get(event) + /* v8 ignore next 2 -- internal/dispatch stages the exact callback arguments */ + if (staged === undefined || staged.session !== session) { + throw new InvariantError('session/event reached publication without matching pre-commit validation') + } + stagedTransitions.delete(event) + applyTransition(staged.trace, staged.transition) + }, { global: true }) ctx.on('agent/status', (agent, status) => { checkTransition(lastStatus.get(agent), status) lastStatus.set(agent, status) - }) + }, { global: true }) + + // --- Setup-drives invariant --------------------------------------------- + // + // CreateAgentOptions.setup COMPOSES the agent's scoped world; it must not + // DRIVE the agent. ReactLoopAgent rejects every driving verb structurally + // until rollback-covered publication reaches the session-start boundary; + // this event-level invariant remains the cross-implementation backstop for + // alternate Agent implementations and raw session writes. A turn/start + // candidate before agent/session-start is rejected by internal/dispatch, + // before Session commits it. Sessions of agents that exist BEFORE this + // plugin applies are marked started (their ordering is unknowable after the + // fact — never a false positive on HMR). `agents` is read via ctx.get (a + // strict, optional store lookup) rather than injected: the invariants plugin + // must load in harnesses that carry no agent registry at all (bare session + // tests), where this check simply never trips. + const sessionStarted = new WeakSet() + for (const agent of ctx.get('agents')?.list() ?? []) sessionStarted.add(agent.session) + const assertSessionStartedBeforeTurn = (session: Session, event: SessionEvent): void => { + if (event.type !== 'turn/start' || sessionStarted.has(session)) return + const owner = ctx.get('agents')?.list().find(agent => agent.session === session) + if (owner === undefined) return + throw new InvariantError( + `agent "${owner.id}": a turn opened before agent/session-start fired — ` + + 'CreateAgentOptions.setup composes the scoped world, it must not drive the agent ' + + '(send/steer/inject belong after creation returns)') + } // --- Scoped-dispatch invariants (the agent-scoping seam) --------------- // @@ -386,6 +491,22 @@ export function apply(ctx: Context): void { `"${name}" was dispatched with a scope carrier keyed to a DIFFERENT subject than its arguments name — ` + 'the carrier key and the event\'s subject must be the same object (use agentEvents(ctx, agent))') } + if (name === 'agent/session-start') { + // Mark before product listeners run: a prepended session-start listener is + // explicitly allowed to inject the first turn's context synchronously. + sessionStarted.add((args[0] as Agent).session) + } + if (name === 'session/event') { + const [session, event] = args as [Session, SessionEvent] + const trace = traceFor(session) + const transition = validateEvent(trace, event) + assertSessionStartedBeforeTurn(session, event) + // The exact event identity reaches the contained post-commit listener. + // A later internal/dispatch listener may still veto; because validation + // is pure, abandoning this weakly keyed transition does not advance the + // committed trace or retain the session. + stagedTransitions.set(event, { session, trace, transition }) + } // The assembly context must never carry the agent DX field without the // scope layer selector: the assembly would silently miss the agent's // scoped sections/tools (use assembleContextFor(agent)). @@ -399,33 +520,6 @@ export function apply(ctx: Context): void { } }, { global: true }) - // --- Setup-drives invariant --------------------------------------------- - // - // CreateAgentOptions.setup COMPOSES the agent's scoped world; it must not - // DRIVE the agent. ReactLoopAgent rejects every driving verb structurally - // until rollback-covered publication reaches the session-start boundary; this event-level invariant remains the - // cross-implementation backstop for alternate Agent implementations and raw - // session writes. A turn/start appended before agent/session-start is a - // creation-time misuse, reported at the appending call site. Sessions of - // agents that exist BEFORE this plugin applies are marked started (their - // ordering is unknowable after the fact — never a false positive on HMR). - // `agents` is read via ctx.get (a strict, optional store lookup) rather - // than injected: the invariants plugin must load in harnesses that carry - // no agent registry at all (bare session tests), where this check simply - // never trips. - const sessionStarted = new WeakSet() - for (const agent of ctx.get('agents')?.list() ?? []) sessionStarted.add(agent.session) - ctx.on('agent/session-start', (agent) => { sessionStarted.add(agent.session) }) - ctx.on('session/event', (session, event) => { - if (event.type !== 'turn/start' || sessionStarted.has(session)) return - const owner = ctx.get('agents')?.list().find(agent => agent.session === session) - if (owner === undefined) return - throw new InvariantError( - `agent "${owner.id}": a turn opened before agent/session-start fired — ` - + 'CreateAgentOptions.setup composes the scoped world, it must not drive the agent ' - + '(send/steer/inject belong after creation returns)') - }) - // Request-reconstruction cross-check (the reconstructability RFC): a // loop-built request — frozen envelope + live sessionId is the marker; a // hand-built one-shot (compaction summarize) is unfrozen and skipped — must @@ -501,5 +595,5 @@ export function apply(ctx: Context): void { throw new InvariantError(`llm request for session "${String(session.id)}" diverges from the folded request header`) } return next() - }, { prepend: true }) + }, { global: true, prepend: true }) } diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index 44a87abf8c..efbf1dd978 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { scopeTarget } from '@deepseek-ai/dsh-scope' +import { createScope, scopeTarget } from '@deepseek-ai/dsh-scope' import { CallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -21,6 +21,25 @@ function mockAgent(id: string): Agent { } describe('session-log invariants', () => { + it('keeps pre-commit staging and post-commit application global when mounted under a scope', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + let scopedCtx!: Context + await ctx.plugin(Object.assign((inner: Context) => { + scopedCtx = createScope(inner, {}).ctx + }, { inject: ['sessions'] })) + await scopedCtx.plugin(Invariants) + const globalSession = ctx.sessions.create(SessionId('global-under-scoped-invariants')) + + expect(() => { + globalSession.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + globalSession.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }).not.toThrow() + }) + it('accepts a well-formed turn/step/tool sequence', async () => { const { ctx } = await setup() const session = ctx.sessions.create() @@ -37,6 +56,75 @@ describe('session-log invariants', () => { }).not.toThrow() }) + it('does not advance the trace when a later internal-dispatch listener vetoes', async () => { + const { ctx } = await setup() + const session = ctx.sessions.create(SessionId('dispatch-veto-rollback')) + let veto = true + ctx.on('internal/dispatch', (_mode, name) => { + if (name !== 'session/event' || !veto) return + veto = false + throw new Error('later dispatch veto') + }) + + expect(() => session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + })).toThrow('later dispatch veto') + expect(session.events).toEqual([]) + + expect(() => { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }).not.toThrow() + expect(session.events.map(event => event.type)).toEqual(['turn/start', 'turn/end']) + }) + + it('does not stage a substituted candidate from prepended internal instrumentation', async () => { + const { ctx } = await setup() + const session = ctx.sessions.create(SessionId('dispatch-substitution-rollback')) + let substitute = true + const replacement = { + type: 'turn/start', + seq: 0, + time: 1, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + } as const + ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event' || !substitute) return + substitute = false + args[1] = replacement + }, { prepend: true }) + + expect(() => session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + })).toThrow('session/event internal dispatch replaced the accepted callback tuple') + expect(session.events).toEqual([]) + + expect(() => { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }).not.toThrow() + }) + + it('applies the committed transition after a prepended observer throws', async () => { + const { ctx } = await setup() + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + const session = ctx.sessions.create(SessionId('postcommit-peer')) + ctx.on('session/event', () => { throw new Error('hostile observer') }, { prepend: true }) + + expect(() => { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }).not.toThrow() + expect(session.events.map(event => event.type)).toEqual(['turn/start', 'turn/end']) + expect(warnings).toEqual([ + 'session "postcommit-peer": session/event listener threw: Error: hostile observer', + 'session "postcommit-peer": session/event listener threw: Error: hostile observer', + ]) + }) + it('rejects a non-monotonic seq (replay spine)', async () => { const { ctx } = await setup() const session = ctx.sessions.create() @@ -861,10 +949,19 @@ describe('scoped-dispatch invariants', () => { expect(() => { session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) }).toThrow(/turn opened before agent\/session-start/) - // After session-start fires, turns open freely. - ctx.emit(scopeTarget(agent, agent), 'agent/session-start', agent, 'startup') - expect(() => { + expect(session.events).toEqual([]) + // The internal boundary marks the session before even a prepended product + // listener runs, so the supported session-start injection pattern can open + // and close its one-shot context turn synchronously. + ctx.on('agent/session-start', () => { + session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'test' } } }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }, { prepend: true }) + expect(() => { + ctx.emit(scopeTarget(agent, agent), 'agent/session-start', agent, 'startup') + }).not.toThrow() + expect(session.events.map(event => event.type)).toEqual(['turn/start', 'turn/end']) + expect(() => { session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) }).not.toThrow() }) diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 2f1f35adc4..f5734e1f10 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -71,7 +71,7 @@ When the client does NOT advertise the capability, none of the `_meta`/terminal ## Settle-exactly-once -A `session/prompt` resolves (or rejects) exactly once, keyed off the canonical session log (the `session/event` stream). One listener captures the prompt's owning turn from the log's `turn/start` and settles on the matching `turn/end` — the durable boundary event (`closeTurn` appends it unconditionally; there is no `agent/*` turn mirror). A prompt settles only on ITS OWN turn (`inflight.turn === turn/end.turn`), so a stale `turn/end` for a previously-cancelled turn whose end arrives late can never settle the wrong prompt. A turn that ends `error` REJECTS the RPC with an internal error carrying the failure message (ACP has no error stop reason); every other reason resolves via the codec. As a fallback, when the agent settles to `idle`/`disposed` with a prompt still pending — e.g. a peer `session/event` listener registered before the bridge threw and starved the bridge's listener — an `agent/status` handler reconciles the prompt from the log (the owning turn's `turn/end`, or `cancelled` if the turn was torn down without one). An empty/whitespace prompt is rejected up front — it would queue no work, so no turn would start and the RPC would hang. +A `session/prompt` resolves (or rejects) exactly once, keyed off the canonical session log (the `session/event` stream). One listener captures the prompt's owning turn from the log's `turn/start` and settles on the matching `turn/end` — the durable boundary event (`closeTurn` appends it unconditionally; there is no `agent/*` turn mirror). A prompt settles only on ITS OWN turn (`inflight.turn === turn/end.turn`), so a stale `turn/end` for a previously-cancelled turn whose end arrives late can never settle the wrong prompt. A turn that ends `error` REJECTS the RPC with an internal error carrying the failure message (ACP has no error stop reason); every other reason resolves via the codec. Session contains post-commit observer failures per listener, so another subscriber cannot starve the bridge. As defensive cross-seam reconciliation, an `agent/status` handler checks the log whenever the agent reaches `idle`/`disposed` with a prompt still pending, settling from the owning turn's `turn/end` or as `cancelled` if teardown left no clean boundary. An empty/whitespace prompt is rejected up front — it would queue no work, so no turn would start and the RPC would hang. ## Permission prompts diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 35ff870080..5cd24c2c73 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -308,11 +308,10 @@ interface SessionRecord { * so a later stale `turn/end` finds no pending prompt. * * `logWatermark` is the session log length at the moment the prompt was - * installed (before `send()`). The settle-from-log fallback uses it to infer - * the owning `turn/start` from the canonical log even when the live - * `session/event` capture was starved (a peer listener that throws on - * `turn/start` — see `settleFromLog`): the prompt owns the FIRST `turn/start` - * appended at or after this watermark. + * installed (before `send()`). Defensive settle-from-log reconciliation uses + * it to infer the owning `turn/start` if status reaches idle/disposed before + * live correlation settled the prompt: the prompt owns the FIRST message + * `turn/start` appended at or after this watermark. */ inflight: { resolve: (reason: StopReason) => void @@ -338,12 +337,11 @@ interface SessionRecord { /** * Drive the in-flight prompt's settle from the harness event stream. The bridge * settles off the durable log: the `turn/end` session event on the - * `session/event` feed for the prompt's own turn, with the agent - * erroring/settling to idle as a fallback (docs/defensive-patterns.md "honor - * cross-seam contracts on BOTH sides") for the case where a throwing peer `session/event` listener - * starved the bridge's listener before it saw the boundary. The first of these - * to fire settles the prompt; `settle` is then cleared so the others are no-ops - * (settle-exactly-once). + * `session/event` feed for the prompt's own turn, with idle/disposed status as + * defensive log reconciliation (docs/defensive-patterns.md "honor cross-seam + * contracts on BOTH sides"). Session contains post-commit observer failures, + * so peers cannot starve this feed. The first settlement path clears the slot, + * making every later signal a no-op. */ export function apply(ctx: Context, config: AcpConfig): void { // Capture the injected services NOW, during apply(), while we are inside this @@ -527,23 +525,18 @@ export function apply(ctx: Context, config: AcpConfig): void { settleFromTurnEnd(inflight, event.data.reason) }) - // Settle fallback: a `session/event` listener registered BEFORE ACP that - // throws (on `turn/start` OR `turn/end`) would, via cordis `emit`'s - // stop-on-throw, starve ACP's listener above — the prompt would hang or, if - // only the turn number was missed, settle as the wrong outcome. So when the - // agent settles to `idle` (or is disposed), reconcile against the canonical - // log: determine the prompt's owning turn (the captured `turn`, or — if the - // live capture was starved — the FIRST `turn/start` appended at/after the - // install-time `logWatermark`), then settle from that turn's `turn/end` - // (reject on error, resolve via codec), or `cancelled` if no owning turn ever - // started. Never double-settles — clears `inflight` first. + // Defensive settle fallback: when the agent reaches idle/disposed while a + // prompt is still pending, reconcile against the canonical log. Determine + // the owning turn from live capture or the first message turn after the + // install-time watermark, then settle from its turn/end; if no clean owning + // turn exists, settle cancelled. The slot is cleared first, so this cannot + // double-settle against the live session/event path. const settleFromLog = (rec: SessionRecord): void => { const inflight = rec.inflight if (inflight === undefined) return const events = rec.agent.session.events - // The owning turn number: the captured one, or — if the live capture was - // starved — inferred from the log as the first MESSAGE-triggered turn opened - // at/after the watermark. The message-trigger filter matches the live + // The owning turn number: the captured one, or inferred from the log as the + // first MESSAGE-triggered turn opened at/after the watermark. The filter matches the live // capture: a one-shot `injection` turn a plugin may open between // prompt-install and the prompt's turn is NOT the prompt's turn. Undefined // only if no message turn ever started for this prompt. @@ -568,9 +561,8 @@ export function apply(ctx: Context, config: AcpConfig): void { settleFromTurnEnd(inflight, end.data.reason) } - // On a settle to idle/disposed, reconcile any still-pending prompt from the - // log (covers a starved `session/event` listener — see settleFromLog). A mid- - // step disposal that never appended a clean turn/end resolves `cancelled`. + // On idle/disposed, reconcile any still-pending prompt from the log. A + // mid-step disposal that never appended a clean turn/end resolves `cancelled`. // Demux via the agent→sessionId reverse map. ctx.on('agent/status', (agent, status: AgentStatus) => { const sessionId = bySession.get(agent) @@ -902,9 +894,9 @@ export function apply(ctx: Context, config: AcpConfig): void { // Install the in-flight slot BEFORE send() (send does not synchronously // flip status to running; the session/event listener records the turn // number and settle/rejects it). Capture the log length now as the - // watermark: the settle-from-log fallback infers the owning turn/start - // as the first one appended at/after it, surviving a starved live - // capture. A turn that ends in error rejects this promise (the codec + // watermark: defensive status reconciliation can infer the owning + // turn/start if status arrives reentrantly after commit but before this + // bridge's live callback. A turn that ends in error rejects this promise (the codec // never produces an error stop reason). const stopReason = await new Promise((resolve, reject) => { rec.inflight = { resolve, reject, turn: undefined, logWatermark: rec.agent.session.events.length } @@ -1010,15 +1002,15 @@ export function apply(ctx: Context, config: AcpConfig): void { * quiescence"): for each session settle any pending prompt `cancelled`, then * run that session's {@link AgentHandle} `dispose()` — which stops the loop * (sets `disposed`, aborts the in-flight step), AWAITS the loop's exit (the - * final `turn/end` + `session/flush` are captured while the store-owned append observer is still + * final `turn/end` + `session/flush` are captured while the store-owned publication hooks are still * attached), unregisters the agent, and removes its session from the store. * The per-session disposes run in parallel. Idempotent — clears the `sessions` * map first and memoizes, so a second call (close racing dispose) is a no-op. * Shared by Cordis disposal AND client disconnect (`conn.closed`). * - * Per-agent disposal closes the former pre-step best-effort window — but via - * the DISPOSED path, not `cancel()`: the start-disposer resolves `handle.disposed`, - * which wakes the parked loop, and `isDisposed()` breaks the loop before a + * Per-agent disposal closes the queued-before-run window through the DISPOSED + * path, not `cancel()`: the start-disposer resolves `handle.disposed`, which + * wakes the parked loop, and `isDisposed()` breaks the loop before a * queued-but-not-yet-running turn can start (a turn cut off mid-flight ends * with reason `disposed`, not `aborted`). A bare client disconnect (resolves * `conn.closed` WITHOUT disposing the fiber) thus leaves NO registered agent diff --git a/packages/ui/acp/tests/dispose.spec.ts b/packages/ui/acp/tests/dispose.spec.ts index 9dbd72aa35..f02a7b0649 100644 --- a/packages/ui/acp/tests/dispose.spec.ts +++ b/packages/ui/acp/tests/dispose.spec.ts @@ -160,7 +160,7 @@ describe('acp bridge — disposal & HMR safety', () => { // The teardown-ORDER guarantee: a per-agent dispose must stop the loop, // AWAIT its exit (so the loop's final `turn/end` + `session/flush` fire // through the still-attached store observer → `session/event`), and only - // THEN detach that observer + remove the session. If the order were inverted + // THEN remove its publication hooks and session entry. If the order were inverted // (detach first), the closing events would never reach persistence. Drive a // CLEAN turn to completion, dispose JUST the bridge, then re-load the // persisted log from disk and assert the closing turn/end is on disk — the @@ -190,7 +190,7 @@ describe('acp bridge — disposal & HMR safety', () => { // produced BY the dispose itself. Here the model stream HANGS, so the turn is // still open when teardown runs: the composite agent effect stops the loop, // the loop unwinds and appends `turn/end {disposed}` + runs its final - // `session/flush` — all while the store-owned append observer is still attached (the session + // `session/flush` — all while the store-owned publication hooks are still attached (the session // detach is the LAST disposer in the same effect's LIFO chain) — and only // THEN is the session detached. If the order were inverted (or the session // were a racing SIBLING effect), the abort-produced `turn/end` would never @@ -255,7 +255,7 @@ describe('acp bridge — disposal & HMR safety', () => { // into ONE composite effect whose disposers run as a `.then()` chain. The // register disposer emits `agent/disposed`; if a listener throws and the // emit is UNCONTAINED, the rejected chain skips the LATER session-detach - // disposer — stranding the session in the store with its append observer attached (a + // disposer — stranding the session in the store with its publication hooks attached (a // leak AND a durability hole, since the new design relies on detach // running). The emit must be contained. Register a throwing listener, drive // a clean turn, dispose, and assert the session was STILL removed. diff --git a/packages/ui/acp/tests/turns.spec.ts b/packages/ui/acp/tests/turns.spec.ts index e182d5f2bf..3fc6eef8a6 100644 --- a/packages/ui/acp/tests/turns.spec.ts +++ b/packages/ui/acp/tests/turns.spec.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { defineTool } from '@deepseek-ai/dsh-tools' -import { AgentId } from '@deepseek-ai/dsh-agent' +import { AgentId, agentEvents } from '@deepseek-ai/dsh-agent' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { errorResponse, @@ -235,12 +235,9 @@ describe('acp bridge — turn outcomes', () => { expect(failed).toHaveLength(1) }) - it('settles via the log fallback when a prior session/event listener throws (starvation)', async () => { - // A peer session/event listener that runs BEFORE the bridge's listener - // throws on turn/end (prepend: true puts it first). cordis emit stops at the - // throw, so the bridge's session/event listener never sees turn/end and - // cannot settle there. The agent/status idle-fallback must reconcile the - // prompt from the log so the RPC settles instead of hanging. + it('settles successfully when an earlier turn/end observer throws', async () => { + // Session contains each post-commit observer failure, so a prepended peer + // cannot starve the bridge's live turn/end delivery. harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer')] }) harness.ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') throw new Error('peer listener boom') @@ -250,9 +247,7 @@ describe('acp bridge — turn outcomes', () => { expect(res.stopReason).toBe('end_turn') }) - it('log fallback REJECTS when the starved turn ended in error', async () => { - // Same starvation as above, but the turn fails: the idle-fallback must - // reject the RPC from the logged turn/end{error}, not resolve. + it('still rejects a failed turn when an earlier turn/end observer throws', async () => { harness = await makeBridgeHarness({ storageDir, script: [errorResponse('starved boom')] }) harness.ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') throw new Error('peer listener boom') @@ -262,21 +257,49 @@ describe('acp bridge — turn outcomes', () => { .rejects.toThrow(/turn failed: starved boom/) }) - it('log fallback infers the owning turn when turn/START capture is starved', async () => { - // A peer listener throws on turn/START (not turn/end): the bridge never + it('captures and settles the owning turn when an earlier turn-start observer throws', async () => { + // Turn correlation still reaches the bridge after the throwing peer and // captures inflight.turn via the live stream. A throwing turn/start listener - // also FAILS the turn (the throw is recorded as the turn's error). Without - // the watermark inference the fallback would resolve `cancelled` (the bug); - // with it, it infers the owning turn from the log and REJECTS from that - // turn's error turn/end. (The model's own error is never reached — the turn - // failed at start — so the rejection carries the listener's failure.) - harness = await makeBridgeHarness({ storageDir, script: [textResponse('never runs')] }) + // Session contains post-commit callbacks independently. + // The model request and normal turn outcome therefore still occur. + harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer')] }) harness.ctx.on('session/event', (_s, event) => { if (event.type === 'turn/start') throw new Error('peer listener boom on start') }, { prepend: true }) const sessionId = await newSession(harness) - await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })) - .rejects.toThrow(/turn failed:/) + const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) + expect(result.stopReason).toBe('end_turn') + }) + + it('status reconciliation infers the owning message turn when teardown wins after turn/start', async () => { + harness = await makeBridgeHarness({ storageDir, script: [textResponse('background completion')] }) + const sessionId = await newSession(harness) + const agent = harness.ctx.agents.get(AgentId(sessionId))! + harness.ctx.on('session/event', (session, event) => { + if (session !== agent.session || event.type !== 'turn/start') return + // Inject the signal ordering the defensive fallback handles: disposal + // status after turn/start commits but before ACP's later live observer. + // This is event-level simulation; it does not mutate the test agent. + agentEvents(harness!.ctx, agent).emit('agent/status', 'disposed') + }, { prepend: true }) + + const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) + expect(result.stopReason).toBe('cancelled') + }) + + it('status reconciliation can settle from a committed turn/end before live delivery', async () => { + harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer')] }) + const sessionId = await newSession(harness) + const agent = harness.ctx.agents.get(AgentId(sessionId))! + harness.ctx.on('session/event', (session, event) => { + if (session !== agent.session || event.type !== 'turn/end') return + // Inject a reentrant status signal after the boundary commits to exercise + // the defensive log path before ACP's captured callback runs. + agentEvents(harness!.ctx, agent).emit('agent/status', 'idle') + }, { prepend: true }) + + const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) + expect(result.stopReason).toBe('end_turn') }) it('a between-turn injection does not settle the prompt early (message-trigger correlation)', async () => { diff --git a/packages/ui/user-approval/README.md b/packages/ui/user-approval/README.md index 1c573cdf3f..b47f112aae 100644 --- a/packages/ui/user-approval/README.md +++ b/packages/ui/user-approval/README.md @@ -2,7 +2,7 @@ User-approval seam. Owns the `ctx.approval` service ([`ApprovalService`](src/index.ts)) and the one-shot permission vocabulary the harness shares: `ApprovalRequest` (agent + tool identity + reason + abort signal), the closed `ApprovalOutcome` union (`allowed-once` / `rejected` / `cancelled` / `unavailable`), the `ApprovalRequestId` brand pairing the two log-only audit events (`approval/asked` / `approval/decided`), and the `approval/request` waterfall the answerers listen on. It lives in the UI group because its purpose is human permission, while remaining channel-neutral: it depends only on Cordis and core vocabulary packages, never on a concrete UI. -The contract in one line: `ctx.approval.request(req)` puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and its decision phase always resolves to an outcome: an aborted signal yields `cancelled`, a throwing or missing answerer yields `unavailable`, and `allowed-once` is a grant for the single asked-about action, never a class of future ones. Acceptance is synchronous: the service reads the request fields and `agent.session` binding once, requires object agent/session identities, a string `toolName`, optional string `callId`/`reason`, and an AbortSignal-shaped live capability, then shallow-freezes a detached request record while preserving the exact `agent` and signal identities. A malformed request rejects before any audit append; later caller mutation cannot redirect scope, payload, cancellation, policy lookup, or either audit event. The other precondition is an open turn on the captured session — the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask also rejects before appending. Session observers run after an event enters the append-only log; if one throws, the service recognizes that the audit is already authoritative, contains the observer failure, and completes the pair. +The contract in one line: `ctx.approval.request(req)` puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and its answerer phase always produces an outcome: an aborted signal yields `cancelled`, a throwing or missing answerer yields `unavailable`, and `allowed-once` is a grant for the single asked-about action, never a class of future ones. Acceptance is synchronous: the service reads the request fields and `agent.session` binding once, requires object agent/session identities, a string `toolName`, optional string `callId`/`reason`, and an AbortSignal-shaped live capability, then shallow-freezes a detached request record while preserving the exact `agent` and signal identities. A malformed request rejects before any audit append; later caller mutation cannot redirect scope, payload, cancellation, policy lookup, or either audit event. The other precondition is an open turn on the captured session — the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask also rejects before appending. Either audit append may reject before commit because returning an unlogged decision would violate the pair. Session contains post-commit observer failures, so an authoritative audit append cannot reject the request or suppress its matching event. The service is the mechanism, answerers are the policy. Answerers are `approval/request` waterfall listeners occupying a single decision slot: answer for an agent you own by returning an outcome without calling `next()`, or delegate an agent you don't recognize by calling `next()` — the chain's built-in default is `unavailable`, so a deployment with no answerer (headless, CI) fails closed with zero configuration. Dispatch is keyed by `req.agent`: a listener registered through `agent.ctx` receives only that agent's questions, while a plain-context listener receives every agent's. Registration order across sibling plugins is not load-order deterministic; compose one terminal answerer per deployment and use `prepend` listeners only for decide-or-delegate gates. diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 93ccd973c7..9b0347bb74 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -383,20 +383,23 @@ export class ApprovalService extends Service { * contract (the turn is the log's commit/replay boundary; an idle append * would be dropped as crash tail on reload) — and likewise throws before * appending anything when called idle; asking outside a turn is a deferred - * design. Once accepted it always resolves to an outcome, never rejects: an - * aborted signal yields `'cancelled'`, a missing or throwing answerer yields - * `'unavailable'` (fail closed), and a rogue non-vocabulary return value is - * normalized to `'unavailable'`. The caller-owned request is synchronously + * design. The answerer phase always produces an outcome: an aborted signal + * yields `'cancelled'`, a missing or throwing answerer yields `'unavailable'` + * (fail closed), and a rogue non-vocabulary return value is normalized to + * `'unavailable'`. A failure that prevents either audit append from committing + * still rejects; returning an unlogged decision would violate the audit pair. + * The caller-owned request is synchronously * snapshotted, so later mutation cannot split routing, dispatch payload, * cancellation, policy lookup, or the audit pair across agents/sessions. * Appends the * `approval/asked`/`approval/decided` audit pair (log-only) around the - * decision regardless of outcome. A synchronous session observer failure - * after an audit event entered the append-only log is contained; the event - * is already authoritative, so the pair still completes and the request - * still resolves. + * decision regardless of outcome. Session contains each post-commit observer + * failure, so an already authoritative audit event cannot make this request + * reject or suppress its matching event. * @param req - the pending decision (agent, tool identity, reason, signal). * @returns the closed outcome; `'allowed-once'` is the only grant. + * @throws when request acceptance fails, no turn is open, or either audit + * event fails before the session append commit point. */ async request(req: ApprovalRequest): Promise { // Accept one immutable request shape before the first async boundary. The @@ -476,47 +479,17 @@ export class ApprovalService extends Service { ) } const id = ApprovalRequestId(randomUUID()) - this.appendAudit(session, 'approval/asked', id, () => { - Reflect.apply(append, session, ['approval/asked', { - id, - toolName: accepted.toolName, - ...accepted.callId !== undefined ? { callId: accepted.callId } : {}, - ...accepted.reason !== undefined ? { reason: accepted.reason } : {}, - }]) - }) + Reflect.apply(append, session, ['approval/asked', { + id, + toolName: accepted.toolName, + ...accepted.callId !== undefined ? { callId: accepted.callId } : {}, + ...accepted.reason !== undefined ? { reason: accepted.reason } : {}, + }]) const outcome = await this.decide(accepted, session, acceptedSignal) - this.appendAudit(session, 'approval/decided', id, () => { - Reflect.apply(append, session, ['approval/decided', { id, outcome }]) - }) + Reflect.apply(append, session, ['approval/decided', { id, outcome }]) return outcome } - /** - * Append one audit event while distinguishing a post-append observer throw - * from a failure that prevented the event entering the log. `Session.append` - * pushes first and then notifies synchronously, so log growth proves the - * event is already authoritative; that observer failure is reported and - * contained so it cannot reject the approval or suppress its matching event. - * @param session - the captured session receiving both audit events. - * @param type - the audit event currently being appended. - * @param id - the request id, used to identify the contained failure. - * @param append - the single concrete `Session.append` call. - */ - private appendAudit( - session: Session, - type: 'approval/asked' | 'approval/decided', - id: ApprovalRequestId, - append: () => void, - ): void { - const length = session.events.length - try { - append() - } catch (error) { - if (session.events.length === length) throw error - this.ctx.logger.warn(`approval request "${id}": ${type} observer threw after the event was appended`) - } - } - /** * The session's effective policy: its own `approval/policy` fold, else the * configured default (the schema already defaulted an omitted policy to diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index 831b50111b..4d4b7aa67f 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -323,7 +323,7 @@ describe('ApprovalService.request', () => { const decided = session.events.find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided') expect(audit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided']) expect(decided?.data.id).toBe(asked?.data.id) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('approval/asked observer threw')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('session/event listener threw: Error: observer failed after asked append')) }) it('contains an approval/decided observer throw after append and still resolves', async () => { @@ -346,10 +346,10 @@ describe('ApprovalService.request', () => { const decided = session.events.find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided') expect(audit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided']) expect(decided?.data).toMatchObject({ id: asked?.data.id, outcome: 'rejected' }) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('approval/decided observer threw')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('session/event listener threw: Error: observer failed after decided append')) }) - it('does not misclassify a pre-append failure as an observer failure', async () => { + it('propagates an append failure that prevented audit log growth', async () => { const ctx = await mounted() const failure = new Error('append failed before log growth') const agent = { diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 048ce58937..c36a50c244 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -253,6 +253,12 @@ const DYNAMIC_EVENT_DISPATCHERS: Array<{ event: string; pkg: string; method: str // and contains each listener directly rather than rebuilding via agentEvents. { event: 'agent/disposed', pkg: 'agent', method: 'events.dispatch' }, { event: 'session/created', pkg: 'session', method: 'events.dispatch' }, + // Session event callbacks are likewise resolved before the log push, then + // invoked individually after commit so observer failures are contained. + { event: 'session/event', pkg: 'session', method: 'events.dispatch' }, + // Flush resolves the scoped callback set directly so internal instrumentation + // cannot substitute the accepted session before parallel invocation. + { event: 'session/flush', pkg: 'session', method: 'events.dispatch' }, // Session disposal uses direct callback resolution so teardown contains each // synchronous throw and returned-promise rejection independently. { event: 'session/disposed', pkg: 'session', method: 'events.dispatch' }, @@ -278,6 +284,12 @@ const DYNAMIC_EVENT_DISPATCHERS: Array<{ event: string; pkg: string; method: str { event: 'workflow/end', pkg: 'workflow', method: 'events.dispatch' }, ] +const DYNAMIC_EVENT_LISTENERS: Array<{ event: string; pkg: string }> = [ + // The invariants oracle marks the session started from its global + // internal/dispatch listener before product session-start callbacks run. + { event: 'agent/session-start', pkg: 'invariants' }, +] + function generatedHeader(title: string): string[] { return [ ' pkg_brand pkg_session --> pkg_llm pkg_session --> pkg_scope + pkg_system_prompt --> pkg_llm + pkg_system_prompt --> pkg_scope pkg_fs --> pkg_brand pkg_fs --> pkg_llm pkg_web --> pkg_llm pkg_sandbox --> pkg_llm - pkg_system_prompt --> pkg_llm - pkg_system_prompt --> pkg_scope - pkg_system_prompt --> pkg_session + pkg_agent --> pkg_brand + pkg_agent --> pkg_llm + pkg_agent --> pkg_scope + pkg_agent --> pkg_session + pkg_agent --> pkg_system_prompt pkg_bash --> pkg_brand pkg_bash --> pkg_sandbox pkg_bash --> pkg_session @@ -146,26 +150,22 @@ flowchart TD pkg_llm_replay --> pkg_session pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox - pkg_agent --> pkg_brand - pkg_agent --> pkg_llm - pkg_agent --> pkg_scope - pkg_agent --> pkg_session - pkg_agent --> pkg_system_prompt pkg_bash_local --> pkg_bash pkg_bash_local --> pkg_timeout + pkg_compact_basic --> pkg_agent + pkg_compact_basic --> pkg_compact + pkg_compact_basic --> pkg_llm + pkg_compact_basic --> pkg_session pkg_hook_protocol --> pkg_bash pkg_hook_protocol --> pkg_session pkg_session_persistence_jsonl --> pkg_session pkg_session_persistence_jsonl --> pkg_session_persistence pkg_session_persistence_sqlite --> pkg_session pkg_session_persistence_sqlite --> pkg_session_persistence - pkg_bash_sandbox --> pkg_bash - pkg_bash_sandbox --> pkg_bash_local - pkg_bash_sandbox --> pkg_sandbox - pkg_compact_basic --> pkg_agent - pkg_compact_basic --> pkg_compact - pkg_compact_basic --> pkg_llm - pkg_compact_basic --> pkg_session + pkg_invariants --> pkg_agent + pkg_invariants --> pkg_llm + pkg_invariants --> pkg_scope + pkg_invariants --> pkg_session pkg_user_approval --> pkg_agent pkg_user_approval --> pkg_brand pkg_user_approval --> pkg_llm @@ -184,6 +184,9 @@ flowchart TD pkg_tools --> pkg_session pkg_tools --> pkg_system_prompt pkg_tools --> pkg_user_approval + pkg_bash_sandbox --> pkg_bash + pkg_bash_sandbox --> pkg_bash_local + pkg_bash_sandbox --> pkg_sandbox pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_llm pkg_agent_loop --> pkg_scope @@ -210,7 +213,6 @@ flowchart TD pkg_subagent --> pkg_agent pkg_subagent --> pkg_llm pkg_subagent --> pkg_scope - pkg_subagent --> pkg_session pkg_subagent --> pkg_tools pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt @@ -229,12 +231,6 @@ flowchart TD pkg_hooks_codex --> pkg_llm pkg_hooks_codex --> pkg_session pkg_hooks_codex --> pkg_tools - pkg_invariants --> pkg_agent - pkg_invariants --> pkg_llm - pkg_invariants --> pkg_scope - pkg_invariants --> pkg_session - pkg_invariants --> pkg_system_prompt - pkg_invariants --> pkg_tools pkg_acp --> pkg_agent pkg_acp --> pkg_bash pkg_acp --> pkg_llm @@ -333,10 +329,11 @@ flowchart TD | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`llm`](../packages/llm/llm) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`llm`](../packages/llm/llm) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm) | -| [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | +| [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`bash`](../packages/bash/bash) | `bash` | [`brand`](../packages/util/brand), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) | @@ -349,28 +346,27 @@ flowchart TD | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | -| [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) | +| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | -| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | -| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | +| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | -| [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index cb0e8ce0dc..245d25fa5f 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -23,7 +23,7 @@ An approval question was put to the answerer chain — log-only audit (like `hoo Types: [CallId](core-data-structures/core.md) -Source: [`packages/ui/user-approval/src/index.ts:86`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:84`](../packages/ui/user-approval/src/index.ts) #### `approval/decided` — log-only @@ -33,7 +33,7 @@ The outcome of a prior `approval/asked` (same `id`) — log-only audit. Exactly 'approval/decided': { id: ApprovalRequestId; outcome: ApprovalOutcome } ``` -Source: [`packages/ui/user-approval/src/index.ts:97`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:95`](../packages/ui/user-approval/src/index.ts) #### `approval/policy` — log-only @@ -43,7 +43,7 @@ The session's approval policy was switched — log-only, durable, replayable, ne 'approval/policy': { policy: ApprovalPolicy } ``` -Source: [`packages/ui/user-approval/src/index.ts:109`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:107`](../packages/ui/user-approval/src/index.ts) ### `assistant/*` @@ -57,7 +57,7 @@ Raw stream chunk — token-level replay fidelity. Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:322`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -69,7 +69,7 @@ Assembled assistant message for one step (derived history uses this). Carries th Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:324`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:329`](../packages/core/session/src/types.ts) ### `bash/*` @@ -129,7 +129,7 @@ In-session context injection (file-change notices, subdir AGENTS.md, skill conte Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:320`](../packages/core/session/src/types.ts) ### `hook/*` @@ -165,7 +165,7 @@ A queued prompt an `agent/prompt-submit` listener VETOED — the durable record Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:309`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:314`](../packages/core/session/src/types.ts) ### `request/*` @@ -177,7 +177,7 @@ Full snapshot of the EpochHeader the NEXT request is built under, with the Reque 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:369`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:374`](../packages/core/session/src/types.ts) #### `request/header-delta` — log-only @@ -187,7 +187,7 @@ Amendment to the folded EpochHeader: at least one of a SystemDelta, a ToolsDelta 'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] } ``` -Source: [`packages/core/session/src/types.ts:386`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:391`](../packages/core/session/src/types.ts) ### `steering/*` @@ -201,7 +201,7 @@ Steering content injected between steps of a running turn. Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:347`](../packages/core/session/src/types.ts) ### `step/*` @@ -213,7 +213,7 @@ Closes step `step` of turn `turn`. 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:296`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -223,7 +223,7 @@ Opens step `step` of turn `turn` — one model call plus the tool executions it 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) ### `todo/*` @@ -239,7 +239,7 @@ NOT a SurfaceEventType: it produces no LLM message and never reaches `deriveMess Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:361`](../packages/core/session/src/types.ts) ### `tool/*` @@ -253,7 +253,7 @@ The model requested one tool invocation: `name` with the raw `arguments` JSON st Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:335`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -277,7 +277,7 @@ A completed tool call's model-facing result, plus an optional tool-private `meta Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:340`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:345`](../packages/core/session/src/types.ts) ### `turn/*` @@ -291,7 +291,7 @@ Closes turn `turn` with the TurnEndReason that ended it. The loop fires the awai Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -303,7 +303,7 @@ Opens turn `turn`. `trigger` records what started it — a drained message batch Types: [TurnTrigger](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:291`](../packages/core/session/src/types.ts) ### `user/*` @@ -317,4 +317,4 @@ A user-visible prompt (queued message drained at turn start). Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts) diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 5e508344fa..1483401835 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -70,6 +70,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [The session prefix — request-only messages in front of the derived history](implemented/feature/2026-07-07-session-prefix.md) | 2026-07-07 | | [Repeat-tool-call guard plugin](implemented/feature/2026-07-08-repeat-tool-guard.md) | 2026-07-08 | | [The self-referential cordis toolset](implemented/feature/2026-07-08-self-referential-cordis-toolset.md) | 2026-07-08 | +| [Configure subagent persona, tool visibility, and depth](implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) | 2026-07-12 | ### Simplification diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md index 024eb05f82..76c40805f8 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -4,30 +4,28 @@ Status: implemented ## Problem -One application needs to share infrastructure across many agents while giving each agent a coherent local world. Model adapters, persistence, user interfaces, and most tool implementations belong to the deployment; personas, visible tools, live policy, event listeners, and cleanup often belong to one agent. +One application needs to share infrastructure across many agents while letting each agent have its own tools, prompt contributions, policies, and listeners. Shared adapters, persistence, and user interfaces belong to the deployment; a persona, tool variant, or listener often belongs to one agent. -A separate service graph per agent duplicates shared infrastructure. One global registration graph has the opposite failure: an agent-specific tool, prompt section, restriction, or listener can leak into unrelated agents. Contributors need one way to compose local behavior without learning a different registration API for every service. +A separate service graph per agent duplicates shared infrastructure. One global registration graph has the opposite failure: an agent-specific contribution can leak into unrelated agents. Contributors need one ordinary registration mechanism that determines both who can see a contribution and when it is cleaned up. -The mechanism also needs a clear lifetime. An agent must not become visible before its local registrations exist, and final loop work must not lose those registrations before it settles. Parent-owned subagents make both failures easy to trigger because several differently configured agents can exist concurrently inside one application. +The mechanism also needs a publication boundary. An agent must not become visible before its local world is complete, and teardown must retain that world until final work has stopped. ## Decision -Every live agent owns one flat registration layer through `agent.ctx`. Code registers through the context that owns the contribution; scope-aware services resolve deployment-global registrations plus exactly one matching agent layer; scoped events route by the operation's real agent; and the layer is published and revoked with the agent lifecycle. +Every live agent owns one flat registration layer exposed as `agent.ctx`. Code registers through the context that owns a contribution; scope-aware services combine deployment-global registrations with exactly one matching agent layer; operations choose that layer from their real agent; and the layer exists for the agent's complete published lifetime. -A Cordis **context** is the object through which code accesses services and registers owned effects. The [Cordis primer](../../../cordis-primer.md) explains the framework beyond that concept. +Cordis is the plugin framework underneath the SDK. A Cordis **context** is the object plugins use to access services and register effects whose cleanup follows that context. The [Cordis primer](../../../cordis-primer.md) explains the framework in more detail. -The contract has four parts: +For most contributors, the complete contract is four rules: -| Contributor question | Contract | +| Question | Rule | |---|---| -| Where do I register agent-local behavior? | Use the ordinary service API through `agent.ctx` | -| What does an agent see? | Deployment globals plus its own layer, with service-specific merge rules | -| Which scoped listeners run? | By default, unscoped listeners plus listeners for the operation's agent; an explicit global-listener exception is described below | -| How long does local behavior exist? | Assembled during unpublished setup, observable only after creation succeeds, and retained through quiescent teardown | +| Where do I register behavior for one agent? | Call the ordinary registration API through `agent.ctx` | +| What does an operation for an agent see? | Deployment globals plus that agent's layer, using the owning service's merge rules | +| Which scoped listeners run? | Unscoped listeners plus listeners registered for the operation's agent | +| How long does the layer exist? | Setup completes before publication; disposal keeps it until work reaches quiescence | -The scope is deliberately flat. Resolution never walks parent or sibling scopes. Parent ownership links lifetimes without importing registrations. - -For scope-aware registries and default listener routing, the whole mechanism can be read from left to right: the registering context chooses a layer, while the agent named by an operation chooses which one local layer joins the deployment-global layer. +The scope is flat. Resolution never walks parent or sibling scopes, and lifetime ownership does not imply registration inheritance. ```mermaid flowchart LR @@ -35,41 +33,32 @@ flowchart LR agentAContext["agentA.ctx
cleanup follows Agent A"] -->|"registers into"| agentALayer["Agent A layer"] agentBContext["agentB.ctx
cleanup follows Agent B"] -->|"registers into"| agentBLayer["Agent B layer"] - operationA["Operation for Agent A"] -->|"selects"| agentAView["Agent A view
eligible globals plus A local only"] + operationA["Operation for Agent A"] -->|"selects"| agentAView["Agent A view
globals plus A local"] globalLayer --> agentAView agentALayer --> agentAView - operationB["Operation for Agent B"] -->|"selects"| agentBView["Agent B view
eligible globals plus B local only"] + operationB["Operation for Agent B"] -->|"selects"| agentBView["Agent B view
globals plus B local"] globalLayer --> agentBView agentBLayer --> agentBView ``` -The missing cross-edges describe registry resolution and default listener routing: Agent A's registered values and ordinary scoped listeners do not enter Agent B's view, and a parent's layer does not enter a child's view merely because the parent owns the child's lifetime. For scope-filtered events, `{ global: true }` is the explicit opt-in exception; it can observe across scopes while cleanup still follows the registering agent. Registry-membership notifications are a separate unfiltered event class described below. +The missing cross-edges are the isolation rule: Agent A's local registrations do not enter Agent B's view, and a parent's registrations do not enter a child merely because the parent owns the child's lifetime. -The companion [runtime-design RFC](2026-07-12-agent-scope-runtime-design.md) explains how the implementation preserves this contract under Cordis dispatch, JavaScript mutation and reentrancy, asynchronous setup, rollback, and racing disposal. +The companion [runtime-design RFC](2026-07-12-agent-scope-runtime-design.md) explains the implementation and correctness reasoning. The [subagent composition-controls RFC](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) owns the separate `persona`, `toolFilter`, and `maxDepth` feature. -### Registration origin selects visibility and cleanup +### Registration origin chooses visibility and cleanup -A contribution made through a plain plugin context is deployment-global and is disposed with that plugin. The same method called through `agent.ctx` contributes only to that agent and is disposed with the agent scope. +A registration made through a plain plugin context is deployment-global and is disposed with that plugin. The same method called through `agent.ctx` contributes to one agent and is disposed with that agent's scope. -| Registration origin | Registration layer and default audience | Disposed with | +| Registration origin | Default visibility | Disposed with | |---|---|---| -| Plain plugin context | Deployment-global; eligible for every agent view, subject to service merge and restriction rules | Registering plugin | -| `agent.ctx` | Agent-local; visible to that agent by default | Agent scope | +| Plain plugin context | Every eligible agent view | Registering plugin | +| `agent.ctx` | Exactly that agent's view | Agent scope | -This applies to tools, prompt sections and variables, restrictions, protections, guards, and scoped event listeners. Named scoped values ordinarily shadow same-named global values; an owning service may reserve a protected name and reject the shadow instead. Duplicate names within one layer fail. Event listeners have the explicit `{ global: true }` audience exception described below. +Tools, prompt sections and variables, tool restrictions, guards, and scoped event listeners adopt this contract. Named local values ordinarily shadow a same-named global value for that agent; each owning service documents exceptions and merge behavior. -The public pattern is ordinary registration inside `setup`: +The ordinary contributor pattern is to register the complete local world during agent setup: ```js -const reviewSummaryTool = { - name: 'review_summary', - description: 'Return the review summary.', - parameters: { type: 'object', properties: {} }, - async execute() { - return [{ type: 'text', text: 'review complete' }] - }, -} - const handle = await ctx.agents.create({ agentId: AgentId('reviewer'), sessionId: SessionId('reviewer-session'), @@ -80,130 +69,108 @@ const handle = await ctx.agents.create({ order: 0, text: 'Review code, but do not modify files.', }) - agentCtx.tools.restrict({ allow: ['read'] }) - agentCtx.tools.register(reviewSummaryTool) + agentCtx.tools.register({ + name: 'review_summary', + description: 'Return the review summary.', + parameters: { type: 'object', properties: {} }, + async execute() { + return [{ type: 'text', text: 'review complete' }] + }, + }) }, }) -const reviewer = handle.agent -ctx.tools.get('read', reviewer) // global and allowed -ctx.tools.get('bash', reviewer) // undefined: filtered global -ctx.tools.get('review_summary') // undefined: not global -ctx.tools.get('review_summary', reviewer) // reviewer-local +ctx.tools.get('review_summary') // undefined: not global +ctx.tools.get('review_summary', handle.agent) // the reviewer-local tool await handle.dispose() -ctx.tools.get('review_summary', reviewer) // undefined: scope is gone +ctx.tools.get('review_summary', handle.agent) // undefined: scope is gone ``` -### The operation selects the view +Setup receives a full trusted Cordis context so it can compose ordinary plugins and services. Its contract is composition-only: driving or publishing the in-flight agent through casts or internal registry calls is unsupported. -Registration origin and operation subject are separate facts. Calling a read method through `agent.ctx` does not implicitly select that agent; lookup, execution, prompt assembly, and event dispatch still receive the agent or scope they act for. +### The operation chooses the view -For example, `agent.ctx.systemPrompt.assemble()` without an assembly scope requests the global view. `ctx.tools.get(name, agent)` and `ctx.tools.execute({ ..., agent })` select that agent's tool view explicitly. This lets one shared service act for any agent without binding the service instance itself to one scope. +Registration origin and operation subject are separate facts. Calling a service through `agent.ctx` selects where a new registration belongs; it does not bind later reads to that agent. -`agent.ctx.agent` is the associated agent for setup code, but it is not a general scope-selection shortcut. Contributors creating nested generic scopes use the nearest scope tag as the registration key; inheriting an `agent` property does not import the outer registration layer. +Tool lookup and execution receive the agent they act for. Prompt assembly receives an assembly context for the agent whose request is being built. Event dispatch receives its domain subject. This keeps shared service instances reusable across agents while making each operation's view explicit. -### Scoped events follow the operation's real subject +Only services that adopt the scope contract resolve an agent layer. `agent.ctx` does not automatically change arbitrary Cordis service calls. -By default, an event about agent A reaches unscoped listeners and A-scoped listeners, not B-scoped listeners; agent-less dispatch reaches only unscoped listeners. Product helpers and service-owned paths couple the routing key to the value the operation already owns—such as `ToolExecution.agent`, `ApprovalRequest.agent`, the prompt assembly scope, or the session's captured owner. Advanced code that constructs a low-level carrier or assembly context directly must keep its subject and scope fields aligned; development invariants detect mismatches, but the low-level types do not make every mismatch unrepresentable. +### Scoped events keep routing separate from event data -Cordis listeners have one explicit exception. `{ global: true }` bypasses contextual filtering, so a listener registered through `agent.ctx` can observe other agents and subjectless dispatches while its cleanup still belongs to that agent scope. Use it only for deliberate cross-scope observation. +An event about Agent A normally reaches unscoped listeners and A-scoped listeners, not B-scoped listeners. An event without an agent subject reaches only unscoped listeners. -Registry-membership notifications remain unfiltered because they describe shared registry state rather than an operation for one agent. The generated [event catalog](../../../cordis-catalog/events.md) is the exhaustive reference for event signatures and modes. +At the Cordis level, `Scoped` is an opaque routing receiver. It carries the filter used to choose listeners but is not the domain object. Event signatures therefore keep the real `Agent`, tool execution, approval request, or other subject as an explicit argument that listeners can inspect. -### Creation publishes after setup; disposal revokes after work stops +A listener registered with `{ global: true }` deliberately bypasses contextual audience filtering while its cleanup still follows the registering context. Registry-membership notifications remain unfiltered because they describe shared registry state rather than one agent's operation. The generated [event catalog](../../../cordis-catalog/events.md) is the exhaustive event reference. -`ctx.agents.create()` and `resume()` construct an unpublished agent. Their optional `setup(agentCtx)` callback may await child-plugin activation and register the complete local world. During setup, neither the agent nor its session is visible in the public registries, and driving methods reject. +### Creation publishes last and disposal revokes last -The returned promise resolves only after setup, ordered lifecycle notification, and loop start succeed. Setup failure or owner loss rolls the unpublished world back and releases its IDs. A caller therefore never receives a handle to a partially configured agent. +`ctx.agents.create()` and `resume()` build an unpublished session, scope, agent, and driver. They await `setup`, admit the final session and agent entries, announce them in order, start the loop, and only then return a handle. -`AgentHandle.dispose()` performs the reverse boundary. It stops and drains the loop, preserves the session and scoped listeners through final events and flushes, detaches the agent and session, unwinds the scope, and releases IDs. Repeated or racing calls join the same completion promise. +An optional creation signal cancels work only while create or resume is pending. After the promise resolves, the returned `AgentHandle` owns explicit disposal. -The calling Cordis context and AgentLoop are structural co-owners. Unloading either disposes the agent, so creation through a short-lived plugin context intentionally gives the agent that shorter lifetime. +If loading, setup, admission, or publication fails, the private transaction rolls back everything it prepared. Concurrent operations using the same caller-supplied live ID may both reach setup, but final registry entry admits only one; every loser rejects and cleans its private resources. Sequential reuse after awaited disposal remains valid. -The lifecycle keeps the local layer private until setup succeeds and keeps it alive until final work has drained: +`AgentHandle.dispose()` reverses the boundary. It deactivates creation or driving, waits for synchronous publication to unwind, stops and drains the driver and final session flushes, detaches the agent and session, and finally disposes the scope. Repeated or racing disposal requests join one completion promise. + +The calling Cordis context and the concrete AgentLoop factory are structural co-owners. Unloading either disposes the transaction or live agent. ```mermaid flowchart TB - request["Create or resume"] --> reserve["Reserve agent and session IDs"] - reserve --> privateWorld["Load or build private session, scope, and driver"] - privateWorld --> setup["Await setup through agent.ctx"] - setup --> publish["Publish session and agent, then start the loop"] - publish --> live["Return the live handle"] + request["Create or resume"] --> privateWorld["Build private session, scope, agent, and driver"] + privateWorld --> setup["Await composition through agent.ctx"] + setup --> admission["Admit final session and agent entries"] + admission --> publish["Announce lifecycle and start the driver"] + publish --> live["Return AgentHandle"] - privateWorld -->|"load or preparation failure, or owner loss"| rollback["Rollback startup
no handle escapes"] - setup -->|"setup failure or owner loss"| rollback - publish -->|"publication failure or owner loss"| rollback - live -->|"handle disposal, owner unload, or AgentLoop unload"| settle["Quiesce prepared or running work"] - rollback --> settle - settle --> detach["Detach any published agent, then session"] - detach --> revoke["Dispose any created agent scope"] - revoke --> release["Release acquired IDs"] + privateWorld -->|"failure, cancellation, or owner loss"| rollback["Rollback private work"] + setup -->|"failure, cancellation, or owner loss"| rollback + admission -->|"duplicate or owner loss"| rollback + publish -->|"listener failure or owner loss"| rollback + live -->|"handle or owner disposal"| quiesce["Stop and drain work"] + rollback --> quiesce + quiesce --> detach["Detach agent, then session"] + detach --> revoke["Dispose the agent scope"] ``` -Contributors should put agent-local activation inside `setup` and always dispose the returned handle. Code that needs to observe a live agent waits for `create()`/`resume()` to resolve rather than polling the registries during setup. +### Subagent controls are an independent feature -## Tool restrictions resolve against a live flat view +In-process subagents consume agent scope by installing their local composition during unpublished setup. Their optional persona, live global-tool filter, and absolute depth cap are not intrinsic scope semantics; the [subagent composition-controls RFC](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) defines those controls, provider capability checks, and dynamic tool behavior. -A tool restriction filters the live deployment-global end-capability layer, after which scope-local tools are added. `allow` retains named globals, `deny` removes named globals, multiple restrictions intersect, and a hidden global tool is absent from both registry presentation and executable lookup. +`inheritsParentContext` describes conversation-history seeding only. It says nothing about Cordis scope, injected services, tools, or authority. -Filter presence is explicit: omitting a filter installs no restriction, `restrict({})` rejects, and `allow: []` deliberately hides every global end capability. +## Security and authority are non-goals -Because globals are live, allow- and deny-lists intentionally differ when a new global tool appears: +Agent scopes compose trusted same-process registrations. They do not sandbox plugins, define a parent-to-child authority lattice, freeze grants at creation, or guarantee that a child can do no more than its parent. -```text -at time 0: - global tools = { read, bash } - deny { bash } view = { read } - allow { read } view = { read } +A parent may own a child whose visible tools are wider than its own because lifetime ownership does not donate or cap registrations. A plugin holding a Cordis context also runs in the same process and can call available services directly. -after registering global tool web: - deny { bash } view = { read, web } - allow { read } view = { read } -``` - -Scope-local tools are merged after the filter. A local tool can therefore exist even when it is absent from an allow-list over globals. This is composition behavior, not an authorization promise. - -Reserved Code Mode presentation is not part of the filterable end-capability layer. The [Code Mode RFC](../feature/2026-06-15-code-mode.md) owns the `run_code`, SDK, `toolOrder`, and presentation-versus-execution contracts; contributors changing Code Mode behavior follow that decision rather than inferring new authority semantics from agent scope. - -## Security and authority are explicit non-goals - -Agent scopes compose trusted in-process registrations. They do not sandbox plugins, define a parent-to-child authority lattice, freeze a creation-time grant set, or guarantee that a child can do no more than its parent. A plugin holding a Cordis context runs in the same process and can call the services injected into that context. - -A parent can own a child whose visible tool set is wider than its own. For example, a parent restricted to global `read` can spawn a child with no restriction; the child then sees later global tools plus its own local registrations. The parent owns the child's lifetime but does not donate or cap the child's registration layer. - -Deployments that need non-escalation require a separate authority representation, propagation rule, and execution check. Authority-versus-visibility ledgers, parent-subset grants, explicit future-grant APIs, and generic capability/output/termination tags are outside this decision. - -## Subagents use the same composition rule - -In-process subagents are a consumer of agent scope, not a second scoping model. A child gets a fresh flat layer during unpublished setup; its persona, tool filter, structured-output protocol, and listeners are ordinary registrations through the child's context. Parent teardown, backend teardown, and manual run disposal own the child lifetime without importing the parent's registrations. - -`inheritsParentContext` describes conversation-history seeding only, not Cordis scope, service injection, tools, or authority. The [subagent capability RFC](../feature/2026-06-21-subagent-capability-seam.md) owns run usage and the provider contract, while the [runtime-design RFC](2026-07-12-agent-scope-runtime-design.md) explains in-process structured output and workflow race handling. +Deployments that need non-escalation require a separate authority representation, propagation rule, and execution check. Parent-subset grants, creation-time authorization snapshots, explicit future-grant APIs, and generic capability/output/termination tags are outside this decision. ## Alternatives considered -The rejected architectures either separate visibility from cleanup, scope only behavior but not registered data, duplicate shared infrastructure, or conflate parent ownership with registration inheritance. +The rejected designs either separate visibility from cleanup, cover only one registration family, duplicate shared infrastructure, or conflate lifetime ownership with inheritance. ### Pass an agent option to every registration -An API such as `tools.register(definition, { agent })` leaves global registration as the leak-by-omission default and repeats scope plumbing in every registry. It can also express “visible to A, disposed with unrelated plugin B,” which registration through `agent.ctx` prevents. +An API such as `tools.register(definition, { agent })` repeats scope plumbing in every registry and permits visibility ownership to drift from cleanup ownership. Registering through `agent.ctx` makes both facts follow one Cordis effect owner. ### Filter events while keeping registries global -Listener filtering prevents a hook from intercepting the wrong agent but does not scope tool schemas, executable lookup, prompt sections, variables, or Code Mode bindings. Agent-local composition would still require temporary global mutation. +Listener filtering prevents the wrong hook from running but does not scope tool schemas, executable lookup, prompt sections, variables, or other registered data. Agent-local composition would still require temporary global mutation. -### Create one isolated service graph per agent +### Create one service graph per agent -Service isolation chooses one registry instance, while the desired view is deployment globals plus one agent layer. Per-agent graphs duplicate adapters and force shared persistence and UI infrastructure to discover every instance. Independent applications still deserve separate graphs; collaborating agents inside one deployment do not. +The required view is shared deployment services plus one local registration layer. Per-agent graphs duplicate adapters and complicate shared persistence, provider registries, and application boot. -### Inherit the parent's registrations into a child +### Inherit parent registration scopes -Hierarchical inheritance silently imports every parent-scoped tool and policy. Flat layers plus parent-owned disposal separate lifetime from composition: the parent owns the child without deciding the child's local world. This choice deliberately makes authorization a separate design. +Parentage describes lifetime and conversation lineage, not a universal merge policy. Hierarchical lookup makes unrelated services inherit accidentally and cannot define security without a separate authority model. ## Consequences -Contributors use the same registration methods at both deployment and agent scope; changing the calling context changes visibility and cleanup together. Model-visible tool lookup, execution, prompt assembly, policy, observation, and teardown agree on one agent key instead of maintaining parallel per-feature scope options. +Contributors use one familiar pattern: register shared behavior through a plugin context, register local behavior through `agent.ctx`, select the real agent on operations, and dispose the returned handle. Setup is atomic from an observer's perspective, and teardown preserves local behavior until work stops. -The cost is explicit subject selection on reads and dispatch, asynchronous programmatic creation, disciplined handle disposal, and awareness that flat registration scope is not authority. Registries retain service-specific merge behavior, and only services that adopt the scope contract become agent-scoped automatically. - -The decision applies to tools, prompt state, scoped events, session lifecycle and scoped session events, approvals, and in-process subagent composition. Filesystem policy, LLM interception, background backend state, and other registries retain their own subject or policy mechanisms until their designs explicitly adopt agent scope. +The cost is explicit subject selection, asynchronous programmatic creation, and service-specific scope adoption. Flat registration scope is intentionally not authority, and subagent composition controls remain a separate feature rather than hidden scope semantics. diff --git a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index 35d1b50c16..786dce250e 100644 --- a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -4,999 +4,390 @@ Status: implemented ## Problem -The [agent-scope contract](2026-07-08-agent-scope-contexts.md) defines the contributor-visible result: registrations made through `agent.ctx` form one flat local layer, operations resolve that layer by their real agent, setup remains unpublished, and teardown preserves the layer until work stops. The implementation must make those claims true inside a cooperative plugin framework and mutable JavaScript runtime. +The [agent-scope contract](2026-07-08-agent-scope-contexts.md) is simple for contributors: register through `agent.ctx`, resolve one global-plus-agent view, publish only after setup, and retain the scope until work stops. The runtime must preserve that contract across a cooperative plugin framework, asynchronous creation, reentrant listeners, durable session commits, and worker or process failure. -Four failure classes interact in the paths this change hardens: +The main design risk is adding a second mechanism for every race. Separate reservations, readiness sentinels, cancellation relays, snapshot layers, and protection registries can mirror the same fact until no reader can tell which one is authoritative. That machinery also encourages the runtime to treat trusted typed calls as hostile serialization boundaries. -| Proof obligation | Failure if implemented locally or incompletely | -|---|---| -| Registration and dispatch select the same key | Prompt data resolves for one agent while behavior listeners run for another | -| Construction and teardown have continuous ownership | Reentrant unload publishes a partial world, leaks IDs, or revokes policy before final work | -| Covered validation and later use observe one accepted value | Stateful accessors or caller mutation make checked, executed, logged, and observed data disagree | -| Protocol invariants survive extensible middleware | Listener ordering removes required prompt state, re-allows denied work, commits a failed result, or forces another model step | - -Cordis already supplies derived contexts, effect ownership, receiver-based filtering, and waterfalls, but none alone establishes all four obligations. Context association is not a scope key, raw disposers are not await-idempotent, waterfall listeners can short-circuit or wrap each other, and JavaScript `readonly` types do not constrain runtime accessors. Async persistence, setup, publication callbacks, subagent providers, and worker messages add reentrancy and race boundaries around those primitives. +The implementation needs enough state to preserve real ownership and settlement boundaries, but no more. A correctness reviewer must be able to follow one fact from acceptance through publication and teardown without reconciling parallel representations. ## Decision -The runtime implements agent scope as four coupled mechanisms rather than one generic framework feature: +The runtime uses one mechanism per independent fact. Scope routing has an opaque carrier; each live registry object has one entry record; each create or resume operation has one transaction; typed same-process calls borrow readonly values; real data boundaries materialize once; and worker/process code retains separate terminal and quiescence state only where different owners can genuinely race. -| Mechanism | Implementation decision | +The design can be skimmed as seven choices: + +| Problem | Authoritative mechanism | |---|---| -| Layer and routing | A scope key tags registration effects; scope-aware contribution registries merge globals plus one layer; dispatch carriers filter listeners by the operation subject | -| Transactional lifetime | Scope, session, registry entry, driver, reservations, caller ownership, and AgentLoop ownership publish and unwind as one ordered transaction | -| Boundary ownership | The hardened acceptance-sensitive paths listed below capture caller fields once and retain stable identities or owner-controlled representations | -| Owner-final policy | Four narrow service-owned boundaries restore named prompt state, deny monotonically, observe final results, and stop terminal turns | +| Select global plus one agent's registrations | Opaque scope key and routing carrier | +| Own one live agent or session | One registry entry captured by its disposer | +| Coordinate create/resume | One `AgentCreationTransaction` | +| Protect durable, queued, model, or wire data | Materialize once at that boundary | +| Pass typed values inside one process | Readonly borrowed contract | +| Preserve an owner's final prompt/tool policy | Contribution-owned finality and one final observer point | +| Coordinate subagent, worker, and process shutdown | One cancellation signal plus the independent terminal/quiescence facts of that boundary | -The same mechanisms carry into in-process subagents and the workflow bridge. Subagents are the composition proof because child setup, structured output, readiness, cancellation, result settlement, and disposal exercise all four obligations concurrently. +The rest of this RFC expands those choices in dependency order. It first explains the Cordis mechanics, then scope routing, creation and session commit, tools and prompts, subagents and workflows, and finally the checks that make the reasoning executable. -This RFC owns the implementation rationale, algorithms, race handling, and correctness enforcement. The [agent-scope contract](2026-07-08-agent-scope-contexts.md) owns contributor-facing behavior, tool-filter semantics, usage examples, and the security non-goal; this document links to that contract rather than redefining authority or public scope inheritance. +The [July 8 RFC](2026-07-08-agent-scope-contexts.md) remains the contributor contract. The separate [subagent composition-controls RFC](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) owns `persona`, `toolFilter`, and `maxDepth`; this document discusses only how their setup fits the lifecycle. -## Implementation model: domain terms and Cordis mechanics +## Cordis model: context, fiber, effect, receiver, and waterfall -Readers need four domain terms and four Cordis mechanics to follow the implementation. Readers already familiar with this codebase and Cordis can skim this section. +Five Cordis ideas are required to understand the implementation. A context selects services and registration ownership; a fiber is one live plugin or child lifecycle; an effect attaches cleanup to a fiber; an event receiver selects listeners; and a waterfall lets listeners transform or veto an operation in sequence. -### Recurring domain terms +### A context is an ownership path through one service graph -Four domain terms keep the rest of the RFC compact. A **Session** is an agent run's append-only event log, from which model history and durable replay are derived. **Lossless JSON** is the JSON subset that can be copied without changing meaning: primitives, dense arrays, and plain objects; cycles, sparse arrays, exotic prototypes, non-finite numbers, negative zero, `undefined`, `bigint`, functions, and symbols are rejected. An **end capability** is an actual callable tool implementation, whether the model sees it as a native schema or a Code Mode binding. **Code Mode** gives the model a generated SDK and a reserved `run_code` transport. Pure `code` presentation replaces native advertisement with that interface; `both` presentation retains native schemas alongside it. +All agents share one Cordis service graph. A derived context does not clone `ToolRegistry`, `SystemPrompt`, persistence, or model adapters; it changes how registrations made through that context are tagged and which effects own their cleanup. -### Four Cordis mechanics +`agent.ctx` is such a derived context. Service calls still reach the shared instances, while a registration can inspect its calling context and store a contribution under the nearest scope key. Ordinary plugin contexts carry no scope key and therefore register globally. -Contexts select service access and registration origin, fibers own effects, waterfalls provide cooperative transformation, and dispatch receivers select listeners. +### Fibers and effects make cleanup structural -| Cordis concept | Meaning in this RFC | -|---|---| -| Context | The object through which a plugin reaches services and registers contributions; a derived context can carry a different registration scope | -| Fiber and effect | The runtime owner and one owned piece of setup/cleanup; disposing the fiber unwinds its effects | -| Waterfall | Ordered around-middleware whose listener calls `next()` to include downstream work and may transform or short-circuit the result | -| Dispatch receiver | The `this` object used by Cordis listener filtering; a scope carrier encodes the operation's agent key | +A Cordis fiber is the live instance created when a plugin or child context is activated. Its state records whether that lifecycle is active, unloading, failed, or disposed. `ctx.effect()` and `ctx.on()` return disposers and also attach those disposers to the registering fiber, so unloading a plugin or agent scope removes everything registered through that context without a separate inventory. -#### Context selects both service access and registration origin +The vendored Cordis fiber implementation establishes ownership before arbitrary setup or `internal/plugin` observers run. A reentrant unload can see the child fiber or effect that has started, reject effects added after unload begins, and join cleanup already started through a public single-shot disposer. Teardown observers are contained individually so one callback cannot prevent structural cleanup. -A Cordis `Context` is the object through which code calls services such as `ctx.tools`, `ctx.systemPrompt`, and `ctx.sessions`. A service can recover the context through which it was accessed, so the same method can register globally from a plain plugin context or locally from `agent.ctx` without adding an `agent` option to every registration API. Cordis implements contextual service access with a **traced receiver**: a proxy that carries the accessing context while forwarding calls to the concrete service object. +These are framework lifecycle guarantees rather than agent-specific policy. Agent creation depends on them because setup can activate arbitrary plugins and synchronously reenter owner disposal. -```js -ctx.tools.register(globalTool) -agent.ctx.tools.register(agentOnlyTool) +### Receivers route listeners; waterfalls compose decisions -ctx.on('tools/result', globalObserver) -agent.ctx.on('tools/result', agentObserver) -``` +Cordis filters listeners using the dispatch receiver (`this`), while harness listeners need an explicit agent, execution, request, or other subject. `Scoped` marks the receiver expected by a scoped event declaration, but the runtime carrier deliberately exposes no subject API. -A context also exposes the dependency view injected into the plugin that minted it. `agent.ctx` therefore carries the agent loop's deliberate service surface; it is not an ambient root context or a security boundary. +Product helpers therefore construct the carrier and pass the domain subject separately. This prevents listener routing from becoming an alternate object model and keeps event signatures understandable without knowledge of carrier internals. -#### Effects make cleanup follow ownership +A Cordis waterfall is middleware-style dispatch. Each listener receives `next()`: calling it delegates to the remaining listeners and base operation, while returning without it vetoes or replaces the downstream result. Waterfalls power prompt assembly and tool policy; ordinary emit events notify synchronously, and parallel events await all listeners without a veto result. -An effect is setup whose cleanup belongs to a fiber. Tool registration, prompt contribution, event subscription, and an agent scope are effects, so normal disposal, failure, and hot module reload all follow the same ownership graph. +## Scope routing: one opaque key selects one layer -```js -ctx.effect(() => { - const resource = openResource() - return async () => { - await resource.close() - } -}) -``` +The scope package implements the smallest object needed for Cordis routing. Its carrier holds only a composed service filter and scope predicate, while the package records the opaque key privately and exposes the scope fiber's quiescent disposer separately. -Cordis also supports generator effects that nest child effects in a chosen teardown order. The lifecycle section explains why construction must become owner-visible before arbitrary callbacks run. +### Scope identity uses object identity -#### Waterfalls remain cooperative extension points +A `ScopeKey` is an opaque object compared by identity. The harness uses the live `Agent` as its own key, but the primitive is domain-neutral and supports other scoped owners. -A waterfall listener wraps downstream work. Calling `next()` includes the remaining listeners and base implementation; returning directly skips that downstream portion. +`createScope(parent, key)` returns a scope whose `ctx` shares the parent's services and whose effects are tagged with that key. `scopeOf(ctx)` reads the nearest registration key. `scopeTarget(base, key)` creates the event receiver whose filter preserves the base receiver's Cordis service filter, then admits unscoped listeners and listeners with that exact key. -```js -ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { - const downstream = await next() - return { - ...downstream, - sections: [...downstream.sections, extraSection], - } -}) +The receiver is a small carrier rather than a transparent proxy for the domain object. Code that needs the agent receives the explicit event argument; code that needs registration ownership receives `agent.ctx`. -ctx.on('system-prompt/assemble', async () => replacementAssembly) -// The direct return skips this listener's downstream/base. An outer listener -// that already awaited next() still resumes around replacementAssembly. -``` +### Registry reads overlay one exact map -This flexibility is intentional for ordinary policy, but it cannot express a fact that must remain true after every wrapper and short-circuit. [Owner-final policy](#owner-final-policy-four-narrow-boundaries) adds only the four final checkpoints that need stronger semantics. +Scope-aware registries store global contributions separately from identity-keyed local contributions. A read resolves the global layer and at most one local layer; it never traverses parentage. -#### Dispatch receivers select scoped listeners +Each service retains its domain rule. Named prompt values and tools use local shadowing, tool restrictions filter globals before local tools are added, and events select listener audiences rather than registered data. Scope supplies identity and ownership, not a universal merge algorithm. -Cordis filters listeners using the dispatch receiver, the object visible as `this` inside a function-style listener. `dsh-scope` builds a receiver carrying the operation's scope key, allowing global listeners plus listeners registered for that exact key while rejecting other agents' listeners. +### Fused dispatch helpers prevent subject drift -The receiver is live coordination state, not durable session data. For example, `tools/result` is a live final-outcome notification, while `tool/result` is an append-only session event used for replay and model history. +`agentEvents(context, agent)` constructs the agent's carrier and injects the same agent as the event subject. Session, tool, approval, prompt, and subagent services likewise derive routing from the object they already own instead of accepting an unrelated key. -## Registration and delivery: global plus exactly one agent layer +The type marker rejects ordinary bare-receiver mistakes, and development invariants cover direct JavaScript or casted dispatch. The subject remains explicit because routing correctness and useful event data are different concerns. -Within services that adopt the agent-scope contract, one scope key controls both registered data and registered behavior. Contribution reads combine the deployment-global layer with exactly one agent layer, while scoped event dispatch admits global listeners plus the listeners for that same agent. +## Agent creation: one transaction owns the complete operation -Scope keys are opaque objects compared by identity; a live `Agent` is its own registration key. There is no name-based equality or parent traversal. +Create and resume are one asynchronous lifecycle with several phases, not several lifecycles. `AgentCreationTransaction` owns caller and factory liveness, optional cancellation, private resources, publication, rollback, and the memoized teardown observed by every owner. -### Scope mechanism: context, key, and lifetime +### Registry entries are the only live identity records -The registration context selects the layer, the scope primitive binds that layer to cleanup, and the nearest scope tag—not an inherited convenience property—selects the key. +AgentRegistry and SessionStore each keep one entry per live object. The entry holds the stable ID, object, scoped carrier, and the small amount of publication or append state that belongs to that object. -#### The calling context selects visibility and cleanup +A detach closure captures its exact entry. It deletes only when the map still points to that entry, so an old disposer cannot delete a later object that reuses the same ID. No registry rereads a mutable caller object to decide identity. -A scope-aware registry method recovers the Cordis context through which its service receiver was accessed and calls `scopeOf(context)` once while installing the registration effect. An absent key selects the global store; a key selects the per-scope store. Cleanup closes over that accepted store and key, so later context mutation or a same-named replacement cannot redirect disposal. Event listeners follow a different Cordis path: `ctx.on()` retains the registering context, and targeted dispatch reads its scope while filtering listeners; `{ global: true }` deliberately bypasses that audience filter without changing cleanup ownership. +There is no reservation API. Caller-supplied IDs are admitted at final entry. Concurrent same-ID operations may both complete private setup; exactly one final `enter()` succeeds, and every loser rolls its private resources back. Sequential reuse is valid after the earlier disposer reaches quiescence. -The [public contract](2026-07-08-agent-scope-contexts.md#registration-origin-selects-visibility-and-cleanup) owns the visibility table, shadowing rule, and `{ global: true }` listener exception. Internally, registry resolution overlays one exact identity-keyed map on the global map and never traverses an ancestry relation: +### The transaction owns preparation before awaiting it -```text -resolveLayer(agentA): - visible = copy(global registrations) - visible.overlay(registrations from agentA.ctx) - return visible -``` +The transaction is installed under both the calling Cordis context and the concrete AgentLoop factory before persistence load or setup can suspend. It also observes an optional create/resume signal until the public operation settles. -The one-overlay algorithm is why the generic primitive needs only opaque identity and effect ownership; parent/child meaning stays outside `dsh-scope`. +Create prepares a new Session. Resume loads and validates the persisted Session before preparing the same live session identity. Both paths then build the scope, agent, and driver and invoke the same setup/publication algorithm. -#### The scope primitive keeps layer and owner together +The factory stores concrete trace targets but invokes them through a caller-bound Cordis trace. This preserves dependency origin and caller ownership without stacking trace proxies. -`dsh-scope` exposes only the operations needed to mint a tagged ownership layer, read its key, target dispatch, and reach quiescent cleanup. For ordinary scope-aware registries, using a separate `{ scope }` option could express “stored in A's layer, disposed with B”; registration through the scoped context makes that mismatch unrepresentable. The `{ global: true }` listener option is an intentional audience exception, and the low-level `scopeTarget(base, key)` primitive still relies on its service-owned caller to supply matching facts. +### Setup is trusted composition inside a private world -| Operation | Responsibility | -|---|---| -| `createScope(context, key)` | Mount an ownership fiber and return its tagged context | -| `scopeOf(context)` | Read the nearest inherited scope key | -| `scopeTarget(base, key)` | Build a scope-filtered dispatch receiver around the existing base receiver | -| `Scope.dispose()` | Return one shared idempotent promise that reaches cleanup quiescence | -| `Scope.rawDispose` | Expose the exact Cordis disposer for ordered generator composition | +Setup receives the full child context and may await plugin activation. It can register tools, prompt sections, restrictions, listeners, and other effects, but the public contract does not support driving or publishing the in-flight agent through casts or internal registry calls. -`Scope.dispose()` and `rawDispose` serve different callers. Cordis raw disposers are single-shot, so a repeated raw call need not wait for an earlier asynchronous teardown; the public method follows the backing fiber's in-flight cleanup and gives racing callers the same completion promise. Generator lifecycles use `rawDispose` because Cordis recognizes nested ownership by exact disposer identity. +The transaction races asynchronous load and setup against deactivation rather than waiting forever for a promise owned by external code. If cancellation or owner unload wins, public creation rejects after transaction-owned cleanup even when the external promise never settles. -The primitive has one essential shape: +### Publication has one ordered commit path -```text -createScope(parentContext, key): - fiber = mount no-op plugin under parentContext - scopedContext = derive fiber.context with nearest-scope-tag = key +Publication admits and announces resources in the order required by observers: - rawDispose = fiber's exact disposer - dispose = memoized operation that: - invoke rawDispose if teardown has not started - follow fiber's in-flight cleanup until quiescent +1. Enter the session. +2. Enter the agent. +3. Announce `session/created`. +4. Announce `agent/created`. +5. Enable public driving. +6. Emit `agent/session-start`. +7. Start the driver. - return { ctx: scopedContext, rawDispose, dispose } -``` +The agent never drives before both registries and creation notifications agree. A synchronous listener may veto or dispose an owner; the transaction records publication in progress and waits for that callback stack to unwind before teardown continues. Every creation announcement that begins has a matching disposal announcement during rollback. -Derived contexts inherit the nearest tag. Mounting a plugin under `agent.ctx` preserves the agent scope; deliberately creating another scope replaces the tag below it. - -#### `ctx.agent` is an association; `scopeOf()` selects the layer - -`agent.ctx.agent` gives setup code convenient access to the associated agent, but the nearest scope tag remains authoritative for resolution. A nested scope can inherit the ergonomic `agent` property while replacing the registration key. - -```js -const auditKey = {} -const auditScope = createScope(agent.ctx, auditKey) - -auditScope.ctx.agent === agent // true: inherited association -scopeOf(auditScope.ctx) === auditKey // true: nearest registration key - -await auditScope.dispose() -``` - -This separation keeps the generic scope package independent of the agent package. - -### Resolution contracts preserve domain semantics - -The shared scope selects two layers, but each registry retains its own merge rules and must keep presentation, lookup, and execution coherent within the view it owns. - -#### Registries retain domain-specific merge rules - -The shared primitive answers “which layer?” and “who owns cleanup?”; each service still defines how its values combine. Prompt sections, variables, and tools use scoped-over-global shadowing by name. Tool-schema providers are additive. Tool lookup and execution receive an agent or scope explicitly, while prompt assembly receives an `AssembleContext` whose `scope` selects the layer. - -Calling a read method through `agent.ctx` does not silently choose an agent subject. For example, `agent.ctx.systemPrompt.assemble()` without an assembly scope still requests the global view. Registration origin and operation subject remain explicit, allowing one shared service to act for any agent. - -#### The tool view is live and executable - -`ToolRegistry` owns one resolver rather than separate presentation and execution stores. It snapshots restriction definitions at registration, applies them to the current global map, overlays the matching scoped map, and derives lookup, dispatch, schemas, SDK bindings, timeouts, inspection, and UI presentation from that resolved map. A filtered global implementation therefore cannot remain executable through a second path. - -The [public RFC](2026-07-08-agent-scope-contexts.md#tool-restrictions-resolve-against-a-live-flat-view) owns the exact allow/deny/future-global/local-overlay behavior. The internal distinction needed here is that `ToolRegistry.knownNames()` validates restrictions against the pre-restriction end-capability universe, while the system-prompt provider validates `toolOrder` against the presentation mode's wire universe. - -Final prompt assembly can include schemas from other `systemPrompt.tools()` providers or assembly listeners. The coherent-view proof therefore covers `ToolRegistry`'s own schemas, bindings, lookup, execution, and presentation; another provider owns coherence for the unrelated schemas it contributes. - -Reserved `run_code` presentation sits outside both registration maps. The [Code Mode RFC](../feature/2026-06-15-code-mode.md) owns its mode, SDK, and `toolOrder` semantics; this design relies only on the fact that the transport is resolved separately from filterable end capabilities. - -### Dispatch contract follows the operation subject - -Service-owned dispatch paths derive or couple the scope key with the operation subject, and a carrier composes that key with the chosen base receiver's existing dispatch behavior. For agent events the agent is both base and operation subject; tool, approval, and prompt dispatch instead wrap their owning service while selecting listeners with the operation's agent key. The low-level primitives can still represent mismatched facts, so helper use and development invariants—rather than the type system alone—protect direct internal callers. - -#### The operation subject selects the listener set - -The [public dispatch rule](2026-07-08-agent-scope-contexts.md#scoped-events-follow-the-operations-real-subject) and generated [event catalog](../../../cordis-catalog/events.md) own listener visibility and the exhaustive event-family mapping. The implementation problem is to prevent each service from choosing its carrier, subject argument, and scope key independently. - -Fused helpers keep values that must agree together. `agentEvents(context, agent)` uses one agent as the subject, scope key, and first event argument. `assembleContextFor(agent)` sets both prompt facts and the scope selector. The session store captures its carrier when a session enters because later appends and flushes may occur without the original agent context. - -#### The carrier preserves base-receiver behavior - -Function-style listeners receive the carrier as `this`. Agent listeners may call methods on the agent base; service listeners rely on the owning service's contextual receiver behavior. The carrier is therefore a proxy that selects listeners while reading, writing, and invoking through the real base receiver. - -The implementation uses a dedicated surrogate proxy target with an immutable composed-filter slot. It combines the base receiver's existing `Context.filter` with the scope predicate instead of replacing it. Methods bind to the real base; callable carriers preserve call and construct shape; descriptor queries normalize configurable flags as required by Proxy invariants; and definitions through the carrier require an explicitly configurable descriptor. Stable built-in references protect the composed filter from accidental `.call` replacement. - -Those mechanics preserve observable JavaScript behavior, including private-field method identity: - -```js -class Base { - #count = 0 - increment() { this.#count += 1 } -} - -const base = new Base() -const key = {} -new Proxy(base, {}).increment() // TypeError: proxy lacks Base's private identity - -const carrier = scopeTarget(base, key) -carrier.increment() // works: method is bound to base -carrier === base // false: carrier has distinct identity -``` - -Together these constraints keep listener selection correct while preserving the base-receiver behavior listeners expect. - -The TypeScript-only `Scoped` marker requires a carrier at typed dispatch sites. Runtime marks and development invariants cover JavaScript, casts, and direct Cordis dispatch; they detect routing mistakes but do not confine hostile same-process code. - -## Lifecycle: compose privately, publish once, tear down in reverse - -Scope, session, registry entry, and driver form one transaction with two owners. Request fields are captured first; AgentLoop tracking and both identity reservations precede asynchronous work; the caller owns the prepared lifecycle before setup; publication proceeds in synchronous observable phases; and every teardown path reaches one reverse-order quiescence boundary. - -Two services split the public API from the implementation. `AgentRegistry`, reached as `ctx.agents`, stores live agents and is the front door for `create()` and `resume()`. Its registered `AgentFactory` is concretely implemented by `AgentLoop`, which constructs and drives agents using its own injected dependencies. The rest of this section calls that concrete co-owner the **AgentLoop factory**. - -| Phase | Public state | Ownership fact | -|---|---|---| -| Reserve | IDs unavailable to competitors | AgentLoop tracking and exact reservations cover the next await | -| Prepare or load | Persistence data is loading, or session, scope, and driver exist privately | Resume's load sentinel covers persistence; the complete caller lifecycle covers setup | -| Setup | `setup(agent.ctx)` may await and register | Neither ID is published | -| Publish and start | Session, agent, and lifecycle notifications appear in order | Liveness is checked between observable phases | -| Dispose | Driver drains, registries detach, scope unwinds, IDs release | All owner paths join one completion promise | - -The [public lifecycle contract](2026-07-08-agent-scope-contexts.md#creation-publishes-after-setup-disposal-revokes-after-work-stops) defines what callers observe. The following sections justify each ownership and ordering fact behind that contract. - -### Reservations precede awaiting; lifecycle ownership precedes setup - -AgentLoop tracking and exact identity reservations precede the first await. Resume adds a caller sentinel across persistence loading; create and resume both establish the complete caller-owned lifecycle before invoking setup. - -#### The prepared lifecycle is owned before setup callbacks - -The caller context owns the work it requested and receives the consumer-facing `AgentHandle`. The AgentLoop factory is a structural co-owner because a live agent continues to depend on its injected services. Either owner can deactivate the transaction; both converge on the same lifecycle disposer. - -| Owner mechanism | Covers | Retires when | -|---|---|---| -| Caller lifecycle sentinel | Caller-fiber loss from lifecycle preparation through live lifecycle | Shared lifecycle reaches quiescence | -| Resume load sentinel | Caller-fiber loss across persistence load and lifecycle handoff | Load rollback or the adopted lifecycle reaches quiescence | -| AgentLoop tracker | AgentLoop unload and structural dependency loss | Transaction and lifecycle settle | -| ID reservations | Competing agent/session insertion | Ordered teardown releases both IDs | - -A **sentinel** is an owner-visible effect that follows work whose final disposer is not yet available. It adopts the exact reservation disposers immediately, then follows the complete lifecycle disposer once preparation establishes it. - -Cordis must make construction owner-visible before setup can reenter teardown. An effect's cleanup wrapper enters its owner list before its setup body runs, a child fiber receives its parent-owned disposer before Cordis's child-plugin notification (`internal/plugin`) announces it, and a fiber already unloading rejects new effects after taking its cleanup snapshot. Teardown observers are contained independently so one callback cannot starve peers or interrupt cleanup. These are domain-neutral lifecycle rules; `dsh-scope` uses them by mounting a no-op plugin fiber as the ownership bucket for one scope. - -#### Caller ownership and factory dependency lookup stay separate - -Factory delegation carries two contexts because ownership and dependency origin are different facts. `ownerCtx` is the caller-bound context whose fiber and optional scope own the requested lifecycle. The factory method receiver is the accepted factory traced through that access so the concrete service retains its own injected dependency view. - -```text -callerCtx.agents.create(options) - ownerCtx = context carrying callerCtx's fiber and scope - factoryThis = concrete accepted factory traced through ownerCtx - Reflect.apply(capturedCreateAgent, factoryThis, [ownerCtx, options]) -``` - -`setFactory()` captures the concrete target and its `createAgent` and `resume` callbacks once. It canonicalizes an already traced service before retracing, avoiding a second proxy layer that would break raw-identity state. Plain factory objects receive the explicit `ownerCtx` without depending on Cordis tracing. - -#### Create and resume reserve identities before awaiting - -Programmatic create and resume reserve both agent and session IDs before any operation can await. Create prepares a new or seeded session; resume loads persisted data while a caller sentinel and AgentLoop load tracker already own the interval in which no `Agent` object exists. - -Reservations are capabilities, not advisory sets. Setup code cannot reserve, prepare, create, register, or enter a substitute under the same IDs. A session reservation prepares at most one exact object, and publication requires the matching factory-held capabilities. A failed or abandoned transaction therefore cannot publish a substitute or wedge an ID indefinitely. - -Resume transfers ownership rather than opening a gap: - -```text -resume(ownerCtx, request): - snapshot request identity, options, and setup callback - reserve agentId and sessionId - install caller sentinel adopting both reservation disposers - track load under AgentLoop - - persisted = await firstOf(persistence.load(sessionId), deactivated) - session = sessionReservation.prepare(reconstruct persisted data) - starting = startOwned(ownerCtx, session, reservations, setup) - caller sentinel follows starting.dispose - return await starting.result -``` - -If deactivation wins, a backend load may still settle internally but has no path back to publication. Preparation failure still returns a rollback-backed lifecycle result, so both owners can wait for actual cleanup instead of mistaking a rejected async result for successful installation. - -### Setup composes an unpublished world - -`setup(agentCtx)` may register tools, prompt state, restrictions, listeners, protections, or child plugins and may await their activation. The new agent is available as `agentCtx.agent`, but neither agent nor session is visible in its global registry. - -The complete rollback skeleton exists before setup runs. If setup throws, rejects, or loses either owner, the scope and prepared resources unwind and the IDs become reusable. After setup settles, a microtask checkpoint and liveness checks let a same-turn owner unload win before publication. - -Setup composes but cannot drive. `send`, `steer`, `inject`, and `cancel` reject until publication reaches the session-start boundary. The driver lock and inbox use runtime-private state, and only factory-held controls enable and start the loop; JavaScript casts cannot call a public start method or write directly into the queue. - -```text -startOwned(ownerCtx, snapshot, preparedSession): - world = prepareLifecycleWithCompleteRollback(ownerCtx, snapshot, preparedSession) - - result = async: - require world active - await firstOf(runOptionalSetup(snapshot.setup, world.agent.ctx), world.deactivated) - await oneMicrotask() - require caller, factory, owner fiber, and owner agent still active - world.publish(snapshot.source) - return handle(world.agent, world.dispose) - - on any error: - await world.dispose() - rethrow -``` - -### Publication is ordered, observable, and rollback-covered - -Publication is one synchronous sequence with liveness checks between three observable notification phases. Both registry entries exist before the first listener runs, but driving stays locked until immediately before `agent/session-start`. - -1. Enter the session store and capture its scope carrier. -2. Enter the agent registry without announcing it. -3. Recheck caller and factory liveness. -4. Emit `session/created`. -5. Recheck liveness. -6. Emit `agent/created`. -7. Recheck liveness. -8. Enable driving. -9. Emit `agent/session-start`. -10. Recheck liveness. -11. Start the driver. - -```text -publish(world): - world.beginSynchronousPublication() - try: - world.detachSession = sessions.enter(world.session, sessionReservation) - world.detachAgent = agents.enter(world.agent, agentReservation) - require callerAndFactoryActive - sessions.announce(world.session) - require callerAndFactoryActive - agents.announce(world.agent) - require callerAndFactoryActive - world.driver.enableDrivingVerbs() - emitNonVetoing(agent/session-start) - require callerAndFactoryActive - world.driver.start() - finally: - world.endSynchronousPublication() -``` - -#### Creation is paired, not atomic - -Observers run between publication steps, so the sequence is not described as atomic. Effects already performed by an earlier listener cannot be retracted if a later listener throws. Instead, each registry marks a creation announcement as begun before dispatch and emits exactly one matching disposal edge during rollback. An entered object that was never announced has no disposal notification because no observer was told it existed. - -A detach requested during `session/created` or `agent/created` is deferred until that dispatch unwinds. Stable captured carriers and exact-object guards prevent a later listener from observing `disposed` before `created` or a stale detach from deleting a replacement with the same ID. The outer publication barrier likewise prevents caller or AgentLoop teardown from removing the other registry entry or unwinding `agent.ctx` while an announcement remains on the stack. - -Creation listener synchronous throws remain vetoes. Returned promise rejections are observed and logged but not awaited: publication has no asynchronous gap in which such a result could roll back safely. Disposal notifications and `agent/session-start` are non-vetoing and independently contain both synchronous throws and returned-promise rejections so one listener cannot block cleanup or later observers. - -### Teardown stops work before revoking registrations - -Every owner path reaches one memoized reverse-order transaction. It marks the lifecycle inactive, waits for an in-progress synchronous publication phase, stops the driver through actual exit and final durability work, detaches the agent and session, unwinds the scope, and releases IDs last. - -Final turn events, the turn-ending flush, and any outstanding session flush started while the agent was idle therefore run while the session and scoped listeners still exist. `agent/disposed` observes an already quiescent and unregistered concrete agent while its session remains live; `session/disposed` follows after event feed detachment and store removal. Both use the stable carrier captured for their matching creation edge. - -```text -disposeOwnedAgent(world): - mark world inactive - await world.synchronousPublicationIfRunning() - await world.stopDriver() # loop exit plus agent-started flushes - world.detachAgent() - world.detachSession() - await world.scope.dispose() - world.releaseSessionReservation() - world.releaseAgentReservation() -``` - -`AgentHandle.dispose()` gives repeated and racing consumers the same completion promise. The lifecycle-long caller sentinel follows that promise even when handle disposal wins first, while the AgentLoop ledger independently stops new transactions and waits for every structurally dependent agent before the service disappears. - -AgentLoop co-ownership follows dependency shape, not a blanket “creator owns every returned value” rule. An AgentLoop-created agent continues to depend on the loop's services, so AgentLoop unload stops it. - -## Boundary ownership: hardened paths accept once and own the accepted value - -The acceptance-sensitive paths enumerated below read caller-owned fields once and retain only owner-controlled identities or snapshots before crossing asynchronous, reentrant, model-visible, or durable-log code. This is a boundary-by-boundary implementation property, not a blanket claim about every public API. The protection is independent of TypeScript: `readonly` annotations vanish at runtime, and JavaScript accessors can return a different value on every read. - -The shared shape distinguishes identity-bearing references from data. Agent objects and abort signals are retained by identity after one read. Boundaries whose contract requires lossless JSON—such as session events and subagent payloads—validate and materialize it in one traversal; other boundaries use their own owned representation, such as `structuredClone` for agent options. Scalars and callbacks are captured once, then each boundary applies the validation promised by its API before downstream use. - -```text -accept(input): - read every relevant top-level field exactly once - retain identity-bearing references without rereading them - validate acceptance-time fields from those captures - copy or pin data in the representation owned by this boundary - bind accepted callbacks once when method receiver state is intentional - expose only owner-controlled identities, frozen records, or detached results -``` - -Capture does not imply uniform eager callback type-checking. Agent `setup` is captured once and any invocation failure enters rollback; a tool guard is likewise captured, and an invalid cast becomes a normalized execution error. The invariant is that later work never rereads caller fields to choose a different value. - -| Boundary | Identity retained | Data detached or pinned | -|---|---|---| -| Tool and `SubagentProvider` registration | Original callback receiver | Name, flags, schemas, scalar config | -| Agent create/resume | Caller context, setup callback | IDs, options, session metadata and seed | -| Agent send/steer | None | Content blocks and resolved message source | -| Approval request | Agent and abort signal | Tool name, call ID, and reason | -| Tool execution | Agent, signal, registry-minted parent token | Call identity and arguments | -| Session append/load | Session identity | Header and event envelopes | -| Subagent start/result | Parent and signal | Prompt, filters, schema, options, result | - -Before agent setup can run, the concrete agent pins its accepted ID, options, and session and binds `ctx` once. Registry detach closures likewise close over their accepted keys instead of rereading mutable public fields. - -`send()` and running `steer()` resolve the message source once and materialize `{ content, source }` as one detached, deeply frozen lossless-JSON record before `agent/queued` or inbox insertion. The notification and FIFO share that accepted content and source; its metadata wrapper is frozen separately, so neither retained caller references nor an earlier notification listener can rewrite what a later listener, the session log, or the model sees. Invalid content or source throws synchronously without notification, enqueue, or loop wakeup; idle `steer()` delegates to the same `send()` boundary. The later `agent/prompt-submit` waterfall can still replace a queued prompt by returning new content; ownership forbids in-place mutation, not the explicit rewrite protocol. - -The inbox path makes that accepted-value boundary concrete. Getter evaluation happens during materialization, so liveness is rechecked before the accepted record crosses into an inbox FIFO: +The sequence diagram isolates the non-obvious race: a synchronous creation listener can request disposal while the publication call stack still owns both registry entries. Teardown must deactivate immediately but wait for that stack to unwind before stopping and detaching anything. ```mermaid -flowchart TB - callerInput["Caller-owned content and source"] --> initialCheck["Require a live, drive-enabled agent"] - initialCheck --> accept["Resolve source once; materialize and deep-freeze one record"] - accept -->|"invalid lossless JSON"| invalidReject["Throw synchronously; no inbox insertion, agent/queued, or loop wakeup"] - accept -->|"accepted"| liveness["Recheck disposal after caller getters"] - liveness -->|"disposed reentrantly"| disposedReject["Throw disposed; do not insert or announce the message"] - liveness -->|"still live"| inbox["Insert the record into the queued or steering FIFO"] - inbox -->|"same frozen content and source"| queued["Emit agent/queued with a frozen metadata wrapper"] - inbox -->|"if later drained, read the same owned record"| drain["Loop-owned delivery"] - inbox -->|"cancel before drain"| cancelled["Clear the pending record without delivery"] - inbox -->|"disposal wins before drain"| disposed["Stop delivery; the disposed agent may retain the pending record"] - drain -->|"queued prompt"| prompt["agent/prompt-submit may block or explicitly replace"] - drain -->|"steering consumed by an active turn"| steering["Append steering/message"] +sequenceDiagram + participant Tx as AgentCreationTransaction + participant Registries + participant Listener as Synchronous listener + participant Driver + + Tx->>Tx: mark publication in progress + Tx->>Registries: announce agent/created + Registries->>Listener: invoke inside the same call stack + Listener->>Tx: dispose reentrantly + Tx->>Tx: deactivate, teardown waits for publication + Tx-->>Listener: disposal request accepted + Listener-->>Registries: return + Registries-->>Tx: announcement unwound + Tx->>Tx: resolve publication settlement + Tx->>Driver: stop and drain + Tx->>Registries: detach agent, then session + Tx->>Tx: dispose scope and resolve teardown ``` -A stateful getter shows why validation and ownership must use the same capture: +### Teardown preserves work before revoking registrations -```js -let reads = 0 -const input = { - get name() { - reads += 1 - return reads === 1 ? 'safe_tool' : 'different_tool' - }, -} +Every teardown request joins one memoized path. The order is: -// Wrong: validation and storage observe different values. -validateName(input.name) -storeName(input.name) +1. Deactivate creation or driving and let synchronous publication finish. +2. Stop and drain the driver, including idle injection flushes. +3. Detach the agent. +4. Detach the session. +5. Dispose the agent scope. +6. Retire transaction ownership tracking. -// Right: one accepted value drives both. -reads = 0 -const acceptedName = input.name -validateName(acceptedName) -storeName(acceptedName) -``` +This order lets final agent and session events use the matching scoped listeners and keeps persistence observers attached through the final flush. Scope disposal comes last because registration revocation is the externally visible lifetime boundary. -### Registered definitions are frozen snapshots +## Session append: materialize, validate, commit, notify -Tool registration creates the stored definition identity once; changes occur through explicit unregister/register effects rather than mutation of a caller-retained object. Parameters are materialized in one traversal, callbacks bind once to the accepted definition receiver, and the stored record is deep-frozen. +Session events cross a durable boundary, so append owns their data. The rest of the algorithm uses one attached entry and one commit point. -The first-party `defineTool()` helper applies the same boundary before registration. It captures each option once, materializes the authoring `SchemaSpec`, and derives both the wire schema and later execution/presentation validation from that owned spec. +### Durable data is materialized once -```text -defineTool(options): - accepted = read each option exactly once - parameterSpec = snapshotLosslessJson(accepted.parameters) - wireSchema = snapshotLosslessJson(convertToJsonSchema(parameterSpec)) - build execute and presentation validation over parameterSpec +Session headers, seeds, and appended events are lossless JSON data. The Session constructor or append path materializes and validates them before storage and exposes frozen snapshots, so later caller mutation cannot change persistence, replay, or model reconstruction. -registerTool(context, definition): - accepted = read each definition field exactly once - stored = deepFreeze({ - accepted name, description, timeout, - parameters: snapshotLosslessJson(accepted.parameters), - execute: bind accepted.execute to definition, - presentation callbacks: bind accepted callbacks when present - }) - layerFor(scopeOf(context)).add(stored.name, stored) -``` +This is a real ownership boundary: the values leave the caller, may be persisted, and must reconstruct the same request later. It is intentionally stricter than a typed same-process callback or registry definition. -`get()` and `visible()` return the frozen stored definitions; `schemas()` returns detached projections. Replacing `definition.execute` after registration has no effect, while a callback can deliberately read live state from its closure or original receiver. +### Pre-commit listeners can veto; post-commit observers cannot -Factory and backend registration use different reentrancy orderings around the same ownership rule. `AgentFactory` registration claims its single slot before reading callback accessors. `SubagentProvider` registration first snapshots the provider fields, then its effect checks and enters the accepted name. Both capture callback identity and intentional receiver state once, and hot-reload cleanup closes over the accepted slot or key instead of rereading a mutable public property. +Append follows one sequence: -### Durable session ownership carries the scope key +1. Materialize the durable event and surface intent. +2. Claim the SessionEntry and reject reentrant append on that entry. +3. Resolve scoped callbacks and run internal invariant validation. +4. Push exactly once; this is the commit point. +5. Notify each observer independently, containing synchronous and asynchronous failures. +6. Release append state and honor a detach requested during publication. -The [session-immutability RFC](2026-06-11-dev-invariants-over-deep-readonly.md#session-owns-immutable-history) owns header, event, and snapshot semantics. Agent-scope correctness adds one requirement: the store keeps append publication, accepted registry IDs, and captured scope carriers in private owner state rather than caller-writable fields. Outside JavaScript therefore cannot rename a stored session or redirect later `session/event` delivery by mutating visible state. +No observer error makes a committed event look uncommitted, and one bad listener cannot starve later listeners. Session invariants stage their transition before commit and apply it only when the same event reaches the contained post-commit observer. -An entered session treats append as one synchronous acceptance-and-publication boundary: +`flush()` starts every persistence listener and awaits every result before reporting failure. This deliberate all-settled behavior prevents a synchronous failure from starving another backend or final flush. -1. Capture the current store attachment and its private attachment epoch, keep the attachment live, then materialize and deep-freeze the caller's event data. -2. Reject if caller getters changed either value; the epoch catches even a transient attach-then-detach that restores the original hook lookup. The event must not become live without the store hooks that accepted it. -3. Resolve the exact scoped `session/event` callback list before commit. Cordis runs `internal/dispatch` during this step, so development invariants can still reject a bad candidate while the log is unchanged. Resolution uses a throwaway mutable argument array; replacing its accepted session or event rejects before commit, and product callbacks later receive a fresh fixed tuple. -4. Push the event into the log. This is the commit point. -5. Invoke the captured callbacks with per-listener containment and best-effort non-throwing failure reporting, then release the attachment barrier and honor any detach requested during acceptance or publication. +## Trust boundaries: copy only when ownership actually changes -The boundary rejects a reentrant `append()` until the outer callback list drains. Without that guard, an early observer could append event N+1 before a later persistence observer had received event N, reversing delivery relative to the log. Detach is deferred for the same interval, so no event can commit after `session/disposed` or lose its publication hooks. Once the push occurs, synchronous observer throws and returned-promise rejections are logged and contained rather than escaping as a false append failure or starving later observers. +The runtime distinguishes typed in-process contracts from serialization and durability boundaries. This is the main simplification rule for values and callbacks. -`SessionStore.flush()` uses the same pre-dispatch fixed-tuple check but remains an awaited durability barrier rather than an observe-only publication. It starts every captured listener synchronously, converts a synchronous throw into that listener's rejected result so later listeners still start, waits for every result to settle, and only then rejects with the first failed listener in registration order. One broken backend therefore cannot make the caller return while another backend is still flushing. - -Approval requests follow the same async boundary at smaller scale: one capture preserves exact agent/signal identities, copies scalar fields, captures the session once, and drives `approval/asked`, scoped policy, cancellation, and `approval/decided` from that record. - -### Tool execution has pipeline-owned identity - -The [interception-seams RFC](../feature/2026-06-30-interception-seams.md) owns the public tool-pipeline contract. For agent-scope correctness, `ctx.tools.execute(input)` must turn caller-owned input into one pipeline-owned `ToolExecution` before any scoped policy or dispatch runs. It first reads `callId` and `name` once and requires strings; a failure there rejects because even an error result would lack trustworthy correlation identity. Once those strings are accepted, later input failures can become normal final error outcomes. - -Arguments are materialized once and deep-frozen. The registry assigns a frozen property-free `ToolExecutionToken`; callers cannot choose it. `token`, `callId`, `name`, `arguments`, `agent`, and optional opaque `parent` token become non-writable and non-configurable before policy. `signal` is the only operational field an around-dispatch wrapper may replace or remove. - -```text -prepareExecution(input): - callId = read input.callId exactly once - name = read input.name exactly once - require both are strings - - accepted = read arguments, agent, parent, and signal exactly once - require parent is absent or a registry-minted token - arguments = deepFreeze(snapshotLosslessJson(accepted.arguments)) - - execution = { - token: new frozen property-free object, - callId, name, arguments, - agent: accepted.agent, - parent: accepted.parent, - signal: accepted.signal - } - protect every field except signal - return execution -``` - -Stable execution identity prevents middleware from changing which tool or scope policy accepted. It also gives structured-output commit a safe `WeakMap` key when an adapter reuses a string call ID. Code Mode correlates an SDK sub-call with its enclosing `run_code` using only the outer execution's opaque token, never a mutable reference to the live outer object. - -Result boundaries apply the same ownership rule. Each transform returns data that is captured field-by-field, validated, materialized, and ultimately deep-frozen for final observers; malformed outcomes normalize to JSON-safe error results rather than reaching the session log as apparent success. - -## Owner-final policy: four narrow boundaries - -Waterfalls remain the ordinary extension mechanism; each of four protocol invariants runs after the last extension point capable of violating that specific invariant. Each owner-final API has the weakest one-way power that can preserve its guarantee. - -Here **canonical** means the named registry or tool-schema-provider output assembled before the waterfall—not “all output the service approves.” Protection restores only the names its owner declares. - -| Invariant | Cooperative extension point | Owner-final boundary | Guarantee | -|---|---|---|---| -| Named prompt/tool contribution | `system-prompt/assemble` waterfall | `systemPrompt.protect()` finalization | Canonical presence, absence, definition, and local anchor survive | -| Non-overridable tool denial | `tools/pre-execute` allow/deny/ask waterfall | Synchronous `tools.guard()` | A denial cannot become allow | -| Authoritative live outcome | Execute and post-execute waterfalls | Awaited `tools/result` notification | Observers receive one immutable final result | -| Terminal protocol completion | Continuation waterfall and pending steering | Serial `agent/turn-stop` | No middleware or late steering creates another step | - -### Prompt protection restores named canonical contributions - -`systemPrompt.protect({ sections, tools })` snapshots the requested names and restores their canonical registry or tool-schema-provider output after the complete assembly waterfall. Global and matching scoped protections compose by set union; a waterfall failure still fails assembly rather than triggering recovery. - -Protection covers both presence and absence. If the canonical assembly omits a protected name, finalization removes a listener-fabricated entry; this is how Code Mode keeps a native schema absent while preserving the SDK/transport form. Tool providers likewise expose one captured coherent record for schemas and optional known names, so a stateful getter cannot validate one name and display another. - -#### Global section protection reserves its name - -A globally protected section name cannot be shadowed by a scoped section. Scoped registration under an already protected name fails, and adding protection fails if a scoped shadow already exists. This check occurs before assembly because scoped-over-global merge would otherwise make the shadow itself appear canonical. - -Tool-schema protection does not create a blanket reservation for unrelated schema names. Providers are additive and may deliberately contribute other executable schemas; the owner-final guarantee covers only the named canonical contribution. - -#### Restoration preserves a useful local anchor - -Protection does not reset the whole assembly. It removes protected names from the waterfall result and reinserts each canonical entry before the first surviving later unprotected canonical neighbor, or at the end if none survives. Unprotected entries retain the order and definitions chosen by middleware. - -```text -assemble(context): - assembly = assemble registries for context.scope - canonical = snapshot protected section/tool inputs - transformed = await systemPromptAssembleWaterfall(assembly) - - for each protected canonical name: - remove every transformed entry with that name - if canonical includes the name: - insert before first surviving later canonical neighbor, else append - - return transformed -``` - -Code Mode globally protects `tools:sdk` and reserved `run_code`; structured output adds scoped protection for its instruction and capture schema. - -### Tool guards deny monotonically - -`ctx.tools.guard()` installs a global or scoped synchronous check after the complete `tools/pre-execute` waterfall and before dispatch. A guard returns a denial reason or `undefined`; it has no allow result. - -Pre-execute hooks still compose ordinary allow, deny, and ask decisions. An ask resolves through the optional approval service, where only `allowed-once` becomes allow and absence or any non-grant becomes deny. Guards run afterward, so listener order cannot convert their denial into dispatched work. - -```js -agent.ctx.on( - 'tools/pre-execute', - async () => ({ kind: 'allow' }), - { prepend: true }, -) - -agent.ctx.tools.guard(execution => - execution.name === 'bash' - ? 'reviewer agents are read-only' - : undefined, -) -``` - -Even a later prepended allow listener cannot bypass the guard. A denied call still becomes an error outcome that flows through result transformation and final observation. - -### `tools/result` observes the final live outcome - -For a successfully prepared execution, the live pipeline is `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute` → `tools/result`. Malformed non-identity input instead takes the error-shell path directly to final observation, as the algorithm below shows. The first, execute, and post stages are transformable waterfalls; `tools/result` is an awaited observe-only notification after every transform and outer error normalization. - -Every observer receives the same frozen execution and a separate deep-frozen snapshot of the owned result returned to the caller. Listener failures are contained independently, so they cannot change that returned result or starve peers. Routing uses `execution.agent`. - -`tools/result` is not the durable `tool/result` session event. The live notification also fires for direct programmatic executions and is the source of truth for in-process commit logic. The agent loop later appends the durable event for replay, UI reconstruction, and model history. - -```text -execute(input): - accept trustworthy callId and name - try to prepare pipeline-owned execution - on preparation failure: - create an identity-bearing error shell - ownedResult = owned error result - freeze execution - observerResult = deepFreeze(snapshotLosslessJson(ownedResult)) - await every tools/result observer independently with observerResult - return ownedResult - - gate = await tools/pre-execute(execution) - resolve ask through approval when needed - denial = policy denial or first guard denial - - if denied: - result = errorResult(denial) - else: - result = await tools/execute(execution, dispatchRegisteredTool) - - result = await tools/post-execute(execution, result) - ownedResult = normalize into owned lossless JSON - freeze execution - observerResult = deepFreeze(snapshotLosslessJson(ownedResult)) - await every tools/result observer independently with observerResult - return ownedResult -``` - -Waterfalls transform only at their named stages; guards only deny; final observers only observe. - -### `agent/turn-stop` makes continuation terminal - -Steering is input for another model step inside the current turn; queued prompts wait for a future turn. Ordinary continuation remains extensible: the loop computes a default, runs `agent/turn-continuation`, records any force-continue reason as steering, and treats pending steering as a reason to continue. - -The scoped serial `agent/turn-stop` checkpoint runs after that folding. A listener returns `{ action: 'stop' }` or abstains with `undefined`; malformed values and throws close the current turn with an error. A stop is terminal, so later listeners and steering cannot restore continuation. - -The loop uses `strictSerial` because ordinary Cordis serial dispatch treats `null` and `false` as abstentions. This terminal protocol permits only `undefined` to abstain, making accidental return values fail closed. - -Terminal state remains active through `turn/end` and the durability flush. Steering added by continuation, turn-close, or flush listeners is discarded after a terminal stop, while the ordinary queued-prompt FIFO remains untouched. - -```text -afterSuccessfulStep(turn): - decision = await agent/turn-continuation(defaultDecision) - record decision.reason as steering when present - if steering is pending: decision = continue - - terminal = await strictSerial(agent/turn-stop) - if terminal == stop: - discard steering - terminalStopped = true - decision = stop - - append turn/end - await session/flush - - if terminalStopped: - discard steering added by turn/end or flush listeners - else: - move leftover steering to the next-turn queue -``` - -This stronger control is reserved for terminal protocols such as a completed structured child; ordinary continuation policy remains cooperative. - -## Subagents: the composition proof - -In-process subagents add no second scoping model. They create a fresh flat child scope during unpublished setup, install ordinary scoped persona/filter/protocol registrations, own the child through a run handle, and use the same owner-final checkpoints for structured output. - -The roles and phases are explicit: - -| Role | Responsibility | +| Boundary | Ownership rule | |---|---| -| Caller | Supplies parent, prompt, optional child configuration, and eventual disposal | -| `SubagentService` | Validates capabilities, owns the public wrapper, normalizes result and lifecycle telemetry | -| `SubagentProvider` backend | Chooses transport and creates one run | -| In-process driver | Owns child creation, setup, prompt drive, result read, cancellation, and teardown | -| Child `Agent` | Uses the ordinary agent lifecycle and its fresh `agent.ctx` | +| Typed service/plugin call in the same process | Borrow readonly values and callbacks | +| Parsed plugin configuration or external file | Validate semantic and structural input | +| Queued inbox message | Materialize before asynchronous consumption | +| Model/tool JSON input or output | Materialize at the model/tool boundary | +| Durable session or persistence data | Materialize and validate before commit | +| Worker, process, or wire message | Serialize, validate, and own the decoded value | -```text -recommended caller order: start -> await run.started -> await run.result -> await run.dispose() -internal observation: started and result may settle in either order; lifecycle publication waits for started -ownership: dispose may race any phase and joins one cleanup promise -``` +Tests that fabricate hostile getters, replace typed callbacks after handoff, or cast fake service objects do not define a production contract by themselves. The runtime keeps checks where data crosses a parser, queue, model, durable, file, worker, process, or wire boundary and relies on readonly types plus plugin discipline inside the trusted process. -The [agent-scope contract](2026-07-08-agent-scope-contexts.md#subagents-use-the-same-composition-rule) gives the contributor-facing example, and the [subagent capability RFC](../feature/2026-06-21-subagent-capability-seam.md) owns the public `SubagentRun` contract. This section follows only the in-process ownership and terminal-protocol implementation. +Callback containment is separate from data ownership. Listeners are arbitrary extension code and can throw even when their arguments are trusted; publication and post-commit paths still contain failures according to their event contract. -### The child world uses ordinary registrations +## Tools and prompts: one view, one execution identity, explicit finality -A child persona is a scoped `deployment:persona` section. Its tool filter is a scoped restriction over the live global tool layer. Structured output is a bundle of scoped tool, prompt, protection, guard, and listener registrations. +Tool presentation and execution share one private resolver, while prompt/tool owners declare the few contributions that cooperative middleware may not alter finally. No second registry mirrors ownership. -```js -let structured -const setup = childCtx => { - if (persona !== undefined) { - childCtx.systemPrompt.section({ - name: 'deployment:persona', - order: 0, - text: persona, - }) - } - if (toolFilter !== undefined) childCtx.tools.restrict(toolFilter) - if (schema !== undefined) { - structured = attachStructuredRuntime(childCtx, schema) - } -} -``` +### One resolver defines the tool view -The driver creates one run-owner fiber under `parent.ctx` and calls the child factory through it. Parent teardown, `spawn` backend teardown, and manual run disposal reach the same node, but the child still receives a new registration key. Lifetime inheritance therefore does not imply registration inheritance. +The private resolver applies the current presentation mode, live global restrictions, exact local overlay, and local shadowing. Schemas, lookup, execution, Code Mode SDK generation, restriction validation, and owner-final name derivation all use that resolver or its pre-restriction global-name view. -### Structured output is a child-owned terminal protocol +The [subagent composition-controls RFC](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md#tool-filtering-is-one-live-global-view-rule) owns the user-visible allow/deny semantics. The implementation requirement is agreement: a filtered-away global cannot remain executable through a different lookup path, and a locally shadowed definition is the same definition presented and executed. -A structured child registers a real-schema `structured_output` tool and instruction in its own scope. Concurrent children can use different schemas without a global placeholder, reference count, or remove-for-everyone pass. +`ToolRestriction` accepts readonly allow/deny names and compiles them into internal sets. Multiple restrictions intersect. Public `visible()` and `knownNames()` methods are unnecessary because only the registry needs the intermediate views. -The [Code Mode RFC](../feature/2026-06-15-code-mode.md) owns advertised wire routes and SDK behavior. The correctness distinction here is execution nesting: a native capture has one tool execution, while an SDK capture is an inner execution whose parent token identifies the enclosing `run_code`. Tool mode is presentation rather than an execution allowlist, so a direct unadvertised capture still follows the native commit path; a deployment that forbids that route uses an execution guard. +### Tool execution owns identity and boundary materialization -Named protection restores this child's canonical capture contribution and instruction without erasing unrelated schemas deliberately added by another assembly provider. +The registry assigns every execution a fresh branded `Symbol` token. Nested Code Mode calls carry the outer token as `parent`, so structured output can correlate an inner capture with its enclosing `run_code` result by identity. -#### Native calls commit once; Code Mode SDK calls commit twice +A fresh registry-assigned Symbol provides collision-free execution identity without a WeakSet membership registry. Callers cannot supply the execution's own token through `ToolExecutionInput`; they only receive the pipeline-owned `ToolExecution` after the registry creates it. This is a trusted typed contract, not a runtime defense against arbitrary casts or JavaScript callers. -The capture body validates and stages a cloned value by stable `ToolExecution` identity. The scoped final-result observer commits a native capture only if that exact execution's final result succeeds. +Arguments are materialized once where model/tool JSON enters the pipeline. Pre-, around-, and post-execute listeners operate on the typed execution and decisions. Call ID correlation, approval, monotonic guards, and Code Mode nesting remain explicit relational checks. -A schema-validation failure becomes the ordinary `INVALID_ARGS` tool result, so the model can correct the value and call the capture tool again within the same turn. +After the last post-execute listener, the registry materializes and freezes the accepted final result once. Every `tools/result` observer receives that exact committed object, and observer failures are awaited and contained individually. An outer pipeline failure is normalized into a committed error result, so observers can discard staged work against the same authoritative boundary. -```text -structured_output.body(value, execution): - validate value against this child's schema - staged[execution] = clone(value) - return ordinary success +### Contribution-owned finality protects only named invariants -on tools/result(execution, finalResult): - if execution is staged: - value = staged.remove(execution) - if finalResult succeeded: - captured = value -``` +Most prompt assembly remains a cooperative waterfall: listeners may reorder, replace, or remove ordinary sections and schemas. A contribution sets `ownerFinal: true` only when its owner must retain final control over that named entry. -For a Code Mode SDK call, successful inner observation records a pending value against the opaque outer `run_code` token. Commit waits for the outer transport's own successful final result because an inner side effect can succeed while the program or its post-policy still fails. +Prompt sections carry owner-finality directly. Tool definitions carry it through the tool provider's `ownerFinalNames`, including canonical absence when a presentation mode intentionally omits a tool. `tools:sdk`, `run_code`, and structured-output instruction/schema contributions use this flag. -```text -on tools/result(innerStructuredCall, innerResult): - if innerStructuredCall is staged: - value = staged.remove(innerStructuredCall) - if innerResult succeeded: - pending = { outerToken: innerStructuredCall.parent, value } +An owner-final name is reserved across the global and scoped layers: a scoped shadow cannot be added beneath a global owner-final contribution, and a global contribution cannot become owner-final while any scoped shadow already exists. This makes the registered owner definition unambiguous before assembly begins. -on tools/result(outerRunCodeCall, outerResult): - if pending.outerToken == outerRunCodeCall.token: - value = pending.value - pending = none - if outerResult succeeded: - captured = value -``` +Assembly takes one private canonical snapshot before the waterfall. After listeners finish, it restores only owner-final names to their canonical presence, absence, definition, and relative anchor among surviving entries. Unrelated listener additions and reordering remain untouched. -Once capture is staged against an outer transport or committed, the scoped guard denies later calls in that response. After commit, `agent/turn-stop` ends the turn after ordinary continuation and steering fold. A child that otherwise completes cleanly without a committed capture returns an error rather than being re-prompted; requesting a schema makes output mandatory, not guaranteed. +Attaching finality to the owning contribution has two benefits. Registration and cleanup cannot drift from a separate protection registry, and the reader can see why a particular prompt/tool entry is special at its definition. -### The run protocol separates acceptance, readiness, result, and disposal +### Structured output commits only authoritative outcomes -`SubagentService.start()` returns synchronously, but `run.started` is the publication boundary. Callers treat the child as live only after readiness, consume `result`, and always dispose the run. +Structured output uses the final prompt/tool boundaries as a two-phase commit. The child-scoped `structured_output` tool and its instruction are owner-final; the tool body validates a candidate and stages it by the current `ToolExecution`, but successful capture is decided only by immutable `tools/result` observations. -Pre-readiness cancellation of an in-process run deactivates the run-owner fiber, prevents publication, rejects `started`, resolves `result` as `aborted`, and emits neither subagent lifecycle edge. +For a native call, the observer deletes the stage and commits its value only when that exact execution's final result succeeds. A post-execute block or outer pipeline failure therefore cannot leave a captured value behind. -`SubagentProvider` registration captures name, capability flags, the `inheritsParentContext` conversation-history descriptor, and the bound start callback once. The descriptor says whether completed parent turns seed the child's conversation; it says nothing about scope, services, tools, or authority. +For a Code Mode SDK call, the inner successful result records `{ parentToken, value }` rather than committing. The observer waits for the `run_code` execution whose token matches `parentToken` and commits only if that outer final result also succeeds. Program failure, runtime abort, or outer post-policy denial discards the pending value. -Starting a run captures every request field once. Parent and abort signal remain identity references; prompt, filter, schema, and options are detached lossless JSON; fixed `persona` and absolute `maxDepth` values validate before backend ownership. The in-process backend separately snapshots its optional session seed, and the service snapshots the terminal result when it settles. +Once a value is pending or committed, a scoped monotonic guard denies later tool calls. After commit, the ordinary serial `agent/turn-stop` listener returns a stop decision after continuation and steering have already folded. A schema-validation failure remains an ordinary `INVALID_ARGS` tool error and leaves the child able to retry within the same turn. -Depth validation repeats at each public entry while one helper owns the accepted domain: +Pure Code Mode omits `structured_output` from native wire schemas and exposes it through the generated SDK. Contribution-owned finality preserves that canonical absence, preventing an assembly listener from fabricating a second native route while keeping the instruction and SDK declaration intact. -```text -tool-subagent plugin load: - assertSubagentMaxDepth(config.maxDepth) +### Four final boundaries have four narrow powers -SubagentService.start(request): - capture and validate request.maxDepth +Owner-final behavior is not a general priority system. Four domain owners need four different one-way powers after cooperative extension points: -startInProcessRun(request): - capture and validate request.maxDepth - parentDepth = validated depthOf(parent) - childDepth = parentDepth + 1 - reject if childDepth is not a safe integer - reject if maxDepth exists and childDepth > maxDepth -``` - -Only `undefined` means parent depth zero. Present depth and cap values must be non-negative safe integers and must not be negative zero; derived overflow rejects even when no request cap exists. - -The service does not expose the backend-owned run handle directly. It captures `id`, `started`, `result`, and methods once; binds methods to that handle; wraps result in one detached frozen record; and installs a shared disposal promise before calling untrusted backend cleanup. Once a callable backend disposer has been captured, a malformed later field triggers rollback; if no callable disposer can be captured, rollback is impossible and acceptance fails immediately. A backend disposer that directly returns the wrapper's reentrant promise is rejected as a cycle instead of hanging. - -```text -startInProcessRun(backendContext, acceptedRequest): - install backend ownership - attach accepted abort signal - create run-owner fiber under accepted parent.ctx - create child through runOwner.ctx.agents with unpublished setup - - started = child creation publication - result = after started: - send accepted prompt - await child idle - derive owned terminal result - dispose = dispose run owner and await quiescence - -SubagentService.start(...): - backendRun = backend.start(detached request) - serviceRun = freeze accepted id, readiness, bound methods, normalized result - observe result immediately - after readiness: - emit subagent/start, then buffered/eventual subagent/end - on readiness failure: - emit neither lifecycle edge -``` - -The service observes result settlement immediately even while readiness is pending, preventing an early rejection from becoming temporarily unhandled. Lifecycle listeners receive one frozen payload; their throws and returned-promise rejections are contained independently and cannot veto the run. - -## Workflow integration preserves the subagent contract - -The [dynamic-workflows RFC](../feature/2026-07-05-dynamic-workflows.md) owns workflow behavior. The agent-scope concern is whether the worker bridge preserves the same readiness, terminal-claim, and bounded-cleanup boundaries across a message port. It never announces an unready child, never lets cleanup rewrite an already chosen result, and never suppresses disposal merely because another terminal fact already won. - -The worker executes the workflow script and exchanges protocol messages; the host owns `SubagentService`, which invokes `SubagentProvider` backends and returns normalized run wrappers that the host retains. Their lifetimes follow dependency shape: an AgentLoop-created agent stops when its loop unloads, while a workflow run captures its holder-bound `SubagentService` at start, so unloading the workflow engine prevents new runs without revoking an already returned run. - -Three state dimensions remain separate: - -| Dimension | Question | Winning rule | +| Boundary | Final power | Why ordinary listener order is insufficient | |---|---|---| -| Admission | May a worker message still start or announce a child? | Closed admission refuses the exact run and cleans it up | -| Terminal claim | Which external result does the workflow expose? | Earlier accepted external cancellation wins; otherwise first result/death claim wins | -| Physical cleanup | Which registered children and worker resources remain? | Every path may still dispose survivors through per-call gates | +| Prompt assembly | Restore named canonical contributions | A later listener can remove or replace an invariant schema or instruction | +| Tool pre-policy | Deny monotonically | A later listener must not re-allow an already denied call | +| Tool result | Observe the immutable committed outcome | Structured output must commit only the result that actually escaped the pipeline | +| Turn continuation | Stop after ordinary continuation folding | A committed terminal output must end the turn | -### Child admission waits for readiness +`ToolGuard` remains the monotonic policy registry. Final tool observation is the contained `tools/result` point described above. Terminal structured output listens on the ordinary serial `agent/turn-stop` fold after normal continuation and steering decisions; no public `strictSerial()` dispatcher is needed for the typed listener contract. -After `SubagentService.start()` returns its normalized wrapper, the host registers that exact wrapper before awaiting, attaches result observers immediately, and rechecks admission both then and when `started` settles. A closed boundary claims cancellation and disposal for that exact entry, removes it only when disposal settles, and reports `ChildStartError` only while the worker reply channel remains open. +### Skill and approval services trust typed callers -The backend's nested `start()` may synchronously reenter workflow cancellation before the service wrapper reaches the host registry. The immediate post-start check and exact-wrapper identity guard close that interval; a backend that later fulfills its own readiness cannot resurrect workflow admission. +Skill registry definitions and approval policies are readonly same-process contracts. Their services do not clone callback objects or defend against post-handoff callback replacement. -```text -after subagents.start returns its run wrapper: - register exact wrapper for cancellation - observe and snapshot result immediately - if admission closed: refuse and clean exact wrapper - else await run.started +Skill still validates external skill files and parsed provider output, routes catalogs through the calling agent's tool view, and disposes registrations exactly. Approval still resolves policy, observes cancellation, routes `approval/request` by `request.agent`, records the durable audit pair, and contains answerer and post-commit observer failures. - on ready: - if admission closed: refuse and clean exact run - else send ChildStarted, then buffered/eventual outcome +## Subagents: readiness is the start promise - on readiness failure: - send ChildStartError only if the worker reply channel remains open - dispose exact wrapper if still registered -``` +Subagent startup has one ownership transfer. The provider owns partial resources until its start promise fulfills with a ready published run; the caller owns the returned run and must dispose it. -### Each terminal contender claims before its own callbacks +### The service contract has one cancellation channel -Each terminal path records the state it owns before invoking its own callback fanout. External `cancel()` records the accepted cancellation reason before invoking child cancellation. On the Result path, the worker queues its `Result` message before settlement cleanup messages on the same port, and the host records the winning result before any Result-triggered abort or cancellation. Reentry therefore observes the fact that already won instead of rewriting it. +`SubagentProvider.start()` and `SubagentService.start()` return `Promise`. The promise fulfills only after the backend has established the child it promises, so callers and `subagent/start` observers never need a second `run.started` readiness promise. -```text -on workflow Result: - cancellationWasAlreadyAccepted = external cancellation is in flight - claim chosen result: - if earlier external cancellation and result is not cancelled: - cancelled result - else: - worker result +`SubagentStartRequest.signal` is required. Aborting it requests cancellation during startup and after readiness. `SubagentRun.dispose()` also requests cancellation and awaits quiescence. There is no separate public `run.cancel()` channel. - if not cancellationWasAlreadyAccepted: - abort shared child-request signal - cancel every registered child through its at-most-once gate - settle chosen result -``` +Optional `sendMessage()` supports a live backend that can accept steering. Optional `resume()` returns `Promise` because the resumed child has the same asynchronous readiness boundary. -The worker may also send a later `ChildCancel`; host fanout and the worker message share one per-call cancellation gate, so an arbitrary backend's `cancel()` need not be idempotent. Each callback is contained independently. +The service validates provider capabilities and request semantics before calling the provider. A provider rejection cleans any partial resources before the rejection escapes and emits no `subagent/start`/`subagent/end` pair. After fulfillment, the service attaches result observation, emits scoped start, and returns the run. Provider removal prevents later starts but does not revoke a run already accepted by the provider. -### Worker death, exit, and disposal remain separate +### In-process providers reuse the core transaction -The first worker death signal closes message admission, claims a death result unless an earlier terminal fact won, cancels and disposes registered children, and synthesizes missing lifecycle ends. A queued message can arrive between Node's `error` and `exit`, so the logical admission barrier—not physical exit—prevents late child creation or narration. +Spawn and fork share one in-process driver. It creates the child through `parent.ctx`, passes the required signal into the core creation transaction, and installs persona, tool restriction, and structured-output contributions during unpublished setup. -Physical exit performs a final disposal-only sweep without repeating explicit cancellation. A cancellation grace period bounds how long the host waits for cooperative settlement before terminating the worker; a grace result can already be chosen while exit cleanup still needs to dispose surviving child handles. The bound is real: after grace expires, public disposal may return after invoking child disposal and reaping host resources even if a slow backend disposer has not reached quiescence. +The provider awaits creation and returns only the published run. At the handoff, core creation detaches its creation-only abort listener; the provider immediately rechecks the signal before installing the live-run listener, so an abort in that narrow interval disposes the new handle instead of escaping cancellation. Parent teardown follows the child because the operation belongs to `parent.ctx`; provider unload blocks new starts but does not become a second revocation owner for accepted runs. The run disposer cancels the child and awaits the AgentHandle's ordered teardown. -Public `handle.dispose()` claims its shared promise before invoking cancellation or child callbacks. Each `disposeChild` likewise claims its call-ID promise before invoking the backend disposer. Public-first reentry joins the public promise; worker-first reentry lets the holder traversal join the already claimed child promise. Settled `dispose()` still drives a host-side reap before awaiting quiescence, so a fire-and-forget child cannot remain alive merely because workflow result settlement already occurred. +Spawn uses an empty session seed. Fork uses a validated completed-turn prefix. Conversation seeding changes history only and does not import scope, tools, services, or authority. -Together these rules ensure `workflow/agent-start` names only ready children, external result precedence is stable, and every surviving child reaches disposal. +### ACP providers own the process until readiness or cleanup + +An ACP provider crosses a real process and wire boundary, so it retains validation, environment scrubbing, message serialization, abort/process races, and kill-to-exit quiescence. + +Start resolves only after `initialize` and `newSession` succeed. Abort, spawn failure, RPC failure, or invalid startup response reaps the process before rejection. After readiness, result maps the ACP prompt outcome and streamed output; dispose requests cancellation, closes the connection, and awaits process exit through one memoized path. + +## Workflows and ACP UI: retain only independent async facts + +Worker and editor bridges need more state than same-process registries because messages, process death, and rendering can settle independently. Their state is organized around those real facts rather than duplicate cancellation protocols. + +### Workflow children are pending starts or published records + +The workflow host keeps pending provider-start promises and published child records. A child moves from pending to published only when async `SubagentService.start()` fulfills; rejected starts clean their partial provider work and produce no child lifecycle pair. + +One host-owned AbortController supplies the required signal to pending and live children. Closing workflow admission aborts that signal, so there is no duplicate `ChildCancel` worker RPC or explicit host-side `run.cancel()` fanout. Quiescence waits for both pending starts and published child disposal. + +The worker boundary still serializes requests and outcomes. The host retains first-terminal-outcome arbitration, exact child accounting, worker-death handling, grace termination, late/duplicate message rejection, and bounded cleanup because result receipt, worker exit, and child quiescence are genuinely independent facts. + +### Terminal result and physical cleanup remain separate + +The workflow result records the first accepted terminal outcome according to the public precedence rules. Cleanup can continue after that result is chosen: live children still need disposal, a worker still needs termination, and a slow external backend may outlive the configured grace bound. + +Public disposal claims its memoized promise before invoking callbacks. Worker death closes admission before processing any queued late child request, synthesizes missing lifecycle ends, and starts child/process cleanup without rewriting an outcome already claimed. + +### ACP prompt settlement does not depend on rendering success + +The ACP UI correlates a prompt with its observed turn directly. It does not scan from a `logWatermark` or use session status as a second reconciliation oracle. + +Prompt handling settles correlation in a `finally` around transcript rendering. A rendering failure can fail presentation, but it cannot skip prompt settlement or leave the session permanently in flight. Concurrent loads of the same persisted caller-supplied session ID remain excluded because that is a real persistence identity race, not a UUID collision concern. ## Correctness enforcement -The runtime rule is checked at four escape boundaries: API shape couples related subjects, TypeScript marks typed dispatch, development invariants inspect actual dispatch, and repository gates keep declarations aligned with enforcement. +The design is enforced at types, runtime escape points, generated contracts, and behavioral tests. No one layer is asked to prove what it cannot observe. -### API shape couples values that must agree +### Types make the ordinary path hard to misuse -`agentEvents(context, agent)` couples carrier, subject, and first event argument. `assembleContextFor(agent)` couples prompt facts with scope selection. `SessionStore.flush(session)` owns lookup of the carrier captured when the session entered. +Readonly contracts describe borrowed same-process values. `Scoped` marks event receivers, `agentEvents()` fuses carrier and subject, tool inputs omit registry-owned tokens, and subagent async return types expose readiness directly. -```text -assembleContextFor(agent): - return { agent, scope: agent } +TypeScript cannot govern JavaScript casts, direct Cordis dispatch, process messages, or durable files, so runtime enforcement remains at those escape points. -agentEvents(context, agent): - carrier = scopeTarget(agent, agent) - return dispatcher that always injects agent as the event subject -``` +### Runtime invariants cover cross-service facts -These helpers make a mismatch harder to express than the correct spelling. +The invariants plugin verifies that every declared scoped event uses a marked carrier and that event families exposing a subject use the matching key. Session trace validation stages before append commit and advances after the same event commits. -### Type markers cover every scoped event declaration +The plugin does not police trusted setup by scanning registries or reject prompt assembly objects fabricated through casts. Those checks would turn composition contracts into speculative runtime machinery without protecting a real external boundary. -Scoped agent, approval, tool, prompt, session, and subagent lifecycle events declare a `Scoped` receiver. TypeScript rejects a bare subject at typed dispatch sites, including subagent lifecycle events scoped to the delegating parent. +### Generated artifacts keep public contracts aligned -The marker is compile-time only; JavaScript, casts, and direct Cordis dispatch can bypass it. +The event catalog, service catalog, producer/consumer matrix, configuration catalog, module graph, tool catalog, and type-equivalence blocks are generated or freshness-gated from source. `verify-scoped-dispatch` keeps the declared scoped-event set aligned with runtime invariant coverage. -### Development invariants inspect actual dispatch - -The invariants plugin uses Cordis's internal dispatch as the pre-delivery enforcement point. Every scoped event requires a marked carrier, and events whose arguments expose the subject require the carrier key to be the same object. For `session/event`, callback resolution also precedes the log push: the plugin validates and stages the exact candidate there, then advances its live trace only when the same committed event reaches its contained post-commit listener. A later internal check can therefore veto without advancing either log or trace. Both halves of this oracle are explicitly global, so mounting the plugin under a scoped context cannot stage a foreign event without also applying its committed transition. - -Session and subagent payloads do not expose their owner key directly, so their service centralizes key selection and the invariant proves carrier presence. Additional invariants reject an assembly whose `agent` and `scope` disagree and a turn opened before `agent/session-start`. - -Dedicated `dsh-scope` unit tests cover the carrier's advanced Proxy behavior: private-field method binding, call/construct shape, primordial filter invocation, own-key/descriptor consistency, and explicit configurable definitions. These are implementation tests, not checks performed by the invariants plugin. - -### Repository gates keep declarations and dispatchers aligned - -`verify-scoped-dispatch` compares declared scoped events with the runtime invariant table, and the generated event matrix requires every declaration to name a recognized dispatcher. Source JSDoc generates the [event catalog](../../../cordis-catalog/events.md), which remains the exhaustive signature and mode reference. +Behavioral tests pin scoped routing and disposal, final-entry collision cleanup, publication rollback, ordered quiescence, durable pre/post-commit behavior, live tool filtering across presentation and execution, owner-final Code Mode and structured output, async subagent startup and signal cancellation, worker terminal arbitration, ACP settlement, and process teardown. ## Alternatives considered -The [agent-scope contract](2026-07-08-agent-scope-contexts.md#alternatives-considered) owns the rejected public architectures: explicit agent parameters, event-only filtering, per-agent service graphs, and hierarchical registration inheritance. This RFC records the implementation alternatives rejected after choosing the public contract. +The [July 8 RFC](2026-07-08-agent-scope-contexts.md#alternatives-considered) owns alternatives to the public flat-scope contract. The alternatives here concern implementation shape. -### Publish the agent before running setup +### Use a transparent proxy as the scope carrier -Early publication lets setup find the agent in global registries but lets observers act on a partially configured world. Rollback can remove entries but cannot retract external effects from listeners that already ran. +A proxy that impersonates the subject must preserve property, callable, constructable, private-field, descriptor, and proxy-invariant behavior that listener routing never needs. A small opaque carrier keeps the filter and key while the explicit event argument carries the subject. -The unpublished setup callback already receives `agent.ctx` and `ctx.agent`, so early global lookup is unnecessary. +### Reserve agent and session IDs before setup -### Allow only synchronous setup +Reservations prevent duplicate private setup work but require cross-service capabilities, release ordering, abandoned-reservation cleanup, and prepared-object binding. IDs are caller-supplied and concurrent reuse is caller error; final entry can choose the winner while the losing transaction rolls back cleanly. -Synchronous setup cannot honestly compose child plugins whose activation is asynchronous. TypeScript also permits a promise-returning callback where a void return is expected, so a synchronous-looking type would not reliably contain accidental async work. +### Snapshot every typed same-process argument -Awaited setup makes the transaction explicit and keeps first publication and prompt assembly behind it. +Universal copying defends against stateful getters and callers that violate readonly contracts, but it adds allocation, duplicated validators, and paths that can forget to copy. Materialization belongs at parser, queue, model, durable, worker, process, and wire boundaries where ownership actually changes. -### Validate caller data, then clone it +### Give readiness, cancellation, and disposal separate controllers -Validation followed by a separate clone rereads accessors, so it can approve one value and retain another. A generic JSON clone can also erase or coerce exotic prototypes and unsupported values. The lossless-JSON traversal validates and materializes one captured value in the same operation. +Parallel sentinels can all mirror whether one operation is live. One transaction or start promise owns the operation; separate promises remain only where publication unwind, external work, terminal result, and physical quiescence can settle independently. -### Enforce invariants with prepended waterfall listeners +### Keep synchronous subagent start plus `run.started` -A prepended listener is not permanently outermost: another plugin can prepend later, a short-circuit can skip inner work, and an outer wrapper can replace a downstream result. The same defect appears in prompt assembly, tool decisions, result commit, and turn continuation. +This splits provider acceptance from readiness and forces every consumer to register a partial run, attach result observation, await readiness, and clean up readiness failure. An async start promise makes provider-to-caller ownership transfer the readiness boundary itself. -The four owner-final APIs express the exact one-way power required: restore named canonical data, deny monotonically, observe immutable final outcome, or stop after ordinary continuation folding. +### Keep a separate prompt-protection registry -### Put agent-scope policy inside vendored Cordis +A protection registration mirrors the names and lifetime already owned by prompt sections and tool definitions. `ownerFinal` keeps the exceptional policy on the contribution and lets assembly derive the canonical set directly. -Cordis already supplies derived contexts, effect ownership, and receiver-based filtering. The harness-level primitive composes those domain-neutral mechanisms rather than teaching Cordis about agents, tools, prompts, or global-plus-agent merge rules. +### Remove worker/process lifecycle guards with same-process hardening -The lifecycle hardening remains correctly inside Cordis because effect pre-registration, parent ownership before child publication, and rejection of late effects protect every plugin under reentrant hot reload, not only agent scopes. +Worker messages, process death, and durable input do cross ownership and serialization boundaries. First-outcome arbitration, validation, environment scrubbing, and quiescent process cleanup remain necessary even though hostile same-process callback machinery does not. ## Consequences -The implementation makes the contributor contract locally checkable at each escape boundary. Its cost is explicit runtime machinery for key coherence, continuous ownership, accepted-value stability, and post-middleware finality. +The implementation is smaller and its proof follows the same shape as its ownership graph. One key selects a layer, one entry owns a live registry object, one transaction owns creation, one resolver owns a tool view, and one async promise transfers subagent ownership. -### Correctness properties +### What the design guarantees -The mechanisms compose into five properties: +- A scoped contribution is visible only in its exact agent view and is disposed with that scope. +- Create and resume expose no partially configured handle; final-entry losers and publication failures clean every prepared resource. +- Disposal retains scoped listeners and persistence through driver drain and final session work, then revokes the scope. +- Durable, queued, model, worker, process, and wire values are owned at their real boundary; typed same-process values follow readonly contracts. +- Tool presentation and execution resolve the same live view, and committed results have one immutable observation point. +- Owner-final prompt/tool contributions survive cooperative assembly without freezing unrelated middleware behavior. +- Subagent start returns only a ready run, required signals cancel pending or live work, and disposal reaches the backend's quiescence contract. +- Worker/process result precedence and cleanup remain correct under death, late messages, and bounded teardown. -- Registry layers and event carriers derive from one opaque key, while fused helpers couple subjects that must agree. -- Reservations, sentinels, provider tracking, publication barriers, and reverse teardown cover every asynchronous or reentrant ownership interval. -- At the hardened boundaries listed above, accepted identities and snapshots prevent runtime accessors or later mutation from splitting validation, execution, logging, and observation. -- Prompt protection, guards, final-result observation, and terminal stop each have only the one-way power their invariant requires. -- In-process subagents and workflow runs preserve readiness, terminal precedence, and disposal under provider callbacks, worker death, and racing owners. +### Costs and limits -### Costs and constraints +Scope-aware services still maintain global and identity-keyed maps, and operations must carry their real agent explicitly. Async create/resume and subagent start require callers to await ownership transfer and dispose returned handles. -The proof is not free: +The design trusts typed plugins in the same process. It does not defend against arbitrary casts, stateful getters, mutation that violates readonly contracts, or a plugin deliberately using ambient service access outside the supported composition API. -- Registries keep global and per-scope state, and every scoped dispatcher must preserve the operation subject through a proxy-shaped carrier. -- Programmatic create and resume require reservation capabilities, two-owner tracking, rollback state, ordered publication, and a shared quiescence promise. -- Acceptance boundaries copy, freeze, bind, or retain values according to their contract, increasing allocation and validation work. -- Owner-final behavior uses four explicit APIs instead of relying on ordinary listener ordering. -- Runtime invariants, generated dispatch checks, and focused Proxy/lifecycle/race tests remain necessary because TypeScript cannot enforce direct JavaScript dispatch or runtime reentrancy. - -The direct no-setup `ctx.agentLoop.create()` path remains synchronous for configuration and callers that already have complete options. Programmatic registry create/resume use the full unpublished transaction. - -### Limits of the proof - -The proof covers services and event families that explicitly adopt the agent-scope helpers. It does not make every service call scope-aware, strengthen the ordering contract of a custom agent registered outside AgentLoop, or force an arbitrary external subagent backend to reach quiescence after a workflow grace deadline. - -The [security and authority non-goal](2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals) is part of the public contract. These mechanisms prove composition and ownership behavior inside one trusted process; they do not prove confinement or parent-to-child non-escalation. +The [security and authority non-goal](2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals) remains fundamental. These mechanisms prove registration composition, publication, and lifetime ownership; they do not prove confinement or parent-to-child non-escalation. diff --git a/docs/rfc/implemented/feature/2026-06-15-code-mode.md b/docs/rfc/implemented/feature/2026-06-15-code-mode.md index be18e94ae0..16f42104be 100644 --- a/docs/rfc/implemented/feature/2026-06-15-code-mode.md +++ b/docs/rfc/implemented/feature/2026-06-15-code-mode.md @@ -24,11 +24,11 @@ Three decisions, each elaborated in its own section below: `ToolRegistry` gains a schemastery-validated config (`static Config`), its first: `mode: 'native' | 'code' | 'both'`, default `'native'`. A deployment flips it from `cordis.yml` (`tools: { mode: code }`) — no code edit, per the no-hardcoded-tunables convention. -**Wire tool list = the registry's contribution.** The registry feeds assembly through a mode-aware provider: `'native'` contributes every capability visible to that assembly scope, `'code'` contributes only `run_code`, and `'both'` contributes both. Because [`PromptAssembly.tools` is the single source the loop's request header snapshots](../../../../packages/core/system-prompt/src/index.ts), the presentation is logged and reconstructable. The reserved transport is not a capability: it lives outside global/scoped registration and restriction layers, cannot be registered or shadowed, and cannot be named by `ctx.tools.restrict()`. `systemPrompt.protect()` restores its canonical schema after the complete assembly waterfall, so listeners cannot strip, replace, duplicate, or fabricate it. The mode governs only the registry's contribution; a deployment that deliberately installs another direct `systemPrompt.tools()` provider still owns that provider's schemas. +**Wire tool list = the registry's contribution.** The registry feeds assembly through a mode-aware provider: `'native'` contributes every capability visible to that assembly scope, `'code'` contributes only `run_code`, and `'both'` contributes both. Because [`PromptAssembly.tools` is the single source the loop's request header snapshots](../../../../packages/core/system-prompt/src/index.ts), the presentation is logged and reconstructable. The reserved transport is not a capability: it lives outside global/scoped registration and restriction layers, cannot be registered or shadowed, and cannot be named by `ctx.tools.restrict()`. Its `ToolDefinition` declares `ownerFinal: true`, so the provider reports the name as final and assembly restores its canonical schema or canonical absence after the waterfall. The mode governs only the registry's contribution; a deployment that deliberately installs another direct `systemPrompt.tools()` provider still owns that provider's schemas. **Interaction with `toolOrder`, stated up front:** a configured `systemPrompt.toolOrder` naming native capabilities rejects every assembly under `mode: 'code'`, because those names are outside that mode's wire-validation universe. This is correct behavior, not a bug: a deployment using Code Mode updates its order config or drops it. -**The SDK prompt section.** Under `'code'` and `'both'` the registry registers one lazy prompt section (`tools:sdk`, in the 100–199 tool-guidance order band) whose thunk regenerates, for each assembly scope, a TypeScript declaration of every visible end-capability tool plus fixed usage instructions. It uses the same visibility resolver as lookup and execution, so scoped grants and shadows appear while restricted globals disappear; the reserved `run_code` transport itself is excluded. The thunk emits tools in lexicographic name order, so an unchanged visible set produces byte-identical text, and `systemPrompt.protect()` restores the canonical section after every assembly listener. Because that protection is global, it also reserves the `tools:sdk` registry name against scoped section shadows; otherwise scoped-over-global resolution could make a later shadow look canonical before restoration. +**The SDK prompt section.** Under `'code'` and `'both'` the registry registers one lazy prompt section (`tools:sdk`, in the 100–199 tool-guidance order band) whose thunk regenerates, for each assembly scope, a TypeScript declaration of every visible end-capability tool plus fixed usage instructions. It uses the same visibility resolver as lookup and execution, so scoped grants and shadows appear while restricted globals disappear; the reserved `run_code` transport itself is excluded. The thunk emits tools in lexicographic name order, so an unchanged visible set produces byte-identical text. The section declares `ownerFinal: true`, which restores its canonical contribution after every assembly listener and reserves the global name against scoped shadows. **Codegen.** A pure `jsonSchemaToTs(schema)` module inside `dsh-tools` (sibling of `json-schema.ts` — `schemas()` and the SDK are two projections of the same store) maps the JSON-Schema subset the `defineTool` DSL emits (object/string/number/boolean/array, `properties`, `required`, string `enum` → literal union, nested objects, array `items`, `description` → JSDoc) to a TS type literal. It is **total**: any construct outside that subset (`$ref`, `oneOf`/`anyOf`, `integer`, future MCP shapes, …) degrades to `unknown` without throwing. Because `ToolSchema.name` is an arbitrary string, the SDK is declared as one object constant — `declare const tools: { "some-mcp-tool"(args: …): Promise; bash(args: …): Promise; … }` — quoted keys make every name reachable with no sanitization or alias-collision logic. Typing is advisory (the runtime executes type-stripped JS); the instructions say so. diff --git a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md index f26aa501ef..c24c6102d9 100644 --- a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -35,9 +35,9 @@ A new package group `packages/subagent/`: | `@deepseek-ai/dsh-subagent-mock` | support: a scripted provider for testing the seam through the real load path | | `@deepseek-ai/dsh-tool-subagent` | consumer: the model-facing `subagent` tool over `ctx.subagents` | -### The primitive: `start → SubagentRun` +### The primitive: async `start → SubagentRun` -A provider exposes `start(request) → SubagentRun`. The run carries `started` (the provider's publication/readiness promise), `result` (the terminal `SubagentResult`), `cancel()`, and `dispose()`. The transport-neutral verb is **`start`**; "spawn" is reserved for the in-process `dsh-subagent-spawn` backend's identity, not the service verb. The service's `start(name, request)` resolves the named provider, validates capabilities, delegates, and waits for `started` before emitting the paired `subagent/start` / `subagent/end`; an attempt that never establishes a child emits neither lifecycle event. For an in-process backend, readiness means the child is published in `ctx.agents`; for ACP it means the remote session exists. +A provider exposes `start(request) → Promise`. Promise fulfillment is the publication/readiness and provider-to-caller ownership boundary: for an in-process backend the child is already published in `ctx.agents`, and for ACP the remote session already exists. `SubagentStartRequest.signal` is the single cancellation channel before and after readiness; `SubagentRun` carries the terminal `result` and a `dispose()` method that cancels remaining work and awaits quiescence. The transport-neutral verb is **`start`**; "spawn" is reserved for the in-process `dsh-subagent-spawn` backend's identity, not the service verb. A rejected start cleans provider-owned partial resources and emits neither subagent lifecycle event. ### Two kinds of optional capability, discovered two ways @@ -54,7 +54,7 @@ Each subagent runs in its **own `Session`** (own id, `parentSession` lineage), p ### Synchronous collect (first cut) -The `dsh-tool-subagent` consumer awaits `run.result` and returns the child's final output as the tool result, blocking the parent's turn until the child finishes. It does so inside a `try/finally` that always `dispose()`s the run (no leaked idle child/session on any path), bridges `exec.signal` to `run.cancel()`, and maps a non-`completed` stop reason to an `isError` result rather than returning partial output as success. Steering (`sendMessage`) is part of the contract but **intentionally unused** this cut. +The `dsh-tool-subagent` consumer passes its execution signal into the start request, awaits the ready run's `result`, and returns the child's final output as the tool result, blocking the parent's turn until the child finishes. A `try/finally` always `dispose()`s the run, so no success, failure, or cancellation path leaks an idle child/session. A non-`completed` stop reason maps to an `isError` result rather than returning partial output as success. Steering (`sendMessage`) is part of the contract but intentionally unused in this consumer. ### Provider selection is config, not model-facing @@ -66,7 +66,7 @@ The seam is tested through the real cordis Loader / export path, not a hand-buil ## Consequences -- **Recursion.** Without a guard, an in-process child inherits the spawn tool and can spawn unboundedly. Depth-limit is an optional capability (the in-process backends enforce it; ACP advertises it off and rejects a `maxDepth` request); tool-filtering is likewise optional. Tool-filtering, when implemented, needs a `tools/pre-execute` deny in the child context — schema filtering alone is insufficient because a model can hallucinate a denied tool name. +- **Recursion.** Without a bound, an in-process child can see the delegation tool and recurse. The in-process backends implement the optional absolute depth limit and scoped live-global `toolFilter`; ACP advertises both capabilities off and rejects such a request. The [subagent composition-controls RFC](2026-07-12-subagent-persona-tool-filter-and-depth.md) owns their exact semantics and security limits. - **Blocking the parent turn.** Synchronous collect holds the parent's `runStep` open for the child's full duration. This is acceptable for the first cut; **background / poll / spill semantics are deferred to a future redesign that unifies long-running-tool handling across subagents AND bash** (a sub-agent and a long `bash` background task pose the same "the model started something slow, how does it collect later" problem, and should share one mechanism rather than each inventing its own). - **Live progress.** This cut surfaces only lifecycle + final result; a per-chunk child→parent update stream is deferred with the background redesign. - **ACP client surface.** Proxying `fs`/`terminal` from the ACP child back to the parent (a shared-workspace mode) is future work; the first cut advertises neither, so the child self-serves in its own process. diff --git a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md index ec608177b4..518f9dedef 100644 --- a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md +++ b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md @@ -34,7 +34,7 @@ The child is a separate process, so it inherits an environment. Credential-shape Designed at every tier the backend touches, per the root AGENTS.md rule that a new capability shape names its coverage at every tier at plan time: -- **Keyless unit/integration** (`subagent-acp.spec.ts`): spawns a scripted mock ACP server subprocess (`tests/mock-acp-server.ts`) and drives it through the real backend over real ACP stdio. Covers: the prompt round-trip + output accumulation; every StopReason mapping; cancellation via `run.cancel()` and via the request signal; the already-aborted-before-start case; the cancel-races-ahead-of-newSession case; a torn-pipe-after-cancel (child crashes on cancel) settling `aborted`; permission auto-answer under both policies (including the allow-policy-no-allow-option fallback); a non-message update consumed but not accumulated; a nonexistent-command spawn failure settling `error`; HMR provider cleanup; and the namespace export shape. 100% per-file coverage. +- **Keyless unit/integration** (`subagent-acp.spec.ts`): spawns a scripted mock ACP server subprocess (`tests/mock-acp-server.ts`) and drives it through the real backend over real ACP stdio. Coverage includes the prompt round-trip and output accumulation; every StopReason mapping; cancellation through the required request signal and through disposal; already-aborted and cancel-races-ahead-of-newSession starts; a torn pipe after cancellation settling `aborted`; permission auto-answer under both policies; non-message updates; nonexistent-command startup failure with process reaping; provider HMR; and the namespace export shape. - **With-key e2e** (`subagent-acp.e2e.ts`): the harness drives ITSELF — the backend spawns the real `acp-agent` example process and a real model in that child answers a prompt (PONG) and does real file work (writes `proof.txt`, verified on disk). Self-skips without `DEEPSEEK_API_KEY`. This is the "talk to our own process" smoke and the out-of-process analogue of the in-process spawn e2e. - **Snapshot**: deferred as `TODO(acp-subagent-replay)`. An ACP child is a distinct replay shape — each child is its own PROCESS with its own single-agent replay (booted under `DSH_SNAPSHOT=replay` with its own sessions-root + fixture), unlike the in-process per-session keying that [the per-session replay RFC](../testing/2026-06-22-subagent-snapshot-replay.md) added. The keyless mock-server tests give deterministic coverage of the backend in the meantime; the snapshot follow-up would record the parent driving a real-but-replayed ACP child. diff --git a/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md index 17730458de..aa853edd24 100644 --- a/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md +++ b/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md @@ -6,13 +6,13 @@ Status: implemented The hooks subsystem ([interception seams RFC](2026-06-30-interception-seams.md)) lets a plugin observe and gate the agent at lifecycle points. Claude Code and Codex both expose **SubagentStart / SubagentStop** hooks, and CC's carry the subagent's final message. The harness already emits `subagent/start` and `subagent/end` lifecycle events ([the subagent capability-seam](2026-06-21-subagent-capability-seam.md)), but their payloads were minimal (`provider`, `id`, and on end `stopReason`) — not enough for a hooks bridge to report WHAT a subagent produced without separately reaching for the live run. -This RFC enriches the end payload. It is deliberately **observe-only**: no control-flow change, no waterfall, no `start()` restructure. A run-affecting subagent-stop decision (continuation, injection that changes the run) is a separate, larger redesign and stays out of scope. +This RFC enriches the end payload. It is deliberately **observe-only**: no control-flow change and no waterfall. A run-affecting subagent-stop decision (continuation, injection that changes the run) is a separate, larger redesign and stays out of scope. ## Decision -**Add `lastAssistantMessage` — the child's final output — to `SubagentRunEndInfo`.** On the settle path it is a DEEP CLONE of `SubagentResult.output` (so an observer sees WHAT the subagent produced without holding the run). On the REJECT path (an infrastructure fault where no `SubagentResult` was produced — the seam only knows `stopReason: 'error'`) it is absent. The clone is load-bearing for observe-only: the `subagent/end` emit fires from a detached `.then` registered *before* `start()` returns, i.e. before the caller's own `await run.result` continuation — handing listeners the same array reference would let a mutating listener corrupt the caller's `SubagentResult.output`. `structuredClone` makes the event a read-only view (a regression test mutates the event's array and asserts the caller's result is untouched); a clone failure is contained (logged, the event still fires without `lastAssistantMessage`) rather than becoming an unhandled rejection on the detached `.then`. +**Add `lastAssistantMessage` — the child's final output — to `SubagentRunEndInfo`.** On the settle path it is the readonly typed `SubagentResult.output`, so an observer sees what the child produced without holding the run. On an infrastructure rejection where no `SubagentResult` exists, it is absent and the event reports `stopReason: 'error'`. Providers and listeners are trusted same-process collaborators and honor the borrowed immutable payload contract. -Both events stay plain **`emit`s**. The service waits for `run.started` before firing `subagent/start`; an in-process listener can therefore reach the published child via `ctx.agents.get(info.id)` and `inject()` into it, while a remote provider need not have a local registry entry. It observes `run.result` immediately, snapshots the end payload before the caller can mutate it, and emits `subagent/end` only after start; readiness rejection emits neither event. The callbacks remain observe-only and per-listener containment keeps one bad subscriber from stranding a live run, surfacing as an unhandled rejection, or starving later listeners. +Both events stay plain **`emit`s**. Async `SubagentService.start()` attaches result observation to the ready provider run, emits `subagent/start`, and then returns the run; an in-process listener can therefore reach the published child via `ctx.agents.get(info.id)`, while a remote provider need not have a local registry entry. A rejected provider start emits neither event. The callbacks remain observe-only and per-listener containment keeps one bad subscriber from stranding a live run or starving later listeners. ## Alternatives considered diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md index 47d9ec0473..ed718686ef 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -26,7 +26,7 @@ One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferre **Why node:worker_threads**: one run uses one unpooled worker because a workflow run is already heavyweight relative to thread startup. The script runs in a vm context inside the worker, keeping the script-visible surface to the hook contract instead of exposing a bare worker realm, while `agent()` bridges by message-port RPC to I/O-bound child loops on the host. This keeps `start()` from blocking the host on the script's synchronous slice, makes the post-cancel deadline end in a real `worker.terminate()`, and gives cross-thread values a serialization boundary by construction. isolated-vm was rejected for its maintenance state, required `--no-node-snapshot` consumer flag on Node ≥ 20, and node-gyp fallback. -Host-side meta validation and body pre-parsing preserve the seam's synchronous errors, and private enum-keyed payload maps define the wire protocol. Readiness admission, the two child-cancellation channels, worker-death reaping, result precedence, and disposal quiescence preserve the subagent run contract across that wire; the [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#workflow-integration-preserves-the-subagent-contract) owns those race algorithms. Coverage uses an in-process `MessageChannel` for worker-side logic that main-process V8 coverage cannot see and separately proves the built `lib/worker.js`—a second tsdown entry sanctioned by the `"./worker"` subpath export—under plain Node in the built-bin smoke gate. +Host-side meta validation and body pre-parsing preserve the seam's synchronous errors, and private enum-keyed payload maps define the wire protocol. Pending async starts, published child records, one host cancellation signal, worker-death reaping, result precedence, and disposal quiescence preserve the subagent run contract across that wire; the [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#workflow-children-are-pending-starts-or-published-records) owns those race algorithms. Coverage uses an in-process `MessageChannel` for worker-side logic that main-process V8 coverage cannot see and separately proves the built `lib/worker.js`—a second tsdown entry sanctioned by the `"./worker"` subpath export—under plain Node in the built-bin smoke gate. **Meta as data, never evaluated**: the meta block reaches the seam as a plain JSON request field (the tool's schema-validated `meta` parameter) and the engine only shape-validates it, every violation named. This is a host-isolation invariant, not a convenience: evaluating a meta literal host-side — even one contractually "pure", in an empty timed vm context — hands script-controlled getters a host stack with no timeout the moment the result is READ, defeating the exact spin isolation the worker thread buys. @@ -42,7 +42,7 @@ A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, awai An output schema makes a schema-valid committed capture mandatory for successful child completion. The scoped runtime preserves the canonical capture tool and instruction, commits only a successful final outcome—including the enclosing `run_code` outcome for an SDK call—denies later side effects after capture becomes pending, and stops the child without another model step after commit. A validation failure remains a retryable tool error; clean completion without a committed capture settles as an error. -`StructuredOutputSchema` is the raw enforceable JSON-Schema subset in `dsh-tools` (single-string `type`, `properties`/`required`/`additionalProperties`, `items`, scalar `enum`/`const`), and unsupported keywords fail loudly because that wire data becomes the capture tool's parameters verbatim. The [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#structured-output-is-a-child-owned-terminal-protocol) owns the assembly, commit, guard, and terminal-stop correctness algorithms. +`StructuredOutputSchema` is the raw enforceable JSON-Schema subset in `dsh-tools` (single-string `type`, `properties`/`required`/`additionalProperties`, `items`, scalar `enum`/`const`), and unsupported keywords fail loudly because that wire data becomes the capture tool's parameters verbatim. The [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#structured-output-commits-only-authoritative-outcomes) owns the assembly, commit, guard, and terminal-stop correctness algorithms. ## Deferred (documented non-goals of this cut) diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md index 0579bf2629..5d82226a96 100644 --- a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md @@ -49,11 +49,11 @@ The `escalation-rejected` twin ends in `{"outcome": "rejected"}` instead: nothin #### The seam: mechanism and policy split -After request validation and a successful `approval/asked` append, the answerer phase always resolves to a closed `ApprovalOutcome` — `allowed-once` / `rejected` / `cancelled` / `unavailable`. The service synchronously snapshots and shallow-freezes the accepted request before its first asynchronous boundary: scalar fields are copied while the agent and `AbortSignal` remain exact identity capabilities, so later caller mutation cannot redirect scope, payload, cancellation, or either audit event. The service dispatches the `approval/request` waterfall, races the captured signal (abort settles `cancelled`; a late answer is discarded, never double-audited), contains a throwing answerer as `unavailable`, normalizes a rogue non-vocabulary return to `unavailable`, and lands the log-only audit pair `approval/asked`/`approval/decided` (paired by the branded `ApprovalRequestId`) on the captured agent's captured session log. Request acceptance and either pre-commit audit append may still reject; returning a decision that could not be logged would violate the pair. Session owns post-commit observer containment, so a callback failure cannot turn an authoritative audit append into a rejected request or suppress the matching event. Grants are one-shot by definition: `allowed-once` authorizes the single asked-about action, never a class of future ones, and the service stores nothing between requests. `request()` also throws before appending anything when the agent's session has no open turn — the audit pair must be turn-enclosed, the turn being the durable log's commit/replay boundary (a bare event between turns is dropped as crash tail on reload); every ask path runs mid-turn already, and idle asks are a deferred design. +After request validation and a successful `approval/asked` append, the answerer phase always resolves to a closed `ApprovalOutcome` — `allowed-once` / `rejected` / `cancelled` / `unavailable`. `ApprovalRequest` is a readonly same-process contract, so the service borrows its routing identity and cancellation signal instead of copying the record or capturing a parallel callback bundle. It dispatches the `approval/request` waterfall, races the request signal (abort settles `cancelled`; a late answer is discarded, never double-audited), contains a throwing answerer as `unavailable`, normalizes a rogue non-vocabulary return to `unavailable`, and lands the log-only audit pair `approval/asked`/`approval/decided` (paired by the branded `ApprovalRequestId`) on the request agent's session log. Request acceptance and either pre-commit audit append may still reject; returning a decision that could not be logged would violate the pair. Session owns post-commit observer containment, so a callback failure cannot turn an authoritative audit append into a rejected request or suppress the matching event. Grants are one-shot by definition: `allowed-once` authorizes the single asked-about action, never a class of future ones, and the service stores nothing between requests. `request()` also throws before appending anything when the agent's session has no open turn — the audit pair must be turn-enclosed, the turn being the durable log's commit/replay boundary (a bare event between turns is dropped as crash tail on reload); every ask path runs mid-turn already, and idle asks are a deferred design. Answerers are the policy, and they are `approval/request` waterfall listeners. The waterfall buys exactly what the seam needs: with zero listeners the dispatch falls through to the caller-supplied default — `unavailable`, so fail-closed needs no configuration and no code in any deployment; a listener that recognizes the request's agent answers by returning an outcome without calling `next()` (the decision slot is single-occupancy, first answer wins — the same documented semantics as the `fs/write-intent` gate); a listener that does not recognize the agent MUST delegate via `next()` so another answerer or the default gets the question; and listeners dispose with their owning fiber, so an unloaded UI plugin degrades the next ask to `unavailable` instead of leaving a dangling channel. Registration order across sibling plugins is not load-order deterministic (the loader starts siblings concurrently), so a deployment composes ONE terminal answerer and reserves `prepend` listeners for decide-or-delegate gates. -`ApprovalRequest` carries the asking `agent` (routes the question; receives the audit events), the `toolName`, the optional exact `callId`, the asker's human-readable `reason`, and the optional `signal`. The caller owns this input record; `request()` owns its frozen acceptance snapshot. The vocabulary is deliberately self-contained — it names the tool-call by the `CallId` brand from `dsh-llm` and never imports `dsh-tools` — because `dsh-tools` depends on `dsh-user-approval` (the ask routing) and a `ToolCallView` import would close a package cycle. It deliberately does NOT carry tool arguments: a UI answerer attaches the prompt to the already-streamed tool call via `callId` instead of re-rendering the call. +`ApprovalRequest` carries the asking `agent` (routes the question; receives the audit events), the `toolName`, the optional exact `callId`, the asker's human-readable `reason`, and the optional `signal`. The caller retains ownership and honors the readonly contract for the duration of `request()`. The vocabulary is deliberately self-contained — it names the tool-call by the `CallId` brand from `dsh-llm` and never imports `dsh-tools` — because `dsh-tools` depends on `dsh-user-approval` (the ask routing) and a `ToolCallView` import would close a package cycle. It deliberately does NOT carry tool arguments: a UI answerer attaches the prompt to the already-streamed tool call via `callId` instead of re-rendering the call. #### Ask routing in dsh-tools @@ -79,7 +79,7 @@ One package, no cycles: `dsh-user-approval` peers on `cordis`, `dsh-session` (ev ### Testing -Unit tier: the service's outcome branches (fail-closed default, first-wins slot, delegation, containment, rogue-value normalization, abort-before and abort-during with late-answer discard, fresh ids, fiber-disposal degradation), accepted-request mutation across agent scopes, post-append observer throws on both audit events, and the policy tier (both values × dispatch/decide, a `'never'` decision unbypassable even by an answerer prepended AFTER the service, audit pair intact) in `dsh-user-approval`; the ask routing matrix (grant dispatches; three non-grant reasons pinned verbatim; unmounted and agent-less degrades; the registry's own exhaustiveness backstop against a non-conforming stand-in) in `dsh-tools`; the answerer (wire shape of the prompt, outcome mapping, unknown-option conservatism, foreign-agent and call-less delegation) driven through a real bridge + scripted client in `dsh-acp`. +Unit tier: the service's outcome branches (fail-closed default, first-wins slot, delegation, containment, rogue-value normalization, abort-before and abort-during with late-answer discard, fresh ids, fiber-disposal degradation), scoped routing, post-append observer throws on both audit events, and the policy tier (both values × dispatch/decide, a `'never'` decision unbypassable even by an answerer prepended AFTER the service, audit pair intact) in `dsh-user-approval`; the ask routing matrix (grant dispatches; three non-grant reasons pinned verbatim; unmounted and agent-less degrades; the registry's own exhaustiveness backstop against a non-conforming stand-in) in `dsh-tools`; the answerer (wire shape of the prompt, outcome mapping, unknown-option conservatism, foreign-agent and call-less delegation) driven through a real bridge + scripted client in `dsh-acp`. Snapshot tier: the harness accepts scripted permission answers (`permissionAnswers` in a scenario's `input.json`, consumed FIFO; an unscripted prompt answers `cancelled`, fail closed). The seam's wire is recorded end to end in the sandbox example's suite: both escalation branches drive `session/request_permission` through this seam over scripted answers (grant and rejection), and the recorded `mode-switching` scenario pins the `'never'` prompt sentence and the policy-switch notice ([the sandbox RFC](2026-07-06-sandbox.md) § Testing). @@ -105,7 +105,7 @@ The implemented contract is pinned by the suites in Testing: - With an ApprovalService and an answerer composed, a hook's `ask` reaches a human and `allowed-once` dispatches the tool; every other outcome denies with its distinct reason. - A `'never'` session auto-rejects every ask without prompting anyone, states the policy in its prompt, and narrates switches (the shared switching mechanics are pinned in [the sandbox RFC](2026-07-06-sandbox.md)). - Every unanswerable path fails closed to `unavailable`: no service, no listener, a foreign or agent-less request, a throwing answerer, a rogue return value, or a dead client connection. -- Every `request()` snapshots its routing identity and lands exactly one `approval/asked`/`approval/decided` pair on that agent's captured log, replayable and invisible to the model transcript; post-append observer failures cannot split the pair. +- Every `request()` routes through its readonly agent identity and lands exactly one `approval/asked`/`approval/decided` pair on that agent's log, replayable and invisible to the model transcript; post-append observer failures cannot split the pair. - Prompts route per-session through the bridge's ownership map; one session's prompt can never reach another session's editor. - A deployment with no ApprovalService emits no approval prompt or approval audit events and denies every `ask` request. diff --git a/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md b/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md index 9d657f7bd4..5b6eeb4ccb 100644 --- a/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md +++ b/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md @@ -41,7 +41,7 @@ Resolution follows these rules: 3. Child-scoped tools are added after global filtering and may shadow an admitted global tool. 4. Reserved `run_code` presentation and other scope-local protocol contributions are outside the global filter. -Configuration fails loudly when a filter is empty or names a tool that is unknown, scope-local, or reserved at setup time. This catches misspellings and prevents configuration from appearing effective when it cannot affect the named entry. +Configuration fails loudly when a filter supplies neither `allow` nor `deny`, or names something outside the current global restrictable set, including a scope-local-only or reserved name. `allow: []` is valid and deliberately hides every global tool. These checks catch misspellings and prevent configuration from appearing effective when it cannot affect the named entry. The global registry remains live. A deny-only filter admits a later global name unless it explicitly denies that name; an allow-list excludes a later global name unless it explicitly allows that name. Removing a global tool removes it from every resolved view. These semantics preserve hot registration while making the difference between allow and deny explicit. diff --git a/packages/AGENTS.md b/packages/AGENTS.md index ac26b926f7..4b7bed4d1c 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -5,6 +5,8 @@ This directory contains all `@deepseek-ai/dsh-*` harness packages. Repo-wide con - **Plugin export shape — namespace OR default, never both.** A *service* package exports the service class as `export default` (the Loader instantiates it). A *function/namespace* plugin exports `name` / `inject` / `Config` / `apply` as separate named exports and **must NOT add `export default`** — the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default export collapses the module to the bare `apply` function and silently discards the `inject`/`name`/`Config` namespace, leaving the plugin with no injected services (it then throws `cannot get property … without inject` at load). See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md). - **Read an optional (non-injected) service via `ctx.get(name)`, not `ctx.`.** For a service a plugin reads opportunistically but deliberately leaves out of `static inject` (e.g. `AgentLoop` reading `sessionPersistence`), the `ctx.` property proxy resolves by an ancestor-only fiber walk that throws when the call arrives through a foreign traceable shadow (the service lives on a sibling fiber). `ctx.get(name)` is the topology-independent global-store lookup, strict by default (an inactive/absent backend reads as `undefined` — prefer it over the `ctx.get(name, false)` overload, which also skips the active-state check). Services that ARE in `static inject` resolve fine via `ctx.`. See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md). - **A plugin shipped via `cordis.yml` needs at least one test through the REAL Loader/export path** — hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape. Full testing policy (tiers, with-key generosity, real-entry-path guards): [docs/testing.md](../docs/testing.md). +- **Typed same-process service and plugin calls are contracts, not serialization boundaries.** Prefer readonly borrowed values; materialize or defensively validate only at parser/config, queued, model/tool JSON, durable/file, worker, process, or wire boundaries. +- **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement boundary; otherwise fold it while preserving rollback, callback containment, and quiescence. Naming notes: diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 88b89f5d3b..3ee6d1081f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -54,7 +54,7 @@ export interface TypeApiEntry { export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'agentLoop', - summary: 'The agent-loop plugin (`ctx.agentLoop`): creates ReactLoopAgents, runs their loops, and registers them in `ctx.agents`.', + summary: 'Concrete ReactLoopAgent factory and driver service.', methods: [ 'create(id: AgentId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent', 'async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise', @@ -65,12 +65,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'agents', summary: 'Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package.', methods: [ - 'reserve(id: AgentId): AgentRegistrationReservation', 'setFactory(factory: AgentFactory): () => Promise | void', 'async create(options: CreateAgentOptions): Promise', 'async resume(options: ResumeAgentOptions): Promise', 'register(agent: Agent): () => Promise | void', - 'enter(agent: Agent, reservation?: AgentRegistrationReservation): () => void', + 'enter(agent: Agent): () => void', 'announce(agent: Agent): void', 'get(id: AgentId): Agent | undefined', 'list(): Agent[]', @@ -156,10 +155,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'sessions', summary: 'In-memory session store (`ctx.sessions`).', methods: [ - 'reserve(id: SessionId): SessionRegistrationReservation', 'create(id?: SessionId, options?: CreateSessionOptions): Session', 'prepare(id?: SessionId, options?: CreateSessionOptions): Session', - 'enter(session: Session, reservation?: SessionRegistrationReservation): () => void', + 'enter(session: Session): () => void', 'announce(session: Session): void', 'async flush(session: Session): Promise', 'get(id: SessionId): Session | undefined', @@ -179,22 +177,21 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'subagents', - summary: 'The `subagents` service: a registry of named SubagentProviders and a capability-checked start surface.', + summary: 'Named provider registry and capability-checked start surface.', methods: [ 'registerProvider(provider: SubagentProvider): () => Promise | void', 'getProvider(name: string): SubagentProvider | undefined', 'list(): string[]', - 'start(name: string, request: SubagentStartRequest): SubagentRun', + 'async start(name: string, request: SubagentStartRequest): Promise', ], }, { key: 'systemPrompt', - summary: 'Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, named prompt variables, and owner-final contribution protections; the agent loop calls `assemble(context)` once per step.', + summary: 'Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, named prompt variables, and owner-final contributions; the agent loop calls `assemble(context)` once per step.', methods: [ 'section(section: PromptSection): () => Promise | void', 'tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise | void', 'variable(name: string, provider: (context: AssembleContext) => string | undefined): () => Promise | void', - 'protect(protection: PromptProtection): () => Promise | void', 'async assemble(context: AssembleContext = {}): Promise', ], }, @@ -205,10 +202,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'register(definition: ToolDefinition): () => Promise | void', 'restrict(filter: ToolRestriction): () => Promise | void', 'guard(guard: ToolGuard): () => Promise | void', - 'visible(scope?: ScopeKey): ToolDefinition[]', 'get(name: string, scope?: ScopeKey): ToolDefinition | undefined', 'schemas(scope?: ScopeKey): ToolSchema[]', - 'knownNames(scope?: ScopeKey): string[]', 'async execute(exec: ToolExecutionInput): Promise', ], }, @@ -389,25 +384,25 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'subagent/end', mode: 'emit', signature: '\'subagent/end\'(this: Scoped, info: SubagentRunEndInfo): void', - summary: 'A started subagent run settled — emitted when SubagentRun.result resolves (any stop reason) or rejects (reported as `error`).', + summary: 'A ready child settled.', }, { name: 'subagent/provider-added', mode: 'emit', signature: '\'subagent/provider-added\'(provider: SubagentProvider): void', - summary: 'A provider became resolvable in the SubagentService registry.', + summary: 'A provider became resolvable in the registry.', }, { name: 'subagent/provider-removed', mode: 'emit', signature: '\'subagent/provider-removed\'(name: string): void', - summary: 'A provider left the registry (its plugin\'s fiber was disposed — an unload or an HMR reload).', + summary: 'A provider left the registry.', }, { name: 'subagent/start', mode: 'emit', signature: '\'subagent/start\'(this: Scoped, info: SubagentRunInfo): void', - summary: 'A subagent run started — emitted only after SubagentRun.started fulfills, when the provider has established a live child.', + summary: 'A provider established a ready child.', }, { name: 'system-prompt/assemble', @@ -419,7 +414,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'system-prompt/change', mode: 'emit', signature: '\'system-prompt/change\'(): void', - summary: 'A section, tool provider, variable provider, or protection was registered or unregistered (the assembly inputs changed — possibly for one scope only).', + summary: 'A section, tool provider, or variable provider was registered or unregistered (the assembly inputs changed — possibly for one scope only).', }, { name: 'tools/change', @@ -436,7 +431,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'tools/post-execute', mode: 'waterfall', - signature: '\'tools/post-execute\'(this: Scoped, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise): Promise', + signature: '\'tools/post-execute\'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise', summary: 'Waterfall AFTER a tool runs — where hook plugins inspect the result and accept it (optionally REPLACING the model-facing content, and/or attaching `additionalContext` for the next request) or block it with corrective `feedback` (Claude Code\'s `PostToolUse`).', }, { @@ -511,10 +506,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentOptions', declaration: 'export interface AgentOptions {\n model?: string;\n}', }, - { - name: 'AgentRegistrationReservation', - declaration: 'export interface AgentRegistrationReservation {\n readonly id: AgentId;\n release(): void;\n}', - }, { name: 'AgentStatus', declaration: 'export type AgentStatus = \'idle\' | \'running\' | \'disposed\';', @@ -525,7 +516,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ApprovalRequest', - declaration: 'export interface ApprovalRequest {\n agent: Agent;\n toolName: string;\n callId?: CallId;\n reason?: string;\n signal?: AbortSignal;\n}', + declaration: 'export interface ApprovalRequest {\n readonly agent: Agent;\n readonly toolName: string;\n readonly callId?: CallId;\n readonly reason?: string;\n readonly signal?: AbortSignal;\n}', }, { name: 'AskUserQuestionAnswer', @@ -653,11 +644,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateAgentOptions', - declaration: 'export interface CreateAgentOptions {\n agentId: AgentId;\n sessionId: SessionId;\n meta?: {\n cwd?: string;\n parentSession?: SessionId;\n seedLength?: number;\n };\n seed?: SessionEvent[];\n agentOptions?: AgentOptions;\n setup?: (agentCtx: Context) => Promise | void;\n}', + declaration: 'export interface CreateAgentOptions {\n readonly agentId: AgentId;\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', }, { name: 'CreateSessionOptions', - declaration: 'export interface CreateSessionOptions {\n seed?: SessionEvent[];\n meta?: {\n cwd?: string;\n parentSession?: SessionId;\n createdAt?: number;\n seedLength?: number;\n };\n}', + declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n };\n}', }, { name: 'DiffCallView', @@ -755,13 +746,9 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PromptAssembly', declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n tools: ToolSchema[];\n variables: Record;\n}', }, - { - name: 'PromptProtection', - declaration: 'export interface PromptProtection {\n sections?: readonly string[];\n tools?: readonly string[];\n}', - }, { name: 'PromptSection', - declaration: 'export interface PromptSection {\n name: string;\n order: number;\n text: string | ((context: AssembleContext) => string);\n}', + declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n readonly ownerFinal?: boolean;\n}', }, { name: 'ReasoningBlock', @@ -769,7 +756,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ResumeAgentOptions', - declaration: 'export interface ResumeAgentOptions {\n agentId: AgentId;\n resumeSessionId: SessionId;\n agentOptions?: AgentOptions;\n setup?: (agentCtx: Context) => Promise | void;\n}', + declaration: 'export interface ResumeAgentOptions {\n readonly agentId: AgentId;\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', }, { name: 'SandboxEnforcement', @@ -809,23 +796,19 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionHeader', - declaration: 'export interface SessionHeader {\n version: number;\n id: SessionId;\n createdAt: number;\n cwd?: string;\n parentSession?: SessionId;\n seedLength?: number;\n}', + declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n}', }, { name: 'SessionId', declaration: 'export type SessionId = Branded<\'SessionId\'>;', }, - { - name: 'SessionRegistrationReservation', - declaration: 'export interface SessionRegistrationReservation {\n readonly id: SessionId;\n prepare(options?: CreateSessionOptions): Session;\n release(): void;\n}', - }, { name: 'SkillCandidate', - declaration: 'export interface SkillCandidate extends SkillSummary {\n rank: number;\n locator: unknown;\n path?: string;\n metadata?: Record;\n}', + declaration: 'export interface SkillCandidate extends SkillSummary {\n readonly rank: number;\n readonly locator: unknown;\n readonly path?: string;\n readonly metadata?: Readonly>;\n}', }, { name: 'SkillDefinition', - declaration: 'export interface SkillDefinition extends SkillSummary {\n content: string;\n path?: string;\n metadata?: Record;\n}', + declaration: 'export interface SkillDefinition extends SkillSummary {\n readonly content: string;\n readonly path?: string;\n readonly metadata?: Readonly>;\n}', }, { name: 'SkillLookupOptions', @@ -833,15 +816,15 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SkillProvider', - declaration: 'export interface SkillProvider {\n name: string;\n list(options: SkillLookupOptions): Promise;\n get(candidate: SkillCandidate, options: SkillLookupOptions): Promise;\n}', + declaration: 'export interface SkillProvider {\n readonly name: string;\n readonly list: (options: SkillLookupOptions) => Promise;\n readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise;\n}', }, { name: 'SkillRegistration', - declaration: 'export type SkillRegistration = Omit & {\n provider?: string;\n};', + declaration: 'export type SkillRegistration = Omit & {\n readonly provider?: string;\n};', }, { name: 'SkillResourceBase', - declaration: 'export type SkillResourceBase = {\n kind: \'directory\';\n path: string;\n} | {\n kind: \'url\';\n url: string;\n} | {\n kind: \'opaque\';\n description: string;\n};', + declaration: 'export type SkillResourceBase = {\n readonly kind: \'directory\';\n readonly path: string;\n} | {\n readonly kind: \'url\';\n readonly url: string;\n} | {\n readonly kind: \'opaque\';\n readonly description: string;\n};', }, { name: 'SkillSource', @@ -849,7 +832,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SkillSummary', - declaration: 'export interface SkillSummary {\n name: string;\n description: string;\n whenToUse?: string;\n disableModelInvocation?: boolean;\n source: SkillSource;\n provider: string;\n resourceBase?: SkillResourceBase;\n}', + declaration: 'export interface SkillSummary {\n readonly name: string;\n readonly description: string;\n readonly whenToUse?: string;\n readonly disableModelInvocation?: boolean;\n readonly source: SkillSource;\n readonly provider: string;\n readonly resourceBase?: SkillResourceBase;\n}', }, { name: 'StreamChunk', @@ -873,23 +856,23 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentCapabilities', - declaration: 'export interface SubagentCapabilities {\n outputSchema: boolean;\n depthLimit: boolean;\n toolFilter: boolean;\n persona: boolean;\n}', + declaration: 'export interface SubagentCapabilities {\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n readonly toolFilter: boolean;\n readonly persona: boolean;\n}', }, { name: 'SubagentProvider', - declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n start(request: SubagentStartRequest): SubagentRun;\n}', + declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n start(request: SubagentStartRequest): Promise;\n}', }, { name: 'SubagentResult', - declaration: 'export interface SubagentResult {\n output: ContentBlock[];\n structured?: unknown;\n stopReason: SubagentStopReason;\n}', + declaration: 'export interface SubagentResult {\n readonly output: ContentBlock[];\n readonly structured?: unknown;\n readonly stopReason: SubagentStopReason;\n}', }, { name: 'SubagentRun', - declaration: 'export interface SubagentRun {\n readonly id: AgentId;\n readonly started: Promise;\n readonly result: Promise;\n cancel(reason?: string): void;\n dispose(): Promise;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): SubagentRun;\n}', + declaration: 'export interface SubagentRun {\n readonly id: AgentId;\n readonly result: Promise;\n dispose(): Promise;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): Promise;\n}', }, { name: 'SubagentStartRequest', - declaration: 'export interface SubagentStartRequest {\n prompt: ContentBlock[];\n parent: Agent;\n signal?: AbortSignal;\n agentOptions?: AgentOptions;\n outputSchema?: StructuredOutputSchema;\n maxDepth?: number;\n toolFilter?: {\n allow?: string[];\n deny?: string[];\n };\n persona?: string;\n}', + declaration: 'export interface SubagentStartRequest {\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: StructuredOutputSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n}', }, { name: 'SubagentStopReason', @@ -937,7 +920,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolDefinition', - declaration: 'export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolExecution): Promise;\n timeoutMs?: number;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}', + declaration: 'export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolExecution): Promise;\n timeoutMs?: number;\n readonly ownerFinal?: boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}', }, { name: 'ToolErrorInfo', @@ -961,7 +944,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolExecutionToken', - declaration: 'export interface ToolExecutionToken {\n readonly [toolExecutionTokenBrand]: true;\n}', + declaration: 'export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n};', }, { name: 'ToolGuard', @@ -969,11 +952,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolProviderResult', - declaration: 'export interface ToolProviderResult {\n schemas: ToolSchema[];\n knownNames?: readonly string[];\n}', + declaration: 'export interface ToolProviderResult {\n readonly schemas: readonly ToolSchema[];\n readonly knownNames?: readonly string[];\n readonly ownerFinalNames?: readonly string[];\n}', }, { name: 'ToolRestriction', - declaration: 'export interface ToolRestriction {\n allow?: string[];\n deny?: string[];\n}', + declaration: 'export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n}', }, { name: 'ToolResult', diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index f1c3e9af9d..71be5b339e 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -272,7 +272,7 @@ describe('agent loop', () => { expect(result.data.meta).toBeUndefined() expect(result.data.content).toEqual([{ type: 'text', - text: 'Error: tools/execute must return a losslessly JSON-serializable ToolExecutionResult', + text: 'Error: tool result must be losslessly JSON-serializable', }]) } // The normalized failure was durably logged and fed back to the model; the diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 559825d404..6332a0a458 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -73,14 +73,17 @@ declare module 'cordis' { /** * A provider established a ready child. For in-process providers, * `ctx.agents.get(info.id)` resolves during this notification. - * Scope-filtered by the delegating parent and paired with `subagent/end`. + * Scope-filtered dispatch keys the carrier by the delegating parent, so a + * parent-scoped listener observes only its own delegations. Paired with + * `subagent/end`. * @param info - the provider and ready child identity. * @mode emit */ 'subagent/start'(this: Scoped, info: SubagentRunInfo): void /** - * A ready child settled. Scope-filtered by the delegating parent and - * paired with `subagent/start`. + * A ready child settled. Scope-filtered dispatch uses the same delegating + * parent carrier as `subagent/start`, so the lifecycle pair reaches the + * same scoped audience. * @param info - the run identity and terminal outcome. * @mode emit */ diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index ae1fa8d170..68139e9ff8 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -81,7 +81,6 @@ const FENCE = 'ts cordis-catalog' */ export const LINK_MAP: Record = { Agent: 'core.md', - AgentRegistrationReservation: 'core.md', ContentBlock: 'core.md', Message: 'core.md', MessageSource: 'core.md', @@ -89,7 +88,6 @@ export const LINK_MAP: Record = { LlmCallConfig: 'core.md', SessionEvent: 'core.md', SessionStartSource: 'core.md', - SessionRegistrationReservation: 'session.md', StreamChunk: 'llm-streaming.md', TurnEndReason: 'session.md', ToolDefinition: 'tools.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index c36a50c244..c4c8186f38 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -588,12 +588,12 @@ function collectEventRelations(): Map { if (method === 'on') { const event = eventArg(node.arguments, method) if (event) ensure(event).listeners.add(leaf) - } else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'strictSerial' || method === 'waterfall') { + } else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'waterfall') { const event = eventArg(node.arguments, method) if (event) { const relation = ensure(event) const methods = relation.dispatchers.get(leaf) ?? new Set() - methods.add(method === 'strictSerial' ? 'strictSerial (serial)' : method) + methods.add(method) relation.dispatchers.set(leaf, methods) } } diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 62111d1551..a219ea1b1d 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -11,7 +11,6 @@ { "doc": "docs/core-data-structures/core.md", "symbol": "LlmCallConfig", "source": "packages/llm/llm/src/call-config.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "Agent", "source": "packages/core/agent/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "AgentRegistrationReservation", "source": "packages/core/agent/src/index.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "HookContext", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationDecision", "source": "packages/core/agent/src/types.ts" }, @@ -23,8 +22,8 @@ { "doc": "docs/core-data-structures/scope.md", "symbol": "Scope", "source": "packages/core/scope/src/index.ts" }, { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "AssembleContext", "source": "packages/core/system-prompt/src/index.ts" }, + { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "PromptSection", "source": "packages/core/system-prompt/src/index.ts" }, { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "ToolProviderResult", "source": "packages/core/system-prompt/src/index.ts" }, - { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "PromptProtection", "source": "packages/core/system-prompt/src/index.ts" }, { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "TokenUsage", "source": "packages/llm/llm/src/types.ts" }, @@ -41,7 +40,6 @@ { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceOp", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceIntent", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceNode", "source": "packages/core/session/src/surface.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SessionRegistrationReservation", "source": "packages/core/session/src/index.ts" }, { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" }, @@ -54,6 +52,7 @@ { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionInput", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolGuard", "source": "packages/core/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolRestriction", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "PreToolDecision", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "PostToolDecision", "source": "packages/core/tools/src/index.ts" }, From 738054562d7bee3999ff2aee9fd8832597ebbd2f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:15:07 +0800 Subject: [PATCH 58/64] fix: complete scoped lifecycle simplification --- packages/core/agent-loop/src/index.ts | 30 +++---- packages/core/agent-loop/tests/agent.spec.ts | 14 +++ packages/core/agent-loop/tests/resume.spec.ts | 22 +++++ .../agent-loop/tests/scope-lifecycle.spec.ts | 90 +++++++++++++++++++ packages/core/agent/src/index.ts | 1 + packages/core/session/src/index.ts | 1 + packages/core/tools/src/index.ts | 3 +- packages/core/tools/tests/scoped.spec.ts | 15 ++++ packages/skill/skill/src/index.ts | 1 - packages/subagent/subagent-acp/src/run.ts | 5 ++ .../subagent-acp/tests/subagent-acp.spec.ts | 17 +++- .../subagent-mock/tests/subagent-mock.spec.ts | 20 +++++ packages/ui/user-approval/src/index.ts | 3 - .../ui/user-approval/tests/approval.spec.ts | 20 ----- .../workflow-workerthread/src/host.ts | 8 +- .../tests/workflow-workerthread.spec.ts | 61 +++++++++++++ 16 files changed, 261 insertions(+), 50 deletions(-) diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 3f717378d2..4ad4779176 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -54,7 +54,6 @@ class FactoryOwnership { } track(transaction: AgentCreationTransaction): () => void { - if (!this.isActive()) throw new Error('agent loop is not active') this.transactions.add(transaction) return () => { this.transactions.delete(transaction) } } @@ -62,12 +61,9 @@ class FactoryOwnership { async dispose(): Promise { this.accepting = false const reason = new Error('agent loop is not active') - const results = await Promise.allSettled( + await Promise.all( [...this.transactions].map(transaction => transaction.disposeForFactory(reason)), ) - const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason as unknown] : []) - if (errors.length === 1) throw errors[0] - if (errors.length > 1) throw new AggregateError(errors, 'agent loop transaction disposal failed') } } @@ -101,8 +97,6 @@ class AgentCreationTransaction { private detachAgent: (() => void) | undefined private publishing = false private cleanupTask: Promise | undefined - private finished = false - private wrapperFinished = false private ownerFollowing = true private readonly ownerDispose: () => Promise | void private readonly untrackFactory: () => void @@ -120,20 +114,17 @@ class AgentCreationTransaction { ownerCtx.fiber.assertActive() this.ownerAgent = ownerCtx.agent this.ownerFiber = ownerCtx.fiber + if (!ownership.isActive()) throw new Error('agent loop is not active') + this.ownerDispose = ownerCtx.effect(() => () => { + if (!this.ownerFollowing) return + return this.dispose(new Error(`agent "${id}" setup aborted: owner disposed during setup`)) + }, `agentLoop.owner(${id})`) this.untrackFactory = ownership.track(this) - try { - this.ownerDispose = ownerCtx.effect(() => () => { - if (!this.ownerFollowing) return - return this.dispose(new Error(`agent "${id}" setup aborted: owner disposed during setup`)) - }, `agentLoop.owner(${id})`) - } catch (error: unknown) { - this.untrackFactory() - throw error - } if (signal === undefined) { this.abortListener = undefined } else { this.abortListener = () => { + /* v8 ignore next 3 -- transaction teardown contains callback/driver failures; rejection is a future-drift backstop. */ void this.dispose(signalAbortError(id, signal)).catch((error: unknown) => { this.loopCtx.logger.error(error) }) @@ -168,6 +159,7 @@ class AgentCreationTransaction { return await Promise.race([ Promise.resolve(operation), this.deactivation.promise.then(() => { + /* v8 ignore next -- deactivate() assigns failure before resolving deactivation. */ throw this.failure ?? new Error(`agent "${this.id}" creation deactivated`) }), ]) @@ -229,9 +221,11 @@ class AgentCreationTransaction { publish(source: SessionStartSource): AgentHandle { this.assertActive() const driver = this.driver + /* v8 ignore next -- publish() is private and every caller invokes prepare() first. */ if (driver === undefined) throw new Error(`agent "${this.id}" is not prepared`) const agent = driver.agent const session = this.session + /* v8 ignore next -- prepare() assigns the session before it can produce the driver above. */ if (session === undefined) throw new Error(`agent "${this.id}" has no prepared session`) this.publishing = true try { @@ -274,8 +268,6 @@ class AgentCreationTransaction { /** Complete ownership bookkeeping after every resource reached quiescence. */ private finish(): void { - if (this.finished) return - this.finished = true this.untrackFactory() this.ownerFollowing = false void this.ownerDispose() @@ -310,8 +302,6 @@ class AgentCreationTransaction { /** Mark the public create/resume continuation settled and detach its creation-only signal. */ finishWrapper(): void { - if (this.wrapperFinished) return - this.wrapperFinished = true if (this.signal !== undefined && this.abortListener !== undefined) { this.signal.removeEventListener('abort', this.abortListener) } diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 95b5440fe1..7f65bbc5f3 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -49,6 +49,20 @@ function send(agent: ReactLoopAgent, text: string) { } describe('ReactLoopAgent', () => { + it('rejects access before context binding and a second driver for one session', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('exclusive-driver')) + const prepared = prepareReactLoopAgent(ctx, AgentId('first-driver'), { model: 'mock' }, session) + + expect(() => prepared.agent.ctx).toThrow('context is not bound') + expect(() => prepareReactLoopAgent(ctx, AgentId('second-driver'), { model: 'mock' }, session)) + .toThrow('already has a concrete agent driver') + + await prepared.dispose() + await ctx.fiber.dispose() + }) + it('borrows caller options and binds its scoped context exactly once', async () => { const ctx = await harness(new MockAdapter([textResponse('unused')])) const options = { model: 'mock' } diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 2c9fc78177..93605008ec 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -69,7 +69,29 @@ async function promptly(task: Promise): Promise { } } +/** Throw an arbitrary callback value to exercise the public unknown-error boundary. */ +function throwUnknown(value: unknown): never { + throw value +} + describe('the session-persistence RFC: AgentLoop factory create/resume', () => { + it('normalizes a non-Error resume publication failure for rollback and rethrows it', async () => { + const sessionId = SessionId('unknown-resume-failure-s') + const root = await persistSession(sessionId) + const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) + const failure = { source: 'resume' } + ctx.on('session/created', () => throwUnknown(failure)) + + await expect(ctx.agents.resume({ + agentId: AgentId('unknown-resume-failure'), + resumeSessionId: sessionId, + })).rejects.toBe(failure) + + expect(ctx.agents.get(AgentId('unknown-resume-failure'))).toBeUndefined() + expect(ctx.sessions.get(sessionId)).toBeUndefined() + await ctx.fiber.dispose() + }) + it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => { const adapter = new MockAdapter([textResponse('hi')]) const { ctx } = await persistentHarness(adapter) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 65ebbff8dd..2c478ad1f0 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -40,6 +40,11 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }] +/** Throw an arbitrary callback value to exercise the public unknown-error boundary. */ +function throwUnknown(value: unknown): never { + throw value +} + /** Invoke the exact lifecycle effect to exercise same-stack reentrant teardown. */ function disposeCurrentLifecycle(ownerCtx: Context): void { const lifecycle = [...ownerCtx.fiber._disposables] @@ -52,6 +57,91 @@ function disposeCurrentLifecycle(ownerCtx: Context): void { } describe('agent scope lifecycle', () => { + it('rejects an already-aborted creation signal before publishing either identity', async () => { + const ctx = await harness() + const reason = new Error('cancelled before creation') + const controller = new AbortController() + controller.abort(reason) + + await expect(ctx.agents.create({ + agentId: AgentId('pre-aborted'), + sessionId: SessionId('pre-aborted-s'), + signal: controller.signal, + })).rejects.toBe(reason) + + expect(ctx.agents.get(AgentId('pre-aborted'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('pre-aborted-s'))).toBeUndefined() + + const valueController = new AbortController() + valueController.abort('plain cancellation reason') + await expect(ctx.agents.create({ + agentId: AgentId('pre-aborted-value'), + sessionId: SessionId('pre-aborted-value-s'), + signal: valueController.signal, + })).rejects.toMatchObject({ + message: 'agent "pre-aborted-value" creation aborted', + cause: 'plain cancellation reason', + }) + + expect(ctx.agents.get(AgentId('pre-aborted-value'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('pre-aborted-value-s'))).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('joins cleanup when an abort lands reentrantly during scope preparation', async () => { + const ctx = await harness() + const reason = new Error('cancelled while preparing') + const controller = new AbortController() + let aborted = false + ctx.on('internal/plugin', (fiber) => { + if (aborted || fiber.name !== 'scope') return + aborted = true + controller.abort(reason) + }) + + await expect(ctx.agents.create({ + agentId: AgentId('prepare-abort'), + sessionId: SessionId('prepare-abort-s'), + signal: controller.signal, + })).rejects.toBe(reason) + + expect(ctx.agents.get(AgentId('prepare-abort'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('prepare-abort-s'))).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('normalizes non-Error create failures for rollback while rethrowing the original value', async () => { + const ctx = await harness() + let thrown: unknown + ctx.on('session/created', () => { + if (thrown === undefined) return + const value = thrown + thrown = undefined + throwUnknown(value) + }) + + const createFailure = { source: 'create' } + thrown = createFailure + let createCaught: unknown + try { + ctx.agentLoop.create(AgentId('unknown-create')) + } catch (error: unknown) { + createCaught = error + } + expect(createCaught).toBe(createFailure) + + const ownedFailure = { source: 'createAgent' } + thrown = ownedFailure + await expect(ctx.agents.create({ + agentId: AgentId('unknown-owned-create'), + sessionId: SessionId('unknown-owned-create-s'), + })).rejects.toBe(ownedFailure) + + expect(ctx.agents.get(AgentId('unknown-create'))).toBeUndefined() + expect(ctx.agents.get(AgentId('unknown-owned-create'))).toBeUndefined() + await ctx.fiber.dispose() + }) + it('wires agent.ctx: tagged with the agent, DX field set, ctx.agent safe elsewhere', async () => { const ctx = await harness() const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 07ec5adb99..42b9d13e35 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -364,6 +364,7 @@ export class AgentRegistry extends Service { entry.detachRequested = false // A stale capability can never delete a later same-id lifecycle. The // captured entry identity is the final boundary. + /* v8 ignore next -- enter() rejects replacement while this single-shot detach capability is live. */ if (this.store.get(entry.id) !== entry) return this.store.delete(entry.id) this.entries.delete(entry.agent) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index b671ad195b..6ce38bbffe 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -731,6 +731,7 @@ export class SessionStore extends Service { entry.detachRequested = false // A stale capability cannot remove observers or storage belonging to a // later same-id lifecycle. + /* v8 ignore next -- enter() rejects replacement while this single-shot detach capability is live. */ if (this.store.get(entry.id) !== entry) return this.store.delete(entry.id) attachments.delete(entry.session) diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 3365662458..84a1ec3c51 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -843,7 +843,8 @@ export class ToolRegistry extends Service { // invariant assertion as well as protection against future layer changes. if (this.codeTransport !== undefined) { visible.set(RUN_CODE_NAME, this.codeTransport) - if (this.codeTransport.ownerFinal === true) ownerFinalNames.add(RUN_CODE_NAME) + // createRunCodeTool() owns this internal transport and always marks it owner-final. + ownerFinalNames.add(RUN_CODE_NAME) } return { visible, knownNames, restrictableNames, ownerFinalNames } } diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index caf3ccb7be..991d420054 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -103,6 +103,21 @@ describe('scoped tool registration', () => { .toThrow(/owner-final tool "reserved" cannot be registered while a scoped shadow exists/) }) + it('restores global and scoped owner-final tools removed by assembly middleware', async () => { + const ctx = await mount() + const { scope, key } = await mintAgentScope(ctx, 'owner-final') + ctx.tools.register({ ...tool('required'), ownerFinal: true }) + scope.ctx.tools.register({ ...tool('scoped-required'), ownerFinal: true }) + ctx.on('system-prompt/assemble', async assembly => ({ + ...assembly, + tools: assembly.tools.filter(schema => !schema.name.includes('required')), + })) + + expect((await ctx.systemPrompt.assemble()).tools.map(schema => schema.name)).toContain('required') + expect((await ctx.systemPrompt.assemble({ scope: key })).tools.map(schema => schema.name)) + .toEqual(expect.arrayContaining(['required', 'scoped-required'])) + }) + it('disposing the scope unwinds its registrations and leaves no residue', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'a') diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index af4af6f7a2..f2ece2379f 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -527,7 +527,6 @@ function waitWithAbort(promise: Promise, signal: AbortSignal | undefined): reject(toError(error)) }, ) - if (signal.aborted) onAbort() }) } diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 54a5641d96..5222906a2d 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -340,6 +340,11 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe cancelSettled.then((): SubagentResult => ({ output: collectOutput(), stopReason: 'aborted' })), ]) } catch (error: unknown) { + // A deterministic cancellation resolves `cancelSettled` before its + // best-effort ACP cancel can reject the prompt. This fallback is only for + // a process/pipe rejection already queued when the abort event fires; its + // first-outcome ordering cannot be forced without a timing-dependent test. + /* v8 ignore next */ if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' } // The seam contract: result resolves (never rejects) on a child-level // failure. Startup failures were already rejected before publication; diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 4bbafea521..eed3cfe1ed 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -127,7 +127,9 @@ describe('dsh-subagent-acp', () => { const result = await run.result expect(result.stopReason).toBe('completed') expect(text(result.output)).toBe('hello from acp child') - await run.dispose() + const disposal = run.dispose() + expect(run.dispose()).toBe(disposal) + await disposal }) it('maps a max_tokens stop reason', async () => { @@ -470,6 +472,19 @@ describe('dsh-subagent-acp', () => { await run.dispose() }) + it('logs a flattened child failure through the registered provider', async () => { + const ctx = await setup({ MOCK_CRASH_ON_PROMPT: '1' }) + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + const run = await ctx.subagents.start('acp', request()) + const result = await run.result + expect(result.stopReason).toBe('error') + expect(warnings).toEqual([ + expect.stringContaining('subagent-acp "acp": child run failed (error):'), + ]) + await run.dispose() + }) + it('resolves error (never rejects) even when the onError sink itself throws', async () => { // onError is a caller-supplied callback boundary: its own exception must be // contained, or it would reject `result` and break the seam's "result never diff --git a/packages/support/subagent-mock/tests/subagent-mock.spec.ts b/packages/support/subagent-mock/tests/subagent-mock.spec.ts index 3ddfaad4d4..7fd93c62bc 100644 --- a/packages/support/subagent-mock/tests/subagent-mock.spec.ts +++ b/packages/support/subagent-mock/tests/subagent-mock.spec.ts @@ -32,6 +32,7 @@ describe('dsh-subagent-mock', () => { structured: undefined, stopReason: 'completed', }) + await run.dispose() }) it('registers under a configurable name', async () => { @@ -75,6 +76,25 @@ describe('dsh-subagent-mock', () => { await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted' }) }) + it('rejects an already-aborted request before starting publication', async () => { + const ctx = await mount() + const controller = new AbortController() + controller.abort() + + await expect(ctx.subagents.start('mock', baseRequest({ signal: controller.signal }))) + .rejects.toThrow('mock subagent start aborted before publication') + }) + + it('rejects when cancellation wins the asynchronous publication handoff', async () => { + const ctx = await mount() + const controller = new AbortController() + const pending = ctx.subagents.start('mock', baseRequest({ signal: controller.signal })) + + controller.abort() + + await expect(pending).rejects.toThrow('mock subagent start aborted before publication') + }) + it('unregisters the provider when the owning fiber is disposed (HMR safety)', async () => { const ctx = new Context() await ctx.plugin(SubagentService) diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 8496e40e62..6b3ef962bb 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -451,9 +451,6 @@ export class ApprovalService extends Service { resolve('cancelled') } signal.addEventListener('abort', onAbort, { once: true }) - // Abort can win after the initial check but before listener installation. - // Recheck at the settlement boundary so that edge still cancels. - if (signal.aborted) onAbort() void answer.then((outcome) => { signal.removeEventListener('abort', onAbort) // After an abort won the race this resolve is a settled-promise no-op: diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index be9e723732..ff0cd5c7d8 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -281,26 +281,6 @@ describe('ApprovalService.request', () => { expect(appended[1]?.data).toMatchObject({ outcome: 'cancelled' }) }) - it('does not miss an abort between the initial check and listener installation', async () => { - const ctx = await mounted() - const { agent, appended } = fakeAgent() - const answer = Promise.withResolvers() - ctx.on('approval/request', () => answer.promise) - const controller = new AbortController() - const addEventListener = controller.signal.addEventListener.bind(controller.signal) - const add = vi.spyOn(controller.signal, 'addEventListener').mockImplementation((type, listener, options) => { - controller.abort() - addEventListener(type, listener, options) - }) - - await expect(ctx.approval.request(requestOf(agent, { signal: controller.signal }))).resolves.toBe('cancelled') - - answer.resolve('allowed-once') - await Promise.resolve() - expect(add).toHaveBeenCalledOnce() - expect(appended[1]?.data).toMatchObject({ outcome: 'cancelled' }) - }) - it('resolves cancelled when the signal aborts mid-question and discards the late answer', async () => { const ctx = await mounted() const { agent, appended } = fakeAgent() diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index 44ea569382..95c07f0d2d 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -468,13 +468,13 @@ export class WorkerRun implements WorkflowRun { .catch((error: unknown) => { this.ctx.logger.warn(`workflow-workerthread: child dispose failed: ${renderThrown(error)}`) }) - .then(() => { this.finishChild(callId, record) }) + .then(() => { this.finishChild(callId) }) return record.disposal } - /** Drop an exact child record and release quiescence waiters when all work ends. */ - private finishChild(callId: number, record: ChildRecord): void { - if (this.children.get(callId) === record) this.children.delete(callId) + /** Drop a child record and release quiescence waiters when all work ends. */ + private finishChild(callId: number): void { + this.children.delete(callId) this.notifyChildQuiescence() } diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 623da10f72..4ad00ee02f 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -1049,6 +1049,67 @@ describe('dsh-workflow-workerthread', () => { await ctx.fiber.dispose() }) + it('refuses and disposes a provider run that becomes ready after its real worker dies', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const requested = Promise.withResolvers() + const ready = Promise.withResolvers() + let disposeCalls = 0 + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => ctx.logger) + const provider: SubagentProvider = { + name: 'late-ready', + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, + inheritsParentContext: false, + start: (request) => { + requested.resolve(request) + // Model a backend whose independent startup boundary cannot be + // interrupted promptly. The host must still reject ownership if the + // worker dies before this promise transfers the ready run. + return ready.promise + }, + } + ctx.subagents.registerProvider(provider) + await ctx.plugin(WorkerWorkflowEngine, { provider: 'late-ready', maxConcurrentAgents: 1 }) + const lifecycle: string[] = [] + ctx.on('workflow/agent-start', () => { lifecycle.push('start') }) + ctx.on('workflow/agent-end', () => { lifecycle.push('end') }) + + const handle = ctx.workflows.start({ + ...scripted("return await agent('pending startup')"), + parent: fakeParent(), + }) + const request = await requested.promise + const worker = (handle as unknown as { worker: Worker }).worker + + // Kill the actual Worker while provider startup is independently + // pending. Death closes admission and aborts the shared signal, but this + // deliberately uncooperative provider still fulfills afterward. + await worker.terminate() + const result = await handle.result + expect(result.stopReason).toBe('error') + expect(result.error).toContain('exit code') + expect(request.signal.aborted).toBe(true) + expect(request.signal.reason).toBe('workflow worker gone') + + ready.resolve({ + id: AgentId('late-ready-child'), + result: Promise.resolve({ output: [], stopReason: 'aborted' }), + dispose: () => { + disposeCalls += 1 + return Promise.reject(new Error('late ready dispose failed')) + }, + }) + await waitFor(() => { + expect(disposeCalls).toBe(1) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('refused child dispose failed: Error: late ready dispose failed')) + }, 1000) + expect(lifecycle).toEqual([]) + + await handle.dispose() + expect(disposeCalls).toBe(1) + await ctx.fiber.dispose() + }) + it('a worker that exits before settling reports an error result and reaps its children', async () => { const ctx = new Context() await ctx.plugin(SubagentService) From 2ca806a1ec24fe3bf9e09eb3309bfcce378d89f9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:16:30 +0800 Subject: [PATCH 59/64] docs: refresh scoped lifecycle catalogs --- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4e92f0a0a2..63d1303fc6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -129,7 +129,7 @@ export interface Config { Depends on: [`AgentId`](../packages/core/agent/src/index.ts) · [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts) -Source: [`packages/core/agent-loop/src/index.ts:335`](../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` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 29e440776d..2e508e0a35 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -19,7 +19,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:348`](../../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` From 5d49a7916571164a6830e5f97e86d4eaf2df079c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:34:35 +0800 Subject: [PATCH 60/64] docs: align lifecycle guides with final entry --- docs/architecture.md | 2 +- docs/cookbook/extension-cookbook.md | 4 ++-- .../2026-06-18-agent-lifecycle-and-ownership-seams.md | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 0976277aaf..2012be0946 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -58,7 +58,7 @@ A **session** is one agent's append-only event log. A **turn** drains one queued ### Turn Flow ```text -reserve ids -> mint agent.ctx -> await unpublished setup +prepare private session + agent.ctx -> await unpublished setup -> enter session + agent -> session/created -> agent/created -> enable driving -> agent/session-start(source) -> start driver forever: diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index f15ef37e9d..ac62cdd75b 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -94,14 +94,14 @@ Every product feature maps to a listener on a documented extension seam — the | Hook system (user + project level) | listeners on `agent/session-start`, `agent/prompt-submit`, `agent/request`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation` — each interception waterfall returns a typed Decision; the `dsh-hooks-claude` / `dsh-hooks-codex` bridges map hook config files onto these seams | | `/goal` | force-continue via `agent/turn-continuation` + `steer()` reminders | | `/loop` | on the `turn/end` session event, `send()` the next iteration; or force-continue | -| 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` | +| Dynamic workflow | `ctx.workflows` + the worker-thread engine + the `workflow` tool; structured in-process children enforce output with scoped owner-final prompt/tool contributions, 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; 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 | -| ToolSearch / progressive disclosure | filter ordinary capabilities at `system-prompt/assemble` (the loop logs the result as the request header); owner-protected transport and correctness entries retain their canonical presence or absence | +| ToolSearch / progressive disclosure | filter ordinary capabilities at `system-prompt/assemble` (the loop logs the result as the request header); owner-final transport and correctness contributions retain their canonical presence or absence | | Tool deadline / retry / metrics | wrap core dispatch with `tools/execute`; a wrapper may replace `exec.signal`, delegate, and inspect the normalized result in one lexical lifetime | | Final tool-result metrics / audit / capture | observe immutable authoritative outcomes with `tools/result`; use `tools/post-execute` instead only when the plugin must transform the result or attach context | | Monotonic terminal turn policy | return `{ action: 'stop' }` from serial `agent/turn-stop`, after continuation and steering have already been folded | diff --git a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md index 80ca18b56f..d64329262e 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md +++ b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md @@ -16,7 +16,7 @@ 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 }`. 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). +`ctx.agents.create`/`resume` (and the `AgentFactory` interface) return `AgentHandle = { agent: Agent; dispose(): Promise }`. 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 and idle flushes (true quiescence, not just the `disposed` status flip), detach the agent, detach its session, and unwind its scope. Each public ID becomes reusable when its exact registry entry detaches; there is no separate reservation-release phase. 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 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. @@ -35,7 +35,7 @@ These invariants hold and are pinned by tests: ## Session owner tokens are unique among live agents -The bash owner-token comparison relies on `session.header.id` being unique among live agents. `SessionStore.enter()` rejects a duplicate live session id, and the async agent factory reserves both agent and session ids across persistence loading and unpublished setup before rechecking the store at publication. A programmatic caller therefore cannot publish two live agents with one session token. The access *policy* (token comparison) stays in `tool-bash` (the consumer); the bash seam stores only an opaque `owner` string and never interprets it — the correct interface/implementation/consumer split. +The bash owner-token comparison relies on `session.header.id` being unique among live agents. Concurrent same-ID operations may both prepare privately, but publication enters the session and agent in order; `SessionStore.enter()` rejects a duplicate live session id, and every losing transaction rolls its private state back. A programmatic caller therefore cannot publish two live agents with one session token. The access *policy* (token comparison) stays in `tool-bash` (the consumer); the bash seam stores only an opaque `owner` string and never interprets it — the correct interface/implementation/consumer split. ## Alternatives considered From f7b9cea733a472a569079cf2da93dc7961ee813b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:40:27 +0800 Subject: [PATCH 61/64] docs: clarify scoped persona shadowing --- packages/core/agent-loop/README.md | 2 +- packages/core/system-prompt/README.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 0b0414d37f..871689008b 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -40,7 +40,7 @@ interface Config { } ``` -Agents listed in config are auto-created at startup. `cwd` applies only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. There is no per-agent persona: the deployment persona is `dsh-system-prompt`'s own `persona` config, shared by every agent in the context. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from the `assemble({ agent })` context — runtime facts of the agents THIS loop drives, unlike the `harness:identity`/`deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin. +Agents listed in config are auto-created at startup. `cwd` applies only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from the `assemble({ agent })` context — runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin. ### Exported concrete class diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 507ba86c5e..270a016cf3 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -1,12 +1,12 @@ # dsh-system-prompt -System prompt assembly registry. Plugins contribute ordered text sections, tool-schema providers, and named prompt variables; contributions that implement required protocol may declare themselves owner-final. The agent loop calls `assemble(context)` once per step, and `renderPrompt(assembly)` is the full system prompt the model sees. The plugin registers the harness-owned openers itself — the static `harness:identity` section and the deployment's `deployment:persona` section — so they exist for every agent regardless of which loop plugin drives it. +System prompt assembly registry. Plugins contribute ordered text sections, tool-schema providers, and named prompt variables; contributions that implement required protocol may declare themselves owner-final. The agent loop calls `assemble(context)` once per step, and `renderPrompt(assembly)` is the full system prompt the model sees. The plugin registers the harness-owned openers itself — the static `harness:identity` section and the global default `deployment:persona` section — so they remain available regardless of which loop plugin drives an agent. An agent-scoped contribution with the same persona name shadows that default for its agent. ## Config | Key | Default | Meaning | |---|---|---| -| `persona` | `''` | The deployment persona: the ONE deployment-authored prompt fragment, rendered as the order-0 `deployment:persona` section and shared by every agent in the context (subagents included). A template — complete `{{…}}` groups are interpreted strictly against the registered variables (the shipped loop registers `{{model}}`/`{{cwd}}`), with no escape syntax for literal braces yet. Empty ⇒ the section is dropped at render. | +| `persona` | `''` | The global deployment-persona default: the ONE config-authored prompt fragment, rendered as the order-0 `deployment:persona` section unless an agent-scoped contribution shadows it. A template — complete `{{…}}` groups are interpreted strictly against the registered variables (the shipped loop registers `{{model}}`/`{{cwd}}`), with no escape syntax for literal braces yet. Empty ⇒ the section is dropped at render. | | `toolOrder` | — | Explicit model-facing tool order, as a list of `ToolSchema.name`s with one `''` rest entry (`TOOL_ORDER_REST`): listed tools take their listed position, unlisted tools land at the rest entry in lexicographic name order. Absent ⇒ plain lexicographic name order. Applied to the collected tools BEFORE the `system-prompt/assemble` waterfall — like the sections' `order` sort, it canonicalizes what the registry contributed (registration order is a plugin-load artifact), and a waterfall listener that mutates the list owns the determinism of what it emits. Misconfiguration fails loud: a list without exactly one rest entry, or with duplicates, throws at load; a listed name with no registered tool rejects every `assemble()`; a tool provider returning the reserved rest-entry name also rejects. Under the shipped loop the turn fails before any model request. Why a central list and not per-plugin weights: [Explicit model-facing tool order](../../../docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md). | ## Service: `SystemPrompt` (ctx key: `systemPrompt`) @@ -41,7 +41,7 @@ Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `Asse ### What is NOT here -- Any deployment-authored prompt text outside config — the persona is this plugin's `persona` config, and every other section comes from the plugin that owns the fact. (The `harness:identity` line is deliberately a code literal: a harness fact, not a deployment choice; the `system-prompt/assemble` waterfall is the escape valve for a deployment that must drop it.) +- Any end-user prompt-editing API — this plugin owns the config-authored global persona default, creator plugins may register agent-scoped shadows during setup, and every other section comes from the plugin that owns the fact. (The `harness:identity` line is deliberately a code literal: a harness fact, not a deployment choice; the `system-prompt/assemble` waterfall is the escape valve for a deployment that must drop it.) - Prompt compaction (belongs on the `agent/pre-step` seam in `dsh-agent`). Design rationale: [the prompt-variables RFC](../../../docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). From f32cfafa1ab6137bf5b9683df1dceee790630c1d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:58:55 +0800 Subject: [PATCH 62/64] refactor: narrow synchronous extension contracts --- docs/cordis-catalog/events.md | 14 +++---- docs/cordis-catalog/services.md | 22 +++++------ docs/core-data-structures/tools.md | 2 +- docs/event-producer-consumer.md | 6 +-- .../2026-06-11-microkernel-event-taxonomy.md | 4 +- .../2026-07-08-agent-scope-contexts.md | 6 --- .../2026-07-12-agent-scope-runtime-design.md | 2 +- .../feature/2026-06-30-interception-seams.md | 2 +- docs/tool-execution-pipeline.md | 4 +- .../cordis/tool-cordis/src/api-catalog.ts | 30 +++++++-------- packages/cordis/tool-cordis/src/guard.ts | 4 +- packages/core/agent/README.md | 4 +- packages/core/agent/src/index.ts | 6 ++- packages/core/agent/src/types.ts | 5 +-- packages/core/agent/tests/agent.spec.ts | 17 +++++++-- packages/core/system-prompt/README.md | 6 +-- packages/core/system-prompt/src/index.ts | 21 ++++++----- .../core/system-prompt/tests/scoped.spec.ts | 2 +- .../system-prompt/tests/system-prompt.spec.ts | 8 ++-- packages/core/tools/README.md | 6 +-- packages/core/tools/src/index.ts | 37 ++++++++++--------- packages/core/tools/tests/code-mode.spec.ts | 2 +- packages/core/tools/tests/scoped.spec.ts | 17 +++++++-- packages/core/tools/tests/tools.spec.ts | 4 +- packages/skill/skill/README.md | 4 +- packages/skill/skill/src/index.ts | 6 ++- packages/skill/skill/tests/skill.spec.ts | 10 ++--- .../subagent-inprocess/src/structured.ts | 2 +- .../tests/structured.spec.ts | 2 +- packages/subagent/subagent/src/index.ts | 3 +- .../subagent/subagent/tests/service.spec.ts | 4 +- packages/subagent/tool-subagent/src/index.ts | 4 +- scripts/gen-doc-graphs.ts | 8 ++-- 33 files changed, 148 insertions(+), 126 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index e0149c7c67..67d339df5a 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -47,7 +47,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:606`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:605`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -165,15 +165,15 @@ Source: [`packages/core/agent/src/types.ts:570`](../../packages/core/agent/src/t ### `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. 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. +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. ```ts cordis-catalog -'agent/turn-stop'(this: Scoped, agent: Agent, turn: number): Promise | ContinuationStop | undefined +'agent/turn-stop'(this: Scoped, agent: Agent, turn: number): ContinuationStop | undefined ``` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:589`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:588`](../../packages/core/agent/src/types.ts) ## `approval/*` @@ -419,12 +419,12 @@ Types: [ToolExecution](../core-data-structures/tools.md) Source: [`packages/core/tools/src/index.ts:101`](../../packages/core/tools/src/index.ts) -### `tools/result` — parallel +### `tools/result` — emit -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. +Synchronous 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, exec: Readonly, result: Readonly): Promise | void +'tools/result'(this: Scoped, exec: Readonly, result: Readonly): undefined ``` Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 2e508e0a35..e3fe559d35 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -26,10 +26,10 @@ Source: [`packages/core/agent-loop/src/index.ts:338`](../../packages/core/agent- Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package. Agent *creation* is provided by whichever plugin implements the AgentFactory (`@deepseek-ai/dsh-agent-loop`), registered via setFactory. ```ts cordis-catalog -setFactory(factory: AgentFactory): () => Promise | void +setFactory(factory: AgentFactory): () => void async create(options: CreateAgentOptions): Promise async resume(options: ResumeAgentOptions): Promise -register(agent: Agent): () => Promise | void +register(agent: Agent): () => void enter(agent: Agent): () => void announce(agent: Agent): void get(id: AgentId): Agent | undefined @@ -225,8 +225,8 @@ Source: [`packages/core/session/src/index.ts:591`](../../packages/core/session/s Registry of skill providers. It merges provider catalogs with stable first-wins duplicate handling, exposes sorted model-visible summaries, and loads full skill bodies on demand. ```ts cordis-catalog -registerProvider(provider: SkillProvider): () => Promise | void -register(skill: SkillRegistration): () => Promise | void +registerProvider(provider: SkillProvider): () => void +register(skill: SkillRegistration): () => void async list(options: SkillLookupOptions = {}): Promise async get(name: string, options: SkillLookupOptions = {}): Promise ``` @@ -238,7 +238,7 @@ Source: [`packages/skill/skill/src/index.ts:158`](../../packages/skill/skill/src Named provider registry and capability-checked start surface. ```ts cordis-catalog -registerProvider(provider: SubagentProvider): () => Promise | void +registerProvider(provider: SubagentProvider): () => void getProvider(name: string): SubagentProvider | undefined list(): string[] async start(name: string, request: SubagentStartRequest): Promise @@ -251,9 +251,9 @@ Source: [`packages/subagent/subagent/src/index.ts:123`](../../packages/subagent/ Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, named prompt variables, and owner-final contributions; the agent loop calls `assemble(context)` once per step. Registers the harness-owned `harness:identity` and `deployment:persona` sections itself (see Config.persona). ```ts cordis-catalog -section(section: PromptSection): () => Promise | void -tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise | void -variable(name: string, provider: (context: AssembleContext) => string | undefined): () => Promise | void +section(section: PromptSection): () => void +tools(provider: (context: AssembleContext) => ToolProviderResult): () => void +variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void async assemble(context: AssembleContext = {}): Promise ``` @@ -266,9 +266,9 @@ Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop e Two registration layers (`@deepseek-ai/dsh-scope`): a registration through a plain plugin context is GLOBAL (visible to every agent); one through a scoped context (`agent.ctx`) is filed in that scope's layer — visible to that agent alone, disposed with the scope, and SHADOWING a global tool of the same name for that agent (most-specific-wins; within one layer a duplicate name still throws). restrict masks the global layer per scope. One private visibility resolver feeds prompt assembly, get, and execute — and, under a non-native mode, the SDK section and `run_code`'s bindings — so what the model is shown, what a presenter renders, what a program can call, and what dispatches can never disagree. ```ts cordis-catalog -register(definition: ToolDefinition): () => Promise | void -restrict(filter: ToolRestriction): () => Promise | void -guard(guard: ToolGuard): () => Promise | void +register(definition: ToolDefinition): () => void +restrict(filter: ToolRestriction): () => void +guard(guard: ToolGuard): () => void get(name: string, scope?: ScopeKey): ToolDefinition | undefined schemas(scope?: ScopeKey): ToolSchema[] async execute(exec: ToolExecutionInput): Promise diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 4785de84a3..46c32a192f 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -190,7 +190,7 @@ type PostToolDecision = | { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext } ``` -Call `next()` to delegate to the default (allow / dispatch / accept-unchanged), or return a decision/result to short-circuit. A `pre-execute` `deny` skips dispatch and yields an `isError` result. An `ask` resolves through the optional approval seam: only `allowed-once` proceeds, while every non-grant, missing channel/service, or agent-less request becomes a normalized denial. A registered `ToolGuard` then runs and can still impose a final denial. Input rewrite is deliberately NOT offered on `PreToolDecision` because it would desync the pre-execution audit/history/UI from what ran. A `post-execute` `accept` may replace the model-facing `content`; a `block` turns the call into an `isError` whose content is the corrective `feedback`. The awaited `tools/result` notification then receives the frozen execution identity and a deep-frozen result snapshot after every wrapper, post decision, and outer error catch; observers cannot transform the outcome or race each other through payload mutation, and one observer failure neither changes the result nor starves peers. An unregistered tool routes through the same catch as a tool-thrown error, so both failure classes get a structured `{ name, code }` (`ToolNotFoundError` → `UNKNOWN_TOOL`) — the loop records a failed tool call instead of failing the whole turn. +Call `next()` to delegate to the default (allow / dispatch / accept-unchanged), or return a decision/result to short-circuit. A `pre-execute` `deny` skips dispatch and yields an `isError` result. An `ask` resolves through the optional approval seam: only `allowed-once` proceeds, while every non-grant, missing channel/service, or agent-less request becomes a normalized denial. A registered `ToolGuard` then runs and can still impose a final denial. Input rewrite is deliberately NOT offered on `PreToolDecision` because it would desync the pre-execution audit/history/UI from what ran. A `post-execute` `accept` may replace the model-facing `content`; a `block` turns the call into an `isError` whose content is the corrective `feedback`. The synchronous `tools/result` notification then receives the frozen execution identity and a deep-frozen result snapshot after every wrapper, post decision, and outer error catch; observers cannot transform the outcome or race each other through payload mutation, and one observer failure neither changes the result nor starves peers. An unregistered tool routes through the same catch as a tool-thrown error, so both failure classes get a structured `{ name, code }` (`ToolNotFoundError` → `UNKNOWN_TOOL`) — the loop records a failed tool call instead of failing the whole turn. ## The structured-output schema subset diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index aac25e71b9..654ccd27c0 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -9,7 +9,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | --- | --- | --- | --- | --- | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:331`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:606`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:605`](../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`) | - | @@ -19,7 +19,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:588`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:70`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | @@ -41,7 +41,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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) | +| `tools/result` | `emit` | [`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`) | - | diff --git a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md index 80b9b86e2a..8293924d37 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md +++ b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md @@ -12,8 +12,8 @@ Pure Cordis event taxonomy. The loop's extension seams are typed events with del - **waterfall** (around-middleware) where plugins transform, veto, or wrap: `agent/prompt-submit`, `agent/request`, `agent/step-result`, `agent/turn-continuation`, `tools/pre-execute`, `tools/execute`, `tools/post-execute`, `llm/stream`, `system-prompt/assemble`. - **serial** (awaited in listener order; a bail value stops later listeners) for ordered checkpoints: every `agent/pre-step` listener runs when all abstain, while the first stop returned from `agent/turn-stop` makes the terminal decision final. -- **parallel** (awaited fan-out) where every listener must get an independent chance: the `session/flush` durability checkpoint and the immutable observe-only `tools/result` notification. -- **emit** (synchronous fire-and-forget) for notifications: turn/step boundaries, stream chunks, lifecycle, and errors. +- **parallel** (awaited fan-out) where every listener must get an independent chance: the `session/flush` durability checkpoint. +- **emit** (synchronous fire-and-forget) for notifications: turn/step boundaries, stream chunks, lifecycle, errors, and the contained immutable `tools/result` observation. The event vocabulary lives in interface packages (dsh-agent declares the agent/* events); `@deepseek-ai/dsh-agent-loop` is the only concrete loop plugin and is itself swappable — nothing outside it may depend on it. diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md index 76c40805f8..dcff4d3220 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -135,12 +135,6 @@ flowchart TB detach --> revoke["Dispose the agent scope"] ``` -### Subagent controls are an independent feature - -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. - -`inheritsParentContext` describes conversation-history seeding only. It says nothing about Cordis scope, injected services, tools, or authority. - ## Security and authority are non-goals 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. diff --git a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index 786dce250e..2aeea1e48f 100644 --- a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -218,7 +218,7 @@ A fresh registry-assigned Symbol provides collision-free execution identity with 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. +After the last post-execute listener, the registry materializes and freezes the accepted final result once. Every synchronous `tools/result` observer receives that exact committed object, and observer failures are 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 diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md index 52d7e2b425..cb653c2519 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md @@ -26,7 +26,7 @@ Every call follows one ordered pipeline: `tools/pre-execute` → monotonic guard - **`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 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. +- **`tools/result`** is the synchronous contained 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. diff --git a/docs/tool-execution-pipeline.md b/docs/tool-execution-pipeline.md index 23dee7a91f..6f5c8890bf 100644 --- a/docs/tool-execution-pipeline.md +++ b/docs/tool-execution-pipeline.md @@ -19,7 +19,7 @@ flowchart TD fsGate["fs/write-intent or fs/edit-intent
tool-fs mutations only"] owned["Tool-owned session events
todo/write, fs/observed, hook/invoked, hook/result, tool/code-dispatch"] post["tools/post-execute waterfall
accept, block, replace, add context"] - final["tools/result parallel notification
frozen authoritative outcome"] + final["tools/result synchronous notification
frozen authoritative outcome"] context["Buffered additionalContext
context/message after all tool results"] toolResult["Session event: tool/result
single model-facing outcome"] allResults["All calls in the step settled
and tool/result events recorded"] @@ -48,6 +48,6 @@ flowchart TD allResults --> context ``` -Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and approval-triggering permission policy enter through the generic pre/post tool waterfalls, while `ctx.approval` resolves an `ask` before the monotonic guards; owner policy that must not be reordered uses registered guards; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. The awaited `tools/result` notification observes the immutable final outcome after every transform, lossless-JSON validation, and outer error normalization. That split lets the same hooks observe bash, fs, web, todo, skill, and subagent calls without coupling those tools to one policy service. Code Mode rides the whole pipeline twice over: `run_code` is the reserved registry-owned transport whose body enters the pipeline, and each tool call its program makes re-enters `ctx.tools.execute()` — serialized one at a time, carrying the outer execution's opaque token for correlation, and logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call's `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency). +Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and approval-triggering permission policy enter through the generic pre/post tool waterfalls, while `ctx.approval` resolves an `ask` before the monotonic guards; owner policy that must not be reordered uses registered guards; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. The synchronous `tools/result` notification observes the immutable final outcome after every transform, lossless-JSON validation, and outer error normalization. That split lets the same hooks observe bash, fs, web, todo, skill, and subagent calls without coupling those tools to one policy service. Code Mode rides the whole pipeline twice over: `run_code` is the reserved registry-owned transport whose body enters the pipeline, and each tool call its program makes re-enters `ctx.tools.execute()` — serialized one at a time, carrying the outer execution's opaque token for correlation, and logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call's `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency). Maintenance mode: curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs. diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 3ee6d1081f..4b5d614ac4 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -65,10 +65,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'agents', summary: 'Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package.', methods: [ - 'setFactory(factory: AgentFactory): () => Promise | void', + 'setFactory(factory: AgentFactory): () => void', 'async create(options: CreateAgentOptions): Promise', 'async resume(options: ResumeAgentOptions): Promise', - 'register(agent: Agent): () => Promise | void', + 'register(agent: Agent): () => void', 'enter(agent: Agent): () => void', 'announce(agent: Agent): void', 'get(id: AgentId): Agent | undefined', @@ -169,8 +169,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'skills', summary: 'Registry of skill providers.', methods: [ - 'registerProvider(provider: SkillProvider): () => Promise | void', - 'register(skill: SkillRegistration): () => Promise | void', + 'registerProvider(provider: SkillProvider): () => void', + 'register(skill: SkillRegistration): () => void', 'async list(options: SkillLookupOptions = {}): Promise', 'async get(name: string, options: SkillLookupOptions = {}): Promise', ], @@ -179,7 +179,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'subagents', summary: 'Named provider registry and capability-checked start surface.', methods: [ - 'registerProvider(provider: SubagentProvider): () => Promise | void', + 'registerProvider(provider: SubagentProvider): () => void', 'getProvider(name: string): SubagentProvider | undefined', 'list(): string[]', 'async start(name: string, request: SubagentStartRequest): Promise', @@ -189,9 +189,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'systemPrompt', summary: 'Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, named prompt variables, and owner-final contributions; the agent loop calls `assemble(context)` once per step.', methods: [ - 'section(section: PromptSection): () => Promise | void', - 'tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise | void', - 'variable(name: string, provider: (context: AssembleContext) => string | undefined): () => Promise | void', + 'section(section: PromptSection): () => void', + 'tools(provider: (context: AssembleContext) => ToolProviderResult): () => void', + 'variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void', 'async assemble(context: AssembleContext = {}): Promise', ], }, @@ -199,9 +199,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'tools', summary: '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.', methods: [ - 'register(definition: ToolDefinition): () => Promise | void', - 'restrict(filter: ToolRestriction): () => Promise | void', - 'guard(guard: ToolGuard): () => Promise | void', + 'register(definition: ToolDefinition): () => void', + 'restrict(filter: ToolRestriction): () => void', + 'guard(guard: ToolGuard): () => void', 'get(name: string, scope?: ScopeKey): ToolDefinition | undefined', 'schemas(scope?: ScopeKey): ToolSchema[]', 'async execute(exec: ToolExecutionInput): Promise', @@ -311,7 +311,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'agent/turn-stop', mode: 'serial', - signature: '\'agent/turn-stop\'(this: Scoped, agent: Agent, turn: number): Promise | ContinuationStop | undefined', + signature: '\'agent/turn-stop\'(this: Scoped, agent: Agent, turn: number): ContinuationStop | undefined', summary: 'Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` waterfall, any `continue.reason`, and the pending-steering continuation override have been folded.', }, { @@ -442,9 +442,9 @@ export const EVENT_API: readonly EventApiEntry[] = [ }, { name: 'tools/result', - mode: 'parallel', - signature: '\'tools/result\'(this: Scoped, exec: Readonly, result: Readonly): Promise | void', - summary: 'Awaited notification of the authoritative FINAL tool outcome, after the complete pre/execute/post pipeline, final lossless-JSON validation, and outer error normalization.', + mode: 'emit', + signature: '\'tools/result\'(this: Scoped, exec: Readonly, result: Readonly): undefined', + summary: 'Synchronous notification of the authoritative FINAL tool outcome, after the complete pre/execute/post pipeline, final lossless-JSON validation, and outer error normalization.', }, { name: 'workflow/agent-end', diff --git a/packages/cordis/tool-cordis/src/guard.ts b/packages/cordis/tool-cordis/src/guard.ts index 10c2255141..3e1c70e461 100644 --- a/packages/cordis/tool-cordis/src/guard.ts +++ b/packages/cordis/tool-cordis/src/guard.ts @@ -230,7 +230,7 @@ export function sandboxDefineTool(options: Parameters[0]): To * @param tool - a definition produced by {@link sandboxDefineTool}; anything else is rejected. * @returns the registry disposer for the registration. */ -export function sandboxRegisterTool(ctx: Context, tool: unknown): () => Promise | void { +export function sandboxRegisterTool(ctx: Context, tool: unknown): () => void { assertDynamicTool(tool) return ctx.tools.register(tool) } @@ -264,7 +264,7 @@ function sandboxTools(ctx: Context): Record { // view for today's global mounts, its agent's view if a mount ever runs // under an agent scope. return { - register: (tool: unknown): (() => Promise | void) => sandboxRegisterTool(ctx, tool), + register: (tool: unknown): (() => void) => sandboxRegisterTool(ctx, tool), schemas: () => ctx.tools.schemas(scopeOf(ctx)), get: (name: string) => ctx.tools.schemas(scopeOf(ctx)).find(schema => schema.name === name), } diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 290c5d90bf..cfe4976af4 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -10,7 +10,7 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. -- `ctx.agents.register(agent: Agent): () => Promise | void` — record an **already-constructed** agent. Disposed with the calling fiber. +- `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber. - Advanced ordered lifecycle: `enter(agent): () => void` performs the authoritative ID collision check and inserts without announcing; `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`. - `ctx.agents.get(id: AgentId): Agent | undefined` - `ctx.agents.list(): Agent[]` @@ -19,7 +19,7 @@ The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh- Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. The registry canonicalizes an already traced Service to its concrete target and re-traces each call through the caller's context; this avoids nested Cordis shadows while passing an explicit caller-bound `ownerCtx` to plain factories. -- `ctx.agents.setFactory(factory: AgentFactory): () => Promise | void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose. +- `ctx.agents.setFactory(factory: AgentFactory): () => void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose. - `ctx.agents.create(options: CreateAgentOptions): Promise` — create a session and agent, await optional setup while unpublished, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered. - `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured. diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 42b9d13e35..b77e3e8a60 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -228,7 +228,7 @@ export class AgentRegistry extends Service { * Cordis effect disposer (single-shot): composite (generator) effects may * yield it directly — exact identity nests the teardown in order. */ - setFactory(factory: AgentFactory): () => Promise | void { + setFactory(factory: AgentFactory): () => void { const dispose = this.ctx.effect(() => { if (this.factory !== undefined) throw new Error('an agent factory is already registered') // Avoid stacking two Cordis shadow layers when a caller passes a Service @@ -242,6 +242,7 @@ export class AgentRegistry extends Service { // caller's composite effect can yield it for in-order teardown; the // loop's constructor effect returns it directly, identity-nesting the // registration under that effect. + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return dispose } @@ -305,11 +306,12 @@ export class AgentRegistry extends Service { * owner unload, unregistering the agent (and emitting `agent/disposed`) * while its final turn is still draining. */ - register(agent: Agent): () => Promise | void { + register(agent: Agent): () => void { const dispose = this.ctx.effect(function* (this: AgentRegistry) { yield this.enter(agent) this.announce(agent) }.bind(this), 'agents.register()') + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return dispose } diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 9350a5cc03..f85cf3db2c 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -575,8 +575,7 @@ declare module 'cordis' { * 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. + * becoming another step or turn. * @param agent - the agent whose composed continuation outcome may be stopped. * @param turn - the turn at its terminal-stop checkpoint. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered @@ -586,7 +585,7 @@ declare module 'cordis' { * `scopeTarget`/`agentEvents`. * @mode serial */ - 'agent/turn-stop'(this: Scoped, agent: Agent, turn: number): Promise | ContinuationStop | undefined + 'agent/turn-stop'(this: Scoped, agent: Agent, turn: number): ContinuationStop | undefined // ---- error notifications (emit) ---- /** diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index cd40ba59e3..5d541d56a8 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -1,8 +1,9 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, expectTypeOf, it } from 'vitest' import { Context, Service, symbols } from 'cordis' +import type { Events } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { AgentId, agentEvents } from '@deepseek-ai/dsh-agent' -import type { Agent, AgentFactory, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' function stubAgent(rawId: string): Agent { const id = AgentId(rawId) @@ -21,6 +22,14 @@ function stubAgent(rawId: string): Agent { } describe('AgentRegistry', () => { + it('keeps terminal stop decisions synchronous', () => { + type TurnStopListener = Events['agent/turn-stop'] + type AsyncTurnStopListener = () => Promise + + expectTypeOf().not.toExtend() + expectTypeOf>().toEqualTypeOf() + }) + it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) @@ -34,7 +43,7 @@ describe('AgentRegistry', () => { expect(ctx.agents.list()).toEqual([agent]) expect(() => ctx.agents.register(stubAgent('a1'))).toThrow(/already registered/) - await dispose() + dispose() expect(ctx.agents.get(agent.id)).toBeUndefined() expect(lifecycle).toEqual(['created:a1', 'disposed:a1']) }) @@ -65,7 +74,7 @@ describe('AgentRegistry', () => { const dispose = ctx.agents.register(stubAgent('contained')) await Promise.resolve() - await dispose() + dispose() await Promise.resolve() expect(heard).toEqual(['contained']) diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 270a016cf3..009dc6b3fe 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -13,9 +13,9 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool- ### Public API -- `ctx.systemPrompt.section(section: PromptSection): () => Promise | void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. Duplicate names within one layer and non-finite orders throw. `ownerFinal: true` restores this section's canonical definition after the complete waterfall and reserves a global section against scoped shadows. Disposed with the calling fiber. -- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise | void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames?, ownerFinalNames? }`: `schemas` is the post-restriction visible set; `knownNames` is the pre-restriction universe used by `toolOrder`; `ownerFinalNames` makes those tools' canonical presence or absence survive the waterfall. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber. -- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => Promise | void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber. +- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. Duplicate names within one layer and non-finite orders throw. `ownerFinal: true` restores this section's canonical definition after the complete waterfall and reserves a global section against scoped shadows. Disposed with the calling fiber. +- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames?, ownerFinalNames? }`: `schemas` is the post-restriction visible set; `knownNames` is the pre-restriction universe used by `toolOrder`; `ownerFinalNames` makes those tools' canonical presence or absence survive the waterfall. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber. +- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber. - `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform seam. Runs through the scope-filtered `system-prompt/assemble` waterfall, then restores owner-final contributions from a private pre-waterfall snapshot. Restored entries keep canonical relative order without undoing listener reordering of ordinary entries. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name. ### Live events diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index b1946c59e1..0adbce0184 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -434,7 +434,7 @@ export class SystemPrompt extends Service { * Cordis effect disposer (single-shot): composite (generator) effects may * yield it directly — exact identity nests the teardown in order. */ - section(section: PromptSection): () => Promise | void { + section(section: PromptSection): () => void { if (!Number.isFinite(section.order)) { throw new TypeError(`prompt section "${section.name}" order must be a finite number`) } @@ -481,8 +481,9 @@ export class SystemPrompt extends Service { // effect that owns a teardown ORDER must be able to yield THIS function — // cordis nests a disposer out of the fiber's concurrent sibling list by // exact function identity, so a wrapper would silently break the nesting - // (the agents.register() lesson). Fire-and-forget callers may still - // discard the (always-resolved) promise. + // (the agents.register() lesson). Cleanup is synchronous because this + // registration installs only synchronous state and notifications. + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return dispose } @@ -502,7 +503,7 @@ export class SystemPrompt extends Service { * Cordis effect disposer (single-shot): composite (generator) effects may * yield it directly — exact identity nests the teardown in order. */ - tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise | void { + tools(provider: (context: AssembleContext) => ToolProviderResult): () => void { const scope = scopeOf(this.ctx) const dispose = this.ctx.effect(function* (this: SystemPrompt) { const layer = scope === undefined @@ -527,8 +528,9 @@ export class SystemPrompt extends Service { // effect that owns a teardown ORDER must be able to yield THIS function — // cordis nests a disposer out of the fiber's concurrent sibling list by // exact function identity, so a wrapper would silently break the nesting - // (the agents.register() lesson). Fire-and-forget callers may still - // discard the (always-resolved) promise. + // (the agents.register() lesson). Cleanup is synchronous because this + // registration installs only synchronous state and notifications. + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return dispose } @@ -550,7 +552,7 @@ export class SystemPrompt extends Service { * Cordis effect disposer (single-shot): composite (generator) effects may * yield it directly — exact identity nests the teardown in order. */ - variable(name: string, provider: (context: AssembleContext) => string | undefined): () => Promise | void { + variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void { if (!VARIABLE_NAME.test(name)) { throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`) } @@ -581,8 +583,9 @@ export class SystemPrompt extends Service { // effect that owns a teardown ORDER must be able to yield THIS function — // cordis nests a disposer out of the fiber's concurrent sibling list by // exact function identity, so a wrapper would silently break the nesting - // (the agents.register() lesson). Fire-and-forget callers may still - // discard the (always-resolved) promise. + // (the agents.register() lesson). Cleanup is synchronous because this + // registration installs only synchronous state and notifications. + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return dispose } diff --git a/packages/core/system-prompt/tests/scoped.spec.ts b/packages/core/system-prompt/tests/scoped.spec.ts index 329a66813f..4becf5192a 100644 --- a/packages/core/system-prompt/tests/scoped.spec.ts +++ b/packages/core/system-prompt/tests/scoped.spec.ts @@ -116,7 +116,7 @@ describe('scoped tool providers and toolOrder × restriction', () => { const ctx = await mount() const scope = await mintScope(ctx, 'child') const dispose = scope.ctx.systemPrompt.tools(() => ({ schemas: [schema('scoped_tool')] })) - await dispose() + dispose() const after = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }) expect(after.tools.map(t => t.name)).toEqual([]) // Re-registering through the same scope starts a fresh layer. diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index 3417364613..dfdfef42dc 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -316,7 +316,7 @@ describe('SystemPrompt', () => { // registration emits change expect(changeCount).toBe(1) - await dispose() + dispose() // disposal emits change again expect(changeCount).toBe(2) }) @@ -341,7 +341,7 @@ describe('SystemPrompt', () => { const dispose = ctx.systemPrompt.section({ name: 'direct', order: 0, text: 'direct section' }) expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(1) - await dispose() + dispose() expect(contributed(await ctx.systemPrompt.assemble())).toHaveLength(0) }) @@ -352,7 +352,7 @@ describe('SystemPrompt', () => { const dispose = ctx.systemPrompt.tools(() => ({ schemas: [{ name: 'direct-tool', description: '', parameters: {} }] })) expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(1) - await dispose() + dispose() expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(0) }) @@ -370,7 +370,7 @@ describe('SystemPrompt', () => { // A provider returning undefined records "registered but no value here". expect((await ctx.systemPrompt.assemble()).variables).toEqual({ who: undefined }) - await dispose() + dispose() expect(changeCount).toBe(2) expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) }) diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index a50e35a4b4..f4416a312c 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -15,11 +15,11 @@ tools: ### Public API -- `ctx.tools.register(definition: ToolDefinition): () => Promise | void` Register a trusted typed same-process definition. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. `timeoutMs`, when present, must be positive and finite. `ownerFinal: true` makes the tool's canonical wire presence or absence survive prompt assembly listeners. Disposed with the calling fiber. -- `ctx.tools.restrict(filter: ToolRestriction): () => Promise | void` Scoped-only (throws on a plain context): mask the global end-capability surface for the calling agent — `allow` keeps only the listed global tools, `deny` removes them; multiple restrictions intersect; scope-local registrations are merged afterward. The readonly arrays compile once into private sets. Every listed name must exist in the current pre-restriction global registry; scope-local, unknown, and reserved `run_code` names fail loudly. A deny-list admits a later global tool unless it names that tool; an allow-list excludes later names; neither filters a later scope-local registration. `restrict({})` rejects. This is live registration composition, not a parent-derived authority ceiling; see the [agent-scope security non-goal](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals). +- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. `timeoutMs`, when present, must be positive and finite. `ownerFinal: true` makes the tool's canonical wire presence or absence survive prompt assembly listeners. Disposed with the calling fiber. +- `ctx.tools.restrict(filter: ToolRestriction): () => void` Scoped-only (throws on a plain context): mask the global end-capability surface for the calling agent — `allow` keeps only the listed global tools, `deny` removes them; multiple restrictions intersect; scope-local registrations are merged afterward. The readonly arrays compile once into private sets. Every listed name must exist in the current pre-restriction global registry; scope-local, unknown, and reserved `run_code` names fail loudly. A deny-list admits a later global tool unless it names that tool; an allow-list excludes later names; neither filters a later scope-local registration. `restrict({})` rejects. This is live registration composition, not a parent-derived authority ceiling; see the [agent-scope security non-goal](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals). - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)). -- `ctx.tools.guard(guard: ToolGuard): () => Promise | void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber. +- `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber. - `ctx.tools.execute(exec: ToolExecutionInput): Promise` Assign a fresh opaque correlation token, losslessly materialize and deep-freeze arguments once at the model/tool boundary, then run the call through `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute`. Invalid arguments normalize through the same authoritative result path without reaching policy or the body. The final outcome is independently materialized and deep-frozen once before `tools/result`. Optional `signal` remains the operational field an around-dispatch wrapper may replace. ### Injected services diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 84a1ec3c51..ab28324503 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -147,7 +147,7 @@ declare module 'cordis' { */ 'tools/post-execute'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise /** - * Awaited notification of the authoritative FINAL tool outcome, after the + * Synchronous 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 @@ -158,9 +158,9 @@ declare module 'cordis' { * `exec.agent`, using the same carrier as the pipeline. * @param exec - the execution object that traversed the pipeline. * @param result - a deep-frozen snapshot of the final returned result. - * @mode parallel + * @mode emit */ - 'tools/result'(this: Scoped, exec: Readonly, result: Readonly): Promise | void + 'tools/result'(this: Scoped, exec: Readonly, result: Readonly): undefined /** * A tool was registered or unregistered, or a scoped restriction changed * (the available tool set changed — possibly for one scope only). An @@ -623,7 +623,7 @@ export class ToolRegistry extends Service { * Cordis effect disposer (single-shot): composite (generator) effects may * yield it directly — exact identity nests the teardown in order. */ - register(definition: ToolDefinition): () => Promise | void { + register(definition: ToolDefinition): () => void { const scope = scopeOf(this.ctx) const name = definition.name const timeoutMs = definition.timeoutMs @@ -669,8 +669,9 @@ export class ToolRegistry extends Service { // effect that owns a teardown ORDER must be able to yield THIS function — // cordis nests a disposer out of the fiber's concurrent sibling list by // exact function identity, so a wrapper would silently break the nesting - // (the agents.register() lesson). Fire-and-forget callers may still - // discard the (always-resolved) promise. + // (the agents.register() lesson). Cleanup is synchronous because this + // registration installs only synchronous state and notifications. + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return dispose } @@ -697,7 +698,7 @@ export class ToolRegistry extends Service { * Cordis effect disposer (single-shot): composite (generator) effects may * yield it directly — exact identity nests the teardown in order. */ - restrict(filter: ToolRestriction): () => Promise | void { + restrict(filter: ToolRestriction): () => void { const scope = scopeOf(this.ctx) if (scope === undefined) { throw new Error('tools.restrict() requires a scoped context (agent.ctx): a context-global restriction would mask every agent — deny the tool for the intended agent instead') @@ -737,8 +738,9 @@ export class ToolRegistry extends Service { // effect that owns a teardown ORDER must be able to yield THIS function — // cordis nests a disposer out of the fiber's concurrent sibling list by // exact function identity, so a wrapper would silently break the nesting - // (the agents.register() lesson). Fire-and-forget callers may still - // discard the (always-resolved) promise. + // (the agents.register() lesson). Cleanup is synchronous because this + // registration installs only synchronous state and notifications. + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return dispose } @@ -752,7 +754,7 @@ export class ToolRegistry extends Service { * @param guard - synchronous check; a returned string denies the execution. * @returns the exact disposer that unregisters the guard. */ - guard(guard: ToolGuard): () => Promise | void { + guard(guard: ToolGuard): () => void { const scope = scopeOf(this.ctx) const registration = { guard } const dispose = this.ctx.effect(function* (this: ToolRegistry) { @@ -763,6 +765,7 @@ export class ToolRegistry extends Service { if (scope !== undefined && layer.size === 0) this.scopedGuards.delete(scope) } }.bind(this), 'tools.guard()') + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return dispose } @@ -939,7 +942,7 @@ export class ToolRegistry extends Service { } catch (error: unknown) { execution = { ...base, arguments: undefined } const result = this.materializeFinalResult(toolErrorResult(callId, error)) - await this.notifyResult(execution, result) + this.notifyResult(execution, result) return result } let result: ToolExecutionResult @@ -950,7 +953,7 @@ export class ToolRegistry extends Service { // waterfall machinery becomes an isError result, never a turn failure. result = this.materializeFinalResult(toolErrorResult(execution.callId, error)) } - await this.notifyResult(execution, result) + this.notifyResult(execution, result) return result } @@ -1018,20 +1021,20 @@ export class ToolRegistry extends Service { } /** Notify final-result observers without giving them a mutation/error channel into the outcome. */ - private async notifyResult(exec: ToolExecution, result: ToolExecutionResult): Promise { + private notifyResult(exec: ToolExecution, result: ToolExecutionResult): void { // The pipeline is over: freeze the remaining mutable signal slot so every // observer sees the SAME WeakMap-keyable execution without a mutation race. Object.freeze(exec) - const callbacks = this.ctx.events.dispatch('parallel', [ + const callbacks = this.ctx.events.dispatch('emit', [ scopeTarget(this, exec.agent), 'tools/result', exec, result, ]) - await Promise.all(callbacks.map(async (callback) => { + for (const callback of callbacks) { try { - await callback(exec, result) + callback(exec, result) } catch (error: unknown) { this.ctx.logger.warn(`tool "${exec.name}" (${exec.callId}): tools/result observer failed: ${errorMessage(error)}`) } - })) + } } /** diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 83dedbdb00..3333b3e1f9 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -171,7 +171,7 @@ describe('mode-aware wire contribution', () => { expect(result.isError).toBe(false) expect(result.content).toEqual([{ type: 'text', text: 'echo' }]) - await lift() + lift() const unrestricted = await systemPrompt.assemble({ scope: agent }) expect(unrestricted.tools.map(tool => tool.name)).toEqual(mode === 'code' ? [RUN_CODE_NAME] diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index 991d420054..e2445fc555 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -1,5 +1,6 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from 'cordis' +import type { Events } from 'cordis' import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -50,6 +51,14 @@ async function run(ctx: Context, name: string, agent?: Agent): Promise { } describe('scoped tool registration', () => { + it('keeps final-result observers synchronous', () => { + type ToolResultListener = Events['tools/result'] + type AsyncToolResultListener = () => Promise + + expectTypeOf().not.toExtend() + expectTypeOf>().toEqualTypeOf() + }) + it('files a scoped tool in its layer: visible/executable for that scope only', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'a') @@ -177,7 +186,7 @@ describe('restrict()', () => { const liftAllow = scope.ctx.tools.restrict({ allow: ['a', 'b'] }) scope.ctx.tools.restrict({ deny: ['b'] }) expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['a']) - await liftAllow() + liftAllow() // The deny remains after the allow-list is lifted. expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['a', 'c']) }) @@ -257,7 +266,7 @@ describe('scoped execution dispatch', () => { expect(await run(ctx, 't', other)).toBe('ran:t') expect(bodyCalls).toBe(1) - await liftFirst() + liftFirst() expect(await run(ctx, 't', key)).toBe('Error: terminal policy') await scope.dispose() expect(await run(ctx, 't', key)).toBe('ran:t') @@ -607,7 +616,7 @@ describe('scoped execution dispatch', () => { const result = await ctx.tools.execute({ callId: CallId('final'), name: 't', arguments: {}, agent: key }) expect(result).toMatchObject({ isError: true, content: [{ type: 'text', text: 'outer failure' }] }) expect(seen).toEqual([true, true]) - expect(dispatchModes).toEqual(['parallel']) + expect(dispatchModes).toEqual(['emit']) expect(warn).toHaveBeenCalledOnce() expect(String(warn.mock.calls[0]?.[0])).toContain('') }) diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 4c52ba7910..e74766c699 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -677,7 +677,7 @@ describe('ToolRegistry', () => { const dispose = ctx.tools.register({ ...echoTool, name: 'disposable' }) expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo', 'disposable']) - await dispose() + dispose() expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo']) }) @@ -698,7 +698,7 @@ describe('ToolRegistry', () => { // exposed exactly once (the duplicate-name check is not wedged). const dispose = ctx.tools.register(echoTool) expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo']) - await dispose() + dispose() expect(ctx.tools.get('echo')).toBeUndefined() }) diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index ebfac17e2e..1f0c8d552b 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -8,10 +8,10 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ### Public API -- `ctx.skills.registerProvider(provider): () => Promise | void` Registers a readonly provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registry borrows the provider object and invokes its methods directly. The registration is effect-scoped and HMR-safe, and the exact Cordis disposer supports ordered composite teardown. +- `ctx.skills.registerProvider(provider): () => void` Registers a readonly provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registry borrows the provider object and invokes its methods directly. The registration is effect-scoped and HMR-safe, and the exact Cordis disposer supports ordered composite teardown. - `ctx.skills.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns model-invocable summaries for the current workspace, merged across providers and sorted by name. - `ctx.skills.get(name, { cwd?, signal? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it, including disabled-for-model skills. -- `ctx.skills.register(skill): () => Promise | void` Registers a readonly runtime embedded skill, adding `provider: "runtime"` when omitted. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown. +- `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill, adding `provider: "runtime"` when omitted. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown. ### Config diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index f2ece2379f..5ef3f78465 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -187,7 +187,7 @@ export class SkillService extends Service { * @returns the exact Cordis effect disposer that unregisters this provider; * composite effects may yield it directly to preserve teardown ordering. */ - registerProvider(provider: SkillProvider): () => Promise | void { + registerProvider(provider: SkillProvider): () => void { const name = provider.name if (name === RUNTIME_PROVIDER) { throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`) @@ -210,6 +210,7 @@ export class SkillService extends Service { } ctx.emit('skill/provider-added', provider) }, 'skills.registerProvider()') + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return dispose } @@ -225,7 +226,7 @@ export class SkillService extends Service { * contribution and invalidates caches; composite effects may yield it * directly to preserve teardown ordering. */ - register(skill: SkillRegistration): () => Promise | void { + register(skill: SkillRegistration): () => void { validateRuntimeSkill(skill) const existing = this.runtime.get(skill.name) if (existing !== undefined) { @@ -245,6 +246,7 @@ export class SkillService extends Service { invalidateCache() } }, 'skills.register()') + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return dispose } diff --git a/packages/skill/skill/tests/skill.spec.ts b/packages/skill/skill/tests/skill.spec.ts index 90bf1ea983..8195d418cb 100644 --- a/packages/skill/skill/tests/skill.spec.ts +++ b/packages/skill/skill/tests/skill.spec.ts @@ -103,7 +103,7 @@ describe('SkillService registry', () => { }, })).toThrow('reserved') - await disposeMemory() + disposeMemory() expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['same-rank-skill', 'shadowed']) }) @@ -538,7 +538,7 @@ describe('SkillService registry', () => { path: 'memory://runtime-skill', metadata: { owner: 'tests' }, }) - await disposeRuntime() + disposeRuntime() await ctx.skills.list({ cwd: '/tmp/first-cache-key' }) await ctx.skills.list({ cwd: '/tmp/second-cache-key' }) @@ -615,7 +615,7 @@ describe('SkillService registry', () => { const pending = ctx.skills.list() await started - await dispose() + dispose() release?.() expect(await pending).toEqual([]) @@ -673,9 +673,9 @@ describe('SkillService registry', () => { const disposeFirst = ctx.skills.register({ name: 'same-skill', description: 'First', source: 'runtime', content: 'first' }) const disposeSecond = ctx.skills.register({ name: 'same-skill', description: 'Second', source: 'runtime', content: 'second' }) - await disposeSecond() + disposeSecond() expect((await ctx.skills.get('same-skill'))?.description).toBe('First') - await disposeFirst() + disposeFirst() expect(await ctx.skills.get('same-skill')).toBeUndefined() }) }) diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index f81d82ef62..60a2291788 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -149,7 +149,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut // The capture COMMIT observes the immutable, authoritative result after the // complete pipeline and outer error normalization. This notification cannot // transform the outcome, so there is no wrapper outside the commit verdict. - childCtx.on('tools/result', function (this: unknown, exec, result): void { + childCtx.on('tools/result', function (this: unknown, exec, result) { if (exec.name === STRUCTURED_OUTPUT_TOOL) { const entry = staged.get(exec) if (entry === undefined) return diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index f989d727c8..636b2b6161 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -742,7 +742,7 @@ describe('in-process structured output', () => { const run = await ctx.subagents.start('spawn', structuredRequest(parent)) // A backend hot-reload mid-run must not unregister the capture tool out // from under the live child: the registration rides the CHILD's fiber. - await disposeProvider() + disposeProvider() const result = await run.result expect(result.structured).toEqual({ answer: 4 }) const child = ctx.agents.get(run.id)! diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 6332a0a458..4635de9697 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -134,8 +134,9 @@ export class SubagentService extends Service { * @param provider - the trusted provider implementation. * @returns the exact Cordis effect disposer. */ - registerProvider(provider: SubagentProvider): () => Promise | void { + registerProvider(provider: SubagentProvider): () => void { const name = provider.name + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return this.ctx.effect(function* (this: SubagentService) { if (this.providers.has(name)) { throw new SubagentError(`a subagent provider named "${name}" is already registered`, 'DUPLICATE_PROVIDER') diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 23c2caf445..3d10b425ed 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -74,7 +74,7 @@ describe('SubagentService', () => { await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) expect(provider.startCount).toBe(1) - await dispose() + dispose() expect(added).toEqual(['alpha']) expect(removed).toEqual(['alpha']) expect(subagents.getProvider('alpha')).toBeUndefined() @@ -213,7 +213,7 @@ describe('SubagentService', () => { ctx.on('subagent/provider-removed', name => void heard.push(name)) const dispose = subagents.registerProvider(new StubProvider('contained')) - await dispose() + dispose() await Promise.resolve() expect(heard).toEqual(['contained']) expect(warnings.some(message => message.includes('sync boom'))).toBe(true) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 322a76e825..d07e6726dd 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -212,7 +212,7 @@ export function apply(ctx: Context, config: Config): void { // available — deriving the wording from THAT provider — and unregister it // when the provider goes away, so the description can never outlive or // predate the provider it describes. - let disposeTool: (() => Promise | void) | undefined + let disposeTool: (() => void) | undefined const mount = (provider: SubagentProvider): void => { const wording = providerWording(provider.inheritsParentContext) disposeTool = ctx.tools.register(defineTool({ @@ -283,7 +283,7 @@ export function apply(ctx: Context, config: Config): void { }) ctx.on('subagent/provider-removed', (name) => { if (name !== config.provider || disposeTool === undefined) return - void disposeTool() + disposeTool() disposeTool = undefined }) const present = ctx.subagents.getProvider(config.provider) diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index c4c8186f38..0db3be2f6b 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -262,8 +262,8 @@ const DYNAMIC_EVENT_DISPATCHERS: Array<{ event: string; pkg: string; method: str // Session disposal uses direct callback resolution so teardown contains each // synchronous throw and returned-promise rejection independently. { event: 'session/disposed', pkg: 'session', method: 'events.dispatch' }, - // tools/result uses ctx.events.dispatch directly so the registry can await - // every observer while containing each callback independently. + // tools/result uses ctx.events.dispatch directly so the registry can invoke + // every synchronous observer while containing each callback independently. { event: 'tools/result', pkg: 'tools', method: 'events.dispatch' }, // Subagent lifecycle events intentionally bypass ctx.emit and call // ctx.events.dispatch directly so one throwing listener cannot starve later @@ -779,7 +779,7 @@ function renderToolPipeline(): string { ` fsGate["${mermaidCode('fs/write-intent')} or ${mermaidCode('fs/edit-intent')}
tool-fs mutations only"]`, ` owned["Tool-owned session events
${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`, ` post["${mermaidCode('tools/post-execute')} waterfall
accept, block, replace, add context"]`, - ` final["${mermaidCode('tools/result')} parallel notification
frozen authoritative outcome"]`, + ` final["${mermaidCode('tools/result')} synchronous notification
frozen authoritative outcome"]`, ' context["Buffered additionalContext
context/message after all tool results"]', ` toolResult["Session event: ${mermaidCode('tool/result')}
single model-facing outcome"]`, ' allResults["All calls in the step settled
and tool/result events recorded"]', @@ -808,7 +808,7 @@ function renderToolPipeline(): string { ' allResults --> context', '```', '', - 'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and approval-triggering permission policy enter through the generic pre/post tool waterfalls, while `ctx.approval` resolves an `ask` before the monotonic guards; owner policy that must not be reordered uses registered guards; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. The awaited `tools/result` notification observes the immutable final outcome after every transform, lossless-JSON validation, and outer error normalization. That split lets the same hooks observe bash, fs, web, todo, skill, and subagent calls without coupling those tools to one policy service. Code Mode rides the whole pipeline twice over: `run_code` is the reserved registry-owned transport whose body enters the pipeline, and each tool call its program makes re-enters `ctx.tools.execute()` — serialized one at a time, carrying the outer execution\'s opaque token for correlation, and logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call\'s `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency).', + 'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and approval-triggering permission policy enter through the generic pre/post tool waterfalls, while `ctx.approval` resolves an `ask` before the monotonic guards; owner policy that must not be reordered uses registered guards; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. The synchronous `tools/result` notification observes the immutable final outcome after every transform, lossless-JSON validation, and outer error normalization. That split lets the same hooks observe bash, fs, web, todo, skill, and subagent calls without coupling those tools to one policy service. Code Mode rides the whole pipeline twice over: `run_code` is the reserved registry-owned transport whose body enters the pipeline, and each tool call its program makes re-enters `ctx.tools.execute()` — serialized one at a time, carrying the outer execution\'s opaque token for correlation, and logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call\'s `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency).', '', ...maintenanceFooter(maintenance), ].join('\n') From e04ec0734525e184f2210d1bee126c269973858d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:09:41 +0800 Subject: [PATCH 63/64] refactor(core): remove owner-final assembly machinery --- docs/config-catalog.md | 4 +- docs/cookbook/extension-cookbook.md | 8 +- docs/cordis-catalog/events.md | 8 +- docs/cordis-catalog/services.md | 8 +- docs/core-data-structures/core.md | 4 +- docs/core-data-structures/system-prompt.md | 8 +- docs/core-data-structures/tools.md | 6 - docs/event-producer-consumer.md | 4 +- .../2026-07-12-agent-scope-runtime-design.md | 45 ++++--- .../feature/2026-06-15-code-mode.md | 14 ++- .../feature/2026-07-05-dynamic-workflows.md | 2 +- .../feature/2026-07-06-explicit-tool-order.md | 6 +- docs/tool-catalog.md | 4 +- examples/README.md | 2 +- examples/coding-agent/README.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 8 +- packages/core/agent/README.md | 2 +- packages/core/system-prompt/README.md | 15 ++- packages/core/system-prompt/src/index.ts | 112 ++++------------- .../core/system-prompt/tests/scoped.spec.ts | 42 ------- .../system-prompt/tests/system-prompt.spec.ts | 61 --------- packages/core/tools/README.md | 12 +- packages/core/tools/src/code-mode.ts | 1 - packages/core/tools/src/index.ts | 53 +++----- packages/core/tools/src/schema.ts | 3 - packages/core/tools/tests/code-mode.spec.ts | 20 ++- packages/core/tools/tests/scoped.spec.ts | 29 ----- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent-inprocess/src/structured.ts | 15 +-- .../tests/structured.spec.ts | 117 +----------------- scripts/gen-tool-catalog.ts | 2 +- 31 files changed, 143 insertions(+), 476 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 63d1303fc6..1e8bd9e508 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -777,7 +777,7 @@ export interface Config { } ``` -Source: [`packages/core/system-prompt/src/index.ts:257`](../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:225`](../packages/core/system-prompt/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -958,7 +958,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:407`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:401`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-user-approval` diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index ac62cdd75b..e2677e463a 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -89,19 +89,21 @@ Three complete examples load their plugin trees from `cordis.yml`: [`examples/ec Every product feature maps to a listener on a documented extension seam — the microkernel claim made checkable ([microkernel RFC](../rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md)). No row modifies the loop. +`system-prompt/assemble` is an expert cooperative whole-assembly transform: its returned assembly is authoritative, so listener authors own preserving active Code Mode and structured-output protocol contributions. Prefer `ctx.tools.restrict()` for tool filtering that must stay aligned across presentation, lookup, and execution. + | Product feature | Plugin mechanism | |---|---| | Hook system (user + project level) | listeners on `agent/session-start`, `agent/prompt-submit`, `agent/request`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation` — each interception waterfall returns a typed Decision; the `dsh-hooks-claude` / `dsh-hooks-codex` bridges map hook config files onto these seams | | `/goal` | force-continue via `agent/turn-continuation` + `steer()` reminders | | `/loop` | on the `turn/end` session event, `send()` the next iteration; or force-continue | -| Dynamic workflow | `ctx.workflows` + the worker-thread engine + the `workflow` tool; structured in-process children enforce output with scoped owner-final prompt/tool contributions, a monotonic tool guard, final `tools/result` commit (including enclosing `run_code`), and terminal `agent/turn-stop` | +| Dynamic workflow | `ctx.workflows` + the worker-thread engine + the `workflow` tool; structured in-process children enforce output with scoped prompt/tool registrations, 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; a protocol owner sets `ownerFinal: true` on the section or tool only when canonical presence is a correctness invariant | +| System prompt configurability | `ctx.systemPrompt.section()` with ordering and scope-local shadowing | | 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 | -| ToolSearch / progressive disclosure | filter ordinary capabilities at `system-prompt/assemble` (the loop logs the result as the request header); owner-final transport and correctness contributions retain their canonical presence or absence | +| ToolSearch / progressive disclosure | replace a scoped `ctx.tools.restrict()` registration as the visible set changes; the registry keeps presentation, lookup, and execution aligned | | Tool deadline / retry / metrics | wrap core dispatch with `tools/execute`; a wrapper may replace `exec.signal`, delegate, and inspect the normalized result in one lexical lifetime | | Final tool-result metrics / audit / capture | observe immutable authoritative outcomes with `tools/result`; use `tools/post-execute` instead only when the plugin must transform the result or attach context | | Monotonic terminal turn policy | return `{ action: 'stop' }` from serial `agent/turn-stop`, after continuation and steering have already been folded | diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 67d339df5a..acbd39b8fa 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -355,11 +355,15 @@ Source: [`packages/subagent/subagent/src/index.ts:82`](../../packages/subagent/s 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. +Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by `context.scope` — a listener registered through `agent.ctx` fires only for that agent's assemblies; a plain plugin listener fires for every assembly (scope-less ones included, dispatched subject-less). + +The returned assembly is authoritative. This is an expert composition seam: a listener that removes or replaces another plugin's protocol contribution owns preserving that protocol's invariants. + ```ts cordis-catalog 'system-prompt/assemble'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:45`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:49`](../../packages/core/system-prompt/src/index.ts) ### `system-prompt/change` — emit @@ -369,7 +373,7 @@ A section, tool provider, or variable provider was registered or unregistered (t 'system-prompt/change'(): void ``` -Source: [`packages/core/system-prompt/src/index.ts:55`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:59`](../../packages/core/system-prompt/src/index.ts) ## `tools/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e3fe559d35..e067708712 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -248,7 +248,7 @@ Source: [`packages/subagent/subagent/src/index.ts:123`](../../packages/subagent/ ## `ctx.systemPrompt` — `SystemPrompt` -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). +Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, and named prompt variables; 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): () => void @@ -257,13 +257,13 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine async assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:372`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:340`](../../packages/core/system-prompt/src/index.ts) ## `ctx.tools` — `ToolRegistry` Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute` → `tools/result` pipeline. The registry contributes its schemas into the system-prompt assembly — WHICH schemas is governed by its `mode` config (see Config.mode); under a non-native mode it also owns the reserved `run_code` presentation transport and the `tools:sdk` prompt section. -Two registration layers (`@deepseek-ai/dsh-scope`): a registration through a plain plugin context is GLOBAL (visible to every agent); one through a scoped context (`agent.ctx`) is filed in that scope's layer — visible to that agent alone, disposed with the scope, and SHADOWING a global tool of the same name for that agent (most-specific-wins; within one layer a duplicate name still throws). restrict masks the global layer per scope. One 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. +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 the registry's prompt contribution, get, and execute — and, under a non-native mode, the SDK section and `run_code`'s bindings — so those registry-owned presentation and dispatch paths agree. An expert `system-prompt/assemble` listener may deliberately replace the final wire composition and owns any resulting divergence. ```ts cordis-catalog register(definition: ToolDefinition): () => void @@ -276,7 +276,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:500`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:493`](../../packages/core/tools/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index ecf075b20f..e25e066a08 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -19,7 +19,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [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 | +| [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly | | [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 | @@ -201,7 +201,7 @@ The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition` ### The request envelope: `LlmCallConfig` and the logged header -Requests are built by the loop, not shaped per call: the non-history half of a request — the `EpochHeader`: this call configuration plus the rendered system prompt, the tool schemas in the assembly's canonical order (dsh-system-prompt's `toolOrder` config, lexicographic when unset), and the session prefix — is logged session state (`request/header` snapshot and delta events, [session.md](session.md#the-request-header-events-requestheader-and-requestheader-delta)), so every conversation request is a pure function of the session log ([reconstructability RFC](../rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). The `agent/request` waterfall receives a frozen `LlmCallConfig` seed and a listener returns a replacement to switch model or sampling; the `agent/session-prefix` waterfall — fired once per loop instance — composes the request-only messages fronting the derived history (recorded as the header's `messagePrefix`) — the loop logs whatever the request actually uses. Loop-built requests arrive at `llm/stream` deep-frozen; mutation throws. +Requests are built by the loop, not shaped per call: the non-history half of a request — the `EpochHeader`: this call configuration plus the rendered system prompt, the tool schemas in the authoritative returned assembly order (initially canonicalized by dsh-system-prompt's `toolOrder` config, or lexicographically when unset), and the session prefix — is logged session state (`request/header` snapshot and delta events, [session.md](session.md#the-request-header-events-requestheader-and-requestheader-delta)), so every conversation request is a pure function of the session log ([reconstructability RFC](../rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). The `agent/request` waterfall receives a frozen `LlmCallConfig` seed and a listener returns a replacement to switch model or sampling; the `agent/session-prefix` waterfall — fired once per loop instance — composes the request-only messages fronting the derived history (recorded as the header's `messagePrefix`) — the loop logs whatever the request actually uses. Loop-built requests arrive at `llm/stream` deep-frozen; mutation throws. On the wire, a loop-built request reads in this order: the `system` slot (the rendered prompt assembly) → `messagePrefix` (the frozen session prefix) → the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The prefix never enters the derived history; its durable record is the header events, and the dev invariant recomputes exactly this equation against every loop-built request. diff --git a/docs/core-data-structures/system-prompt.md b/docs/core-data-structures/system-prompt.md index bfd34b3626..4b6f1e6625 100644 --- a/docs/core-data-structures/system-prompt.md +++ b/docs/core-data-structures/system-prompt.md @@ -16,25 +16,23 @@ interface AssembleContext { ## Tool-provider result -`ToolProviderResult.schemas` is the model-visible set for the current assembly. `knownNames` is the provider's pre-restriction name universe used to distinguish a configured-name typo from a known tool that is deliberately hidden in this scope. `ownerFinalNames` identifies tool contributions whose canonical presence or absence survives the assembly waterfall. +`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. ```ts type-equiv interface ToolProviderResult { readonly schemas: readonly ToolSchema[] readonly knownNames?: readonly string[] - readonly ownerFinalNames?: readonly string[] } ``` -## Prompt sections and owner finality +## Prompt sections -`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. +`PromptSection` is a readonly same-process registration contract. Its text may be static or resolved from the current assembly context. ```ts type-equiv interface PromptSection { readonly name: string readonly order: number readonly text: string | ((context: AssembleContext) => string) - readonly ownerFinal?: boolean } ``` diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 46c32a192f..d7e0464f01 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -19,12 +19,6 @@ 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 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 654ccd27c0..a8810a01fe 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -35,8 +35,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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`) | - | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:49`](../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:59`](../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) | diff --git a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index 2aeea1e48f..063cc4738c 100644 --- a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -12,7 +12,7 @@ The implementation needs enough state to preserve real ownership and settlement ## 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 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; the cooperative prompt-assembly result is authoritative; 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: @@ -23,7 +23,7 @@ The design can be skimmed as seven choices: | 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 | +| Compose the model-visible prompt and tool surface | One shared tool view plus the authoritative assembly-waterfall result | | 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. @@ -198,13 +198,13 @@ Tests that fabricate hostile getters, replace typed callbacks after handoff, or 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 +## Tools and prompts: one view, authoritative assembly, committed outcomes -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. +Tool presentation and execution share one private resolver. Prompt assembly remains trusted cooperative composition: registries supply the ordered input, and the assembly waterfall's returned value is exactly what the loop logs and sends. Execution uses separate one-way boundaries only where policy or outcome settlement must be monotonic. ### 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 private resolver applies the current presentation mode, live global restrictions, exact local overlay, and local shadowing. Schemas, lookup, execution, Code Mode SDK generation, and restriction validation 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. @@ -220,21 +220,17 @@ Arguments are materialized once where model/tool JSON enters the pipeline. Pre-, After the last post-execute listener, the registry materializes and freezes the accepted final result once. Every synchronous `tools/result` observer receives that exact committed object, and observer failures are 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 +### The assembly waterfall owns the final model-visible composition -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. +SystemPrompt first resolves the global-plus-agent sections, variables, and tool providers into a deterministic registry contribution. The scope-filtered `system-prompt/assemble` waterfall may then reorder, replace, add, or remove any section, variable, or schema. Its returned assembly is authoritative; there is no later restoration pass and no finality metadata on ordinary prompt sections, tool definitions, or provider results. -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. +This is a trusted same-process extension seam, not an authority boundary. A listener that changes Code Mode's `run_code` schema or `tools:sdk` instructions, or a structured child's capture schema or instruction, owns preserving a coherent protocol in the assembly it returns. ToolRegistry still reserves `run_code` against ordinary tool registration and restriction because those are registry invariants, but assembly middleware remains free to transform the final model-visible surface. -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. +Scope solves the real isolation problem directly. Structured-output contributions register in the child's exact scope, while Code Mode derives its transport and SDK from the same resolved tool view. A second named-protection system would need another ownership and collision rule across arbitrary schema providers—including providers that intentionally contribute duplicate names—without creating a new trust boundary. ### 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. +Structured output combines child-scoped composition with a two-phase execution commit. The child registers its `structured_output` tool and instruction before publication; a trusted assembly listener may transform those ordinary contributions and is responsible for preserving the protocol if the child is expected to complete. 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. @@ -242,20 +238,19 @@ For a Code Mode SDK call, the inner successful result records `{ parentToken, va 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. +Pure Code Mode's registry contribution omits `structured_output` from native wire schemas and exposes it through the generated SDK. The assembly waterfall may deliberately change that presentation; execution still validates against the child-scoped definition, and the listener owns the consistency of any alternate model-visible route it creates. -### Four final boundaries have four narrow powers +### Three execution boundaries are deliberately one-way -Owner-final behavior is not a general priority system. Four domain owners need four different one-way powers after cooperative extension points: +Prompt assembly is intentionally cooperative, but three execution facts need one-way settlement after their extensible stages: | 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. +`ToolGuard` is the monotonic policy registry. Committed 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 @@ -335,7 +330,7 @@ The plugin does not police trusted setup by scanning registries or reject prompt 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. +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, cooperative prompt assembly, structured-output commit in native and Code Mode, async subagent startup and signal cancellation, worker terminal arbitration, ACP settlement, and process teardown. ## Alternatives considered @@ -361,9 +356,9 @@ Parallel sentinels can all mirror whether one operation is live. One transaction 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 +### Restore selected prompt or tool contributions after assembly -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. +A post-waterfall restoration pass would create a second composition rule after the documented cooperative seam. Correctly assigning canonical presence or absence would also require provider ownership and collision rules for arbitrary tool-schema providers, whose ordinary output may contain duplicate names. Scoped registration already supplies the required per-agent isolation, and trusted assembly listeners own the protocol consistency of what they return, so named restoration adds machinery without establishing an independent boundary. ### Remove worker/process lifecycle guards with same-process hardening @@ -379,8 +374,8 @@ The implementation is smaller and its proof follows the same shape as its owners - 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. +- ToolRegistry's presentation, lookup, and execution resolve the same live view before expert assembly transforms, and committed results have one immutable observation point. +- Registry contributions are deterministic inputs, while the trusted assembly waterfall owns the final model-visible composition. - 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. @@ -388,6 +383,8 @@ The implementation is smaller and its proof follows the same shape as its owners 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. +A trusted `system-prompt/assemble` listener can remove or replace Code Mode and structured-output protocol pieces. This is deliberate: the listener owns final composition and must preserve any protocol the deployment expects to remain usable. + 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. diff --git a/docs/rfc/implemented/feature/2026-06-15-code-mode.md b/docs/rfc/implemented/feature/2026-06-15-code-mode.md index 16f42104be..60acfa7c88 100644 --- a/docs/rfc/implemented/feature/2026-06-15-code-mode.md +++ b/docs/rfc/implemented/feature/2026-06-15-code-mode.md @@ -16,7 +16,7 @@ Tool presentation belongs to the registry that owns tool visibility: implementin Three decisions, each elaborated in its own section below: -1. **Code Mode is a first-class presentation mode of `ToolRegistry`** (`dsh-tools`), selected by a validated `mode` config: `'native'` (the default, contributing the visible capability schemas), `'code'` (the registry contributes only its reserved `run_code` transport plus a generated SDK `.d.ts` in the system prompt), or `'both'` (native schemas and the transport + SDK). The registry shapes its wire contribution at the source and protects the transport pair through final assembly, so the logged request header records the same presentation the model receives. +1. **Code Mode is a first-class presentation mode of `ToolRegistry`** (`dsh-tools`), selected by a validated `mode` config: `'native'` (the default, contributing the visible capability schemas), `'code'` (the registry contributes only its reserved `run_code` transport plus a generated SDK `.d.ts` in the system prompt), or `'both'` (native schemas and the transport + SDK). The registry shapes its canonical contribution at the source; the cooperative prompt-assembly result remains authoritative, and the logged request header records exactly that returned presentation. 2. **Code execution is a capability seam** — `packages/code-runtime/` contains the interface package `@deepseek-ai/dsh-code-runtime`, which owns `ctx.codeRuntime` ([capability seams](../../implemented/architecture/2026-06-13-capability-seams.md); consumer = `dsh-tools`, with core-consumes-a-seam precedent in `agent-loop` → `dsh-llm`). The runtime knows nothing about tools: it is handed a program and named async bindings, runs the program, and reports `{ value, logs, error? }`. Language and substrate are backend properties, so a future Python or container backend is another implementation package, not a redesign. 3. **The shipped implementation is `@deepseek-ai/dsh-code-runtime-worker`**: one fresh Node worker thread per run, executing the model's TypeScript after type-strip, with bindings bridged over the message port, an empty environment, configurable heap/output/time caps, and hard termination. Its trust posture is bash-equivalent by design — no unsafe-acknowledgement flags — because the harness already ships `dsh-bash-local`, which executes arbitrary model-written shell commands with strictly *more* ambient authority. @@ -24,11 +24,13 @@ 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()`. 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. +**Wire tool list = the registry's contribution before cooperative assembly.** 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 final 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 there, and cannot be named by `ctx.tools.restrict()`. The mode governs this provider's input to assembly; other direct `systemPrompt.tools()` providers own their schemas, and the trusted assembly waterfall owns the returned wire list. **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. The section declares `ownerFinal: true`, which restores its canonical contribution after every assembly listener and reserves the global name against scoped shadows. +**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. + +**Assembly ownership.** `run_code` and `tools:sdk` enter the trusted `system-prompt/assemble` waterfall as normal assembly inputs. A scoped `tools:sdk` section may shadow the global default before dispatch, and a listener may remove or replace either contribution. The waterfall's returned assembly is final, so whoever changes these inputs owns preserving a viable Code Mode protocol when the deployment expects Code Mode to remain usable; no restoration pass overrides deliberate composition. **Codegen.** A pure `jsonSchemaToTs(schema)` module inside `dsh-tools` (sibling of `json-schema.ts` — `schemas()` and the SDK are two projections of the same store) maps the JSON-Schema subset the `defineTool` DSL emits (object/string/number/boolean/array, `properties`, `required`, string `enum` → literal union, nested objects, array `items`, `description` → JSDoc) to a TS type literal. It is **total**: any construct outside that subset (`$ref`, `oneOf`/`anyOf`, `integer`, future MCP shapes, …) degrades to `unknown` without throwing. Because `ToolSchema.name` is an arbitrary string, the SDK is declared as one object constant — `declare const tools: { "some-mcp-tool"(args: …): Promise; bash(args: …): Promise; … }` — quoted keys make every name reachable with no sanitization or alias-collision logic. Typing is advisory (the runtime executes type-stripped JS); the instructions say so. @@ -89,16 +91,16 @@ The design consists of the `dsh-code-runtime` interface package, the `dsh-code-r Shipped surface: - **The seam**: `packages/code-runtime/` — `@deepseek-ai/dsh-code-runtime` (abstract `CodeRuntime`, the vocabulary above, `ctx.codeRuntime`) and `@deepseek-ai/dsh-code-runtime-worker` (the worker-thread backend, every cap a validated config field). Rows in the service map, capability-seams graph, config catalog, and cordis catalog. -- **The registry surface**: `ToolRegistry`'s `mode` config, mode-aware wire contribution, protected `tools:sdk` section and reserved `run_code` transport, `jsonSchemaToTs`/`renderToolsSdk` (exported), the dispatch bridge and `CodeRunFailedError`, and the `tool/code-dispatch` log event (declaration-merged into `SessionEventMap`, regenerated into the persistence catalog; `run_code` in the tool catalog). +- **The registry surface**: `ToolRegistry`'s `mode` config, mode-aware wire contribution, lazy `tools:sdk` section and reserved `run_code` transport, `jsonSchemaToTs`/`renderToolsSdk` (exported), the dispatch bridge and `CodeRunFailedError`, and the `tool/code-dispatch` log event (declaration-merged into `SessionEventMap`, regenerated into the persistence catalog; `run_code` in the tool catalog). - **The composed surface**: the `tools` config forwards through `agent-core` and both app packages (`stdio-agent`, `acp-agent`); `demo:code-mode` boots each UI example's `code-mode.cordis.yml` overlay (the worker runtime + `mode: 'code'` over the base tree); every program sub-dispatch resolves the same scoped capability view and re-enters the complete tool pipeline with an immutable link to its enclosing transport execution. -- **Interactions inherited by deployments**: a `toolOrder` naming native tools rejects every assembly under `'code'` (update or drop the order config when switching modes); restrictions can hide end capabilities but cannot remove the presentation transport; sub-call `additionalContext` is dropped by the bridge (a plural context channel is deferred until a real hook needs it through Code Mode); sub-dispatch stays serialized until tools can declare concurrency safety — the same metadata the native parallel-dispatch TODO waits on. +- **Interactions inherited by deployments**: a `toolOrder` naming native tools rejects every assembly under `'code'` (update or drop the order config when switching modes); restrictions can hide end capabilities but cannot remove the registry-owned presentation transport, while assembly listeners may rewrite the final model-visible surface and own its protocol integrity; sub-call `additionalContext` is dropped by the bridge (a plural context channel is deferred until a real hook needs it through Code Mode); sub-dispatch stays serialized until tools can declare concurrency safety — the same metadata the native parallel-dispatch TODO waits on. ## Testing What the suites pin, per tier: - **Unit — worker runtime** (real workers, no mocks): output/value capture and log-source attribution; error kinds (exception incl. non-erasable syntax, abort, worker-exit under OOM); the two budgets from both sides (a hot loop behind an un-awaited pending dispatch dies at `computeMs` busy time; a program idling on a slow binding outlives `computeMs` and dies only at `maxWallMs`); binding-bridge hostility (junk/forged port traffic incl. non-object messages and forged `log`/`done` cap bypass attempts, unknown names, duplicate ids, post-settlement replies, `__proto__`/`constructor`/`toString` binding names); structured-clone fallback and cap truncation; `env` emptiness verified from inside a program; dispose-awaits-exit. A real-load-path e2e runs the BUILT package under plain `node` so the worker entry resolves both unbuilt (tsx) and built — the published-artifact guard from [docs/testing.md](../../../testing.md). -- **Unit — registry integration**: the codegen table (DSL subset, quoted names, `unknown` degradation, byte-identical determinism); provider contribution per mode (`'native'` capabilities, `'code'` exactly `[run_code]`, `'both'` capabilities + `run_code`); reserved-name, restriction, shadow, assembly-protection, and `toolOrder × mode` invariants; missing-runtime / wrong-language loud failures; full-pipeline and opaque parent-token behavior for sub-dispatches; serialization non-overlap (a probe tool records enter/exit under `Promise.all`); abort aborting the in-flight sub-dispatch and abandoning queued ones; binding rejection on `isError` and on JSON-unrepresentable arguments; `CodeRunFailedError` → structured `isError` carrying kind + logs; `tool/code-dispatch` payloads (JSON-normalized arguments identical to what dispatched); `deriveMessages()` ignoring the event; sub-call `additionalContext` suppression; HMR safety. +- **Unit — registry integration**: the codegen table (DSL subset, quoted names, `unknown` degradation, byte-identical determinism); provider contribution per mode (`'native'` capabilities, `'code'` exactly `[run_code]`, `'both'` capabilities + `run_code`); reserved-name, restriction, scoped shadowing, authoritative assembly transformation, and `toolOrder × mode` invariants; missing-runtime / wrong-language loud failures; full-pipeline and opaque parent-token behavior for sub-dispatches; serialization non-overlap (a probe tool records enter/exit under `Promise.all`); abort aborting the in-flight sub-dispatch and abandoning queued ones; binding rejection on `isError` and on JSON-unrepresentable arguments; `CodeRunFailedError` → structured `isError` carrying kind + logs; `tool/code-dispatch` payloads (JSON-normalized arguments identical to what dispatched); `deriveMessages()` ignoring the event; sub-call `additionalContext` suppression; HMR safety. - **e2e (with-key, self-skips)**: a real model under `mode: 'code'` composes two bash calls in one program (`examples/coding-agent/tests/code-mode.e2e.ts`) — every logged `request/header` carries exactly `[run_code]`, the dispatch events land under the parent call, the file the program wrote exists, and the final answer is the curated output. - **Snapshot (keyless replay)**: goldens for a `run_code` turn under `'code'` and `'both'` (`code-mode-turn`, `both-mode-turn`), each its own header-pinning class — the SDK section text, the collapsed header tool list, the dispatch events, and the result card are committed and replayed. diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md index ed718686ef..9fef336d9c 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -40,7 +40,7 @@ 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. -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. +An output schema makes a schema-valid committed capture mandatory for successful child completion. The scoped runtime presents the 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. diff --git a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md index 3604e70972..39345c2b53 100644 --- a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md +++ b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md @@ -17,7 +17,7 @@ The system-prompt assembly owns the canonical model-facing tool order, exactly w - The list must contain the rest entry exactly once and no duplicate names. - When `toolOrder` is unset, the canonical order is plain lexicographic name order (code-unit comparison, locale-independent), so determinism requires no configuration. -The policy is applied where the list is born: `assemble()`, before the `system-prompt/assemble` waterfall. The assembly canonicalizes the tools it collects from providers the same way it sorts sections by their `order` field — on the initial assembly, killing the registration-order entropy at its source. Everything downstream inherits the order untouched: the waterfall, the loop's `EpochHeader`, the `request/header` event, the deep-frozen request, and the dev invariant's cross-check all see one deterministic list, with no new loop change. +The policy is applied where the list is born: `assemble()`, before the `system-prompt/assemble` waterfall. The assembly canonicalizes the tools it collects from providers the same way it sorts sections by their `order` field — on the initial assembly, killing the registration-order entropy at its source. The waterfall therefore starts from one deterministic list; when a listener leaves that order intact, the loop's `EpochHeader`, the `request/header` event, the deep-frozen request, and the dev invariant's cross-check inherit it with no new loop change. Scope is deliberately narrow: this fixes the REGISTRATION-ORDER race, not plugin behavior. A `system-prompt/assemble` listener may still add, remove, or rearrange tools — same as it may edit sections after their sort — and owns the determinism of what it emits; the waterfall contract already demands deterministic listeners (the reconstructability invariant would catch a listener that diverges between build and replay). @@ -36,8 +36,8 @@ Config plumbing follows the `persona` precedent, and `toolOrder` sits beside it: ## Consequences -- Every assembly — and therefore every `request/header` event and model request — has a deterministic tool order on every host; the CI-vs-local golden flip is structurally gone. The default order is lexicographic, no longer registration order. -- `PromptAssembly.tools` itself is canonical, so every assembly consumer (the loop, waterfall listeners, any future prompt inspector) sees the model-facing order; provider registration order is observable nowhere downstream of the registry. +- Every registry-built assembly starts with a deterministic tool order on every host; absent an expert listener that deliberately changes it, every `request/header` event and model request inherits that order. The CI-vs-local registration-order flip is structurally gone, and the default is lexicographic. +- The initial `PromptAssembly.tools` is canonical, so waterfall listeners start from the model-facing order; provider registration order is observable nowhere before that cooperative seam. - The snapshot suite's single pinned request-header fixture (`text-turn`) carries the new canonical tool order; every other ACP snapshot keeps the header bulk scrubbed as `{{system}}`/`{{tools}}`, per the pinned-header design. - A pure tool reordering between steps is representable only as a `request/header` `'fallback'` snapshot (the name-keyed `ToolsDelta` cannot express it); with a stable canonical order such reorders no longer occur in practice, so the fallback path stays a safety valve. - The `toolOrder` key rides the app → `agent-core` → `SystemPrompt` forwarding chain, so deployments set it next to `persona` in the app config; `dsh-llm` and the agent loop are untouched. diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index ebea9b20c1..9ea71d3005 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -16,7 +16,7 @@ This table connects model-visible tool names to the plugin package and service s | Tool package | Model-visible names | Requires | Writes / affects | Shipped aliases | Deployment note | | --- | --- | --- | --- | --- | --- | | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userInteraction` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. | -| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the registry's only canonical wire contribution; the other visible capabilities are declared in a protected TypeScript SDK section, and a program calls them through serialized bindings that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | +| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through serialized bindings that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | | `@deepseek-ai/dsh-tool-bash` | `bash`, `bash_kill`, `bash_output` | `ctx.tools`, `ctx.bash` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; the request-header ToolsDelta logs those tool-set changes. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | @@ -119,7 +119,7 @@ Execute a TypeScript program against the available tools. Write the BODY of an a Source: [`packages/core/tools/src/code-mode.ts`](../packages/core/tools/src/code-mode.ts) -Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the registry's only canonical wire contribution; the other visible capabilities are declared in a protected TypeScript SDK section, and a program calls them through serialized bindings that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. +Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through serialized bindings that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. ## `@deepseek-ai/dsh-tool-bash` diff --git a/examples/README.md b/examples/README.md index e97c0fa9d5..7f711cb297 100644 --- a/examples/README.md +++ b/examples/README.md @@ -19,7 +19,7 @@ A REPL agent demo: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + th Run with: `pnpm run demo:repl` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [coding-agent/README.md](coding-agent/README.md) for details. -Its `code-mode.cordis.yml` overlay flips the same tree to **Code Mode**: the worker-thread code runtime is loaded and the tool registry runs `mode: code`, so its canonical wire contribution is the protected `run_code` transport plus a protected generated TypeScript SDK section, and the model composes the other tools by writing a program whose output it curates. Run with: `pnpm run demo:code-mode` (the REPL is the default UI; `acp` as the argument serves the acp-agent example's same-shaped overlay instead) — see the [Code Mode section](coding-agent/README.md#code-mode) for what to try. +Its `code-mode.cordis.yml` overlay flips the same tree to **Code Mode**: the worker-thread code runtime is loaded and the tool registry runs `mode: code`, so its registry contribution is the reserved `run_code` transport plus a generated TypeScript SDK section, and the model composes the other tools by writing a program whose output it curates. Run with: `pnpm run demo:code-mode` (the REPL is the default UI; `acp` as the argument serves the acp-agent example's same-shaped overlay instead) — see the [Code Mode section](coding-agent/README.md#code-mode) for what to try. ## cordis-agent diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md index 37a43481e5..5a607d3c7b 100644 --- a/examples/coding-agent/README.md +++ b/examples/coding-agent/README.md @@ -33,7 +33,7 @@ The id is wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_ ## Code Mode -[`code-mode.cordis.yml`](code-mode.cordis.yml) is this same tree flipped to [Code Mode](../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): an include overlay over `./cordis.yml` whose two patches insert the worker-thread code runtime (`@deepseek-ai/dsh-code-runtime-worker`, registering `ctx.codeRuntime`) and set `tools: { mode: code }` on the app. The registry contributes exactly one protected wire transport — reserved `run_code` — plus a protected TypeScript SDK section declaring the visible end-capability tools. The model composes those capabilities by writing a program; each program call carries an immutable link to its enclosing transport, bridges back through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation one at a time, and is logged as a `tool/code-dispatch` session event. Only what the program prints or returns re-enters model context. (Flip the mode to `both` to offer native calls and `run_code` side by side.) +[`code-mode.cordis.yml`](code-mode.cordis.yml) is this same tree flipped to [Code Mode](../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): an include overlay over `./cordis.yml` whose two patches insert the worker-thread code runtime (`@deepseek-ai/dsh-code-runtime-worker`, registering `ctx.codeRuntime`) and set `tools: { mode: code }` on the app. The registry contributes exactly one reserved wire transport — `run_code` — plus a generated TypeScript SDK section declaring the visible end-capability tools. The model composes those capabilities by writing a program; each program call carries an immutable link to its enclosing transport, bridges back through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation one at a time, and is logged as a `tool/code-dispatch` session event. Only what the program prints or returns re-enters model context. (Flip the mode to `both` to offer native calls and `run_code` side by side.) ```sh pnpm run demo:code-mode # this overlay under the REPL (default UI) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 4b5d614ac4..b4420e80c7 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -187,7 +187,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'systemPrompt', - summary: 'Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, named prompt variables, and owner-final contributions; the agent loop calls `assemble(context)` once per step.', + summary: 'Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, and named prompt variables; the agent loop calls `assemble(context)` once per step.', methods: [ 'section(section: PromptSection): () => void', 'tools(provider: (context: AssembleContext) => ToolProviderResult): () => void', @@ -748,7 +748,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PromptSection', - declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n readonly ownerFinal?: boolean;\n}', + declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n}', }, { name: 'ReasoningBlock', @@ -920,7 +920,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolDefinition', - declaration: 'export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolExecution): Promise;\n timeoutMs?: number;\n readonly ownerFinal?: boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}', + declaration: 'export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolExecution): Promise;\n timeoutMs?: number;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}', }, { name: 'ToolErrorInfo', @@ -952,7 +952,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolProviderResult', - declaration: 'export interface ToolProviderResult {\n readonly schemas: readonly ToolSchema[];\n readonly knownNames?: readonly string[];\n readonly ownerFinalNames?: readonly string[];\n}', + declaration: 'export interface ToolProviderResult {\n readonly schemas: readonly ToolSchema[];\n readonly knownNames?: readonly string[];\n}', }, { name: 'ToolRestriction', diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index cfe4976af4..9ee324b0c3 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -31,7 +31,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves. -Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` is a serial surface-mutation checkpoint, while `agent/turn-stop` is the owner-final exception: it runs after ordinary continuation and steering folding, and its terminal state remains through turn close and flush so steering from those later listeners cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale is in the [agent-scope runtime-design RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#owner-final-policy-four-narrow-boundaries). +Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` is a serial surface-mutation checkpoint, while `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale is in the [agent-scope runtime-design RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way). Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md). diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 009dc6b3fe..dc1101faaf 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -1,6 +1,6 @@ # dsh-system-prompt -System prompt assembly registry. Plugins contribute ordered text sections, tool-schema providers, and named prompt variables; contributions that implement required protocol may declare themselves owner-final. The agent loop calls `assemble(context)` once per step, and `renderPrompt(assembly)` is the full system prompt the model sees. The plugin registers the harness-owned openers itself — the static `harness:identity` section and the global default `deployment:persona` section — so they remain available regardless of which loop plugin drives an agent. An agent-scoped contribution with the same persona name shadows that default for its agent. +System prompt assembly registry. Plugins contribute ordered text sections, tool-schema providers, and named prompt variables. The agent loop calls `assemble(context)` once per step, and `renderPrompt(assembly)` is the full system prompt the model sees. The plugin registers the harness-owned openers itself — the static `harness:identity` section and the global default `deployment:persona` section — so they remain available regardless of which loop plugin drives an agent. An agent-scoped contribution with the same persona name shadows that default for its agent. ## Config @@ -13,19 +13,19 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool- ### Public API -- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. Duplicate names within one layer and non-finite orders throw. `ownerFinal: true` restores this section's canonical definition after the complete waterfall and reserves a global section against scoped shadows. Disposed with the calling fiber. -- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames?, ownerFinalNames? }`: `schemas` is the post-restriction visible set; `knownNames` is the pre-restriction universe used by `toolOrder`; `ownerFinalNames` makes those tools' canonical presence or absence survive the waterfall. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber. +- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber. +- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set; `knownNames` is the pre-restriction universe used by `toolOrder`. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber. - `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber. -- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform seam. Runs through the scope-filtered `system-prompt/assemble` waterfall, then restores owner-final contributions from a private pre-waterfall snapshot. Restored entries keep canonical relative order without undoing listener reordering of ordinary entries. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name. +- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform seam. Runs through the scope-filtered `system-prompt/assemble` waterfall and returns its authoritative result. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name. ### Live events -Prompt assembly is the scope-filtered transformable seam; registry change is the deliberately unfiltered notification that an assembly input changed, possibly for one scope. Exact signatures, dispatch modes, and filtering contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md). Owner-final restoration applies only after a successful assembly waterfall returns. +`system-prompt/assemble` is an expert cooperative seam: its returned assembly is authoritative, and a listener that replaces or removes entries owns preserving any active Code Mode or structured-output protocol. Prefer [`ToolRegistry.restrict()`](../tools/README.md) when tool filtering must stay aligned across model presentation, lookup, and execution. Registry change is the deliberately unfiltered notification that an assembly input changed, possibly for one scope; exact signatures, dispatch modes, and filtering contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md). ### Key types - `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) here, and `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent)`). Providers must tolerate absent fields (a bare `assemble()` carries an empty, scope-less context). -- `PromptSection` — `{ name, order, text, ownerFinal? }`. Sections are concatenated in ascending `order`; `ownerFinal` is reserved for required protocol instructions. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100–199`. +- `PromptSection` — `{ name, order, text }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100–199`. - `PromptAssembly` — `{ sections: AssembledSection[], tools: ToolSchema[], variables: Record }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field. - `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference (`Object.hasOwn` lookup — prototype names like `{{constructor}}` are unknown), a registered-but-valueless reference, a malformed complete `{{…}}` group, or a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`) throws — fail loud beats shipping a malformed prompt. A lone `{{` with no `}}` anywhere after it passes through verbatim; substituted values are never re-scanned. @@ -36,8 +36,7 @@ Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `Asse - Section providers: tool packages own their cross-call guidance (`tool:bash`, `tool:read`, …); this plugin owns `harness:identity` and `deployment:persona`. - Variable providers: the agent loop registers `model` and `cwd`; any plugin can register the facts it owns (a future `date`, git state, …). - Tool schema providers: `ToolRegistry` registers itself as a tool provider automatically. -- The `system-prompt/assemble` waterfall: mutate or replace the assembly per caller (dynamic tool filtering, extra variables). -- Owner-final contributions: protocol owners declare finality on the section or tool contribution itself; there is no independent protection registry. +- The [`system-prompt/assemble` waterfall](#live-events): cooperatively mutate or replace the assembly per caller. ### What is NOT here diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 0adbce0184..63ba6b350d 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -1,8 +1,7 @@ /** * System prompt assembly registry. Plugins contribute ordered text sections, - * tool schema providers, and named prompt variables; protocol contributions - * may declare themselves owner-final. `assemble(context)` collates them through a waterfall that - * runs once per step, restores owner-final contributions, and `renderPrompt` + * tool schema providers, and named prompt variables; `assemble(context)` + * collates them through a waterfall that runs once per step, and `renderPrompt` * interpolates `{{variable}}` references into the final text. * * The harness-owned prompt openers live here too: this plugin registers the @@ -30,13 +29,18 @@ declare module 'cordis' { * {@link PromptAssembly} (sections + tools + variables) before it is * rendered. Bound to the {@link SystemPrompt} service; call `next()` to * delegate. - * @param assembly - the assembly built from the registered sections, tool - * providers, and variable providers; listeners may mutate it or return a - * replacement. + * * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed * by `context.scope` — a listener registered through `agent.ctx` fires only * for that agent's assemblies; a plain plugin listener fires for every * assembly (scope-less ones included, dispatched subject-less). + * + * The returned assembly is authoritative. This is an expert composition + * seam: a listener that removes or replaces another plugin's protocol + * contribution owns preserving that protocol's invariants. + * @param assembly - the assembly built from the registered sections, tool + * providers, and variable providers; listeners may mutate it or return a + * replacement. * @param context - the per-assembly {@link AssembleContext} the caller * passed to {@link SystemPrompt.assemble} (e.g. which agent the prompt * is for), so a listener can filter or extend per agent. @@ -93,12 +97,6 @@ export interface PromptSection { * interpolated later, by {@link renderPrompt}. */ readonly text: string | ((context: AssembleContext) => string) - /** - * Whether this section's canonical presence and definition survive the - * complete assembly waterfall. Use this only for owner-required protocol - * instructions; ordinary sections remain transformable. - */ - readonly ownerFinal?: boolean } /** One section of an assembly: {@link PromptSection} with its text resolved. */ @@ -126,12 +124,6 @@ export interface ToolProviderResult { readonly schemas: readonly ToolSchema[] /** The pre-restriction name universe for config validation (defaults to `schemas`' names). */ readonly knownNames?: readonly string[] - /** - * Tool names this provider owns finally. The names need not be present in - * `schemas`: naming a mode-hidden tool makes its canonical absence final, so - * an assembly listener cannot fabricate it onto the wire. - */ - readonly ownerFinalNames?: readonly string[] } /** @@ -224,30 +216,6 @@ function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined, knownN name === TOOL_ORDER_REST ? rest : tools.filter(tool => tool.name === name)) } -/** Restore owner-final entries from `canonical`, anchored before their next ordinary canonical neighbor. */ -function restoreOwnerFinal( - canonical: readonly T[], result: readonly T[], ownerFinalNames: ReadonlySet, -): T[] { - const restored = result.filter(entry => !ownerFinalNames.has(entry.name)) - for (const [index, entry] of canonical.entries()) { - if (!ownerFinalNames.has(entry.name)) continue - // Protected entries are inserted in canonical order. Anchor each one - // before the first later UNPROTECTED canonical neighbor that survived the - // waterfall; if none survived, it belongs at the end. Looking only at - // ordinary neighbors avoids reversing adjacent owner-final entries. - const following = new Set( - canonical.slice(index + 1) - .filter(candidate => !ownerFinalNames.has(candidate.name)) - .map(candidate => candidate.name), - ) - const next = restored.findIndex(candidate => following.has(candidate.name)) - // `canonical` is an owned snapshot made before the waterfall; no second - // clone is needed when moving its entries into the finalized assembly. - restored.splice(next < 0 ? restored.length : next, 0, entry) - } - return restored -} - /** Lexicographic (code-unit) name comparison — locale-independent, so the order is identical on every machine. */ function compareToolNames(a: ToolSchema, b: ToolSchema): number { return a.name < b.name ? -1 : a.name > b.name ? 1 : 0 @@ -364,10 +332,10 @@ function interpolate(section: AssembledSection, variables: Record = z.object({ @@ -420,10 +388,8 @@ export class SystemPrompt extends Service { * scoped context (`agent.ctx`) contributes to that scope alone — and a * scoped section SHADOWS a same-named global section for that scope's * assemblies (most-specific-wins; this is how a per-agent persona overrides - * `deployment:persona`) unless that global contribution is owner-final: it - * reserves its section name against scoped shadows so the - * registration owner—not a later scope—defines the canonical value. The - * readonly typed contribution is borrowed until disposal; only the semantic + * `deployment:persona`). The readonly typed contribution is borrowed until + * disposal; only the semantic * finite-order rule is checked at runtime. Throws if the SAME layer already has the name (a * duplicate would silently double prompt text — e.g. a double-loaded tool * plugin; the global-duplicate message names `agent.ctx` as the per-agent @@ -439,17 +405,6 @@ export class SystemPrompt extends Service { throw new TypeError(`prompt section "${section.name}" order must be a finite number`) } const scope = scopeOf(this.ctx) - if (scope !== undefined - && this.sections.some(global => global.name === section.name && global.ownerFinal === true)) { - throw new Error(`prompt section "${section.name}" is globally owner-final and cannot be shadowed in an agent scope`) - } - if (scope === undefined && section.ownerFinal === true) { - const hasScopedShadow = [...this.scopedSections.values()] - .some(layer => layer.some(scoped => scoped.name === section.name)) - if (hasScopedShadow) { - throw new Error(`owner-final prompt section "${section.name}" cannot be registered while a scoped shadow exists`) - } - } const dispose = this.ctx.effect(function* (this: SystemPrompt) { const layer = scope === undefined ? this.sections @@ -602,11 +557,10 @@ export class SystemPrompt extends Service { * name restricted away for this scope is a normal absence), and every * visible variable resolved against `context` into `assembly.variables`. * Tool schemas are detached because assembly waterfalls may mutate them. - * Runs through the `system-prompt/assemble` - * waterfall, giving listeners the opportunity to mutate or replace the - * assembly, then restores every contribution whose owner declared it final - * from the pre-waterfall canonical assembly. Like the sections' `order` sort, tool - * canonicalization happens on the initial assembly; ordinary listener + * Runs through the `system-prompt/assemble` waterfall, giving listeners the + * opportunity to mutate or replace the assembly; the returned value is the + * authoritative model-visible composition. Like the sections' `order` + * sort, tool canonicalization happens on the initial assembly; listener * output owns its own determinism. Await the result before reading the * assembly values — waterfall listeners may be async. * Interpolation happens later, in {@link renderPrompt}. @@ -638,11 +592,6 @@ export class SystemPrompt extends Service { for (const section of (scope === undefined ? [] : this.scopedSections.get(scope)) ?? []) { sectionByName.set(section.name, section) } - const ownerFinalSections = new Set( - [...sectionByName.values()] - .filter(section => section.ownerFinal === true) - .map(section => section.name), - ) // Tools: consult the global providers plus the scope's, each with this // assembly's context. `schemas` are what the model may see (already // post-restriction, per provider); `knownNames` (defaulting to the @@ -655,7 +604,6 @@ export class SystemPrompt extends Service { ] const collected: ToolSchema[] = [] const knownNames = new Set() - const ownerFinalTools = new Set() for (const provider of providers) { const result = provider(context) const schemas = result.schemas.map(({ name, description, parameters }): ToolSchema => ({ @@ -666,7 +614,6 @@ export class SystemPrompt extends Service { const acceptedKnownNames = result.knownNames ?? schemas.map(tool => tool.name) collected.push(...schemas) for (const name of acceptedKnownNames) knownNames.add(name) - for (const name of result.ownerFinalNames ?? []) ownerFinalTools.add(name) } const assembly: PromptAssembly = { sections: [...sectionByName.values()] @@ -679,27 +626,10 @@ export class SystemPrompt extends Service { tools: orderTools(collected, this.toolOrder, knownNames), variables, } - // Snapshot only the owner-final fields. The waterfall receives - // `assembly` by reference and may mutate it or return a replacement; these - // independent snapshots remain the authoritative registry product. - const canonicalSections = ownerFinalSections.size > 0 ? structuredClone(assembly.sections) : undefined - const canonicalTools = ownerFinalTools.size > 0 ? structuredClone(assembly.tools) : undefined - const result = await this.ctx.waterfall( + return this.ctx.waterfall( scopeTarget(this, scope), 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly), ) - // Build a replacement instead of mutating the waterfall result: a - // listener may legitimately return a frozen assembly. Merge-extensible - // fields ride through the spread untouched. - return { - ...result, - ...canonicalSections !== undefined - ? { sections: restoreOwnerFinal(canonicalSections, result.sections, ownerFinalSections) } - : {}, - ...canonicalTools !== undefined - ? { tools: restoreOwnerFinal(canonicalTools, result.tools, ownerFinalTools) } - : {}, - } } } diff --git a/packages/core/system-prompt/tests/scoped.spec.ts b/packages/core/system-prompt/tests/scoped.spec.ts index 4becf5192a..a452bb6797 100644 --- a/packages/core/system-prompt/tests/scoped.spec.ts +++ b/packages/core/system-prompt/tests/scoped.spec.ts @@ -63,17 +63,6 @@ describe('scoped sections', () => { expect(() => scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'b' })).toThrow(/already registered in this scope/) }) - it('rejects a global owner-final section added after a scoped shadow', async () => { - const ctx = await mount() - const scope = await mintScope(ctx, 'child') - scope.ctx.systemPrompt.section({ name: 'reserved', order: 1, text: 'scoped reserved' }) - - expect(() => ctx.systemPrompt.section({ - name: 'reserved', order: 1, text: 'global reserved', ownerFinal: true, - })).toThrow('owner-final prompt section "reserved"') - expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }))) - .toContain('scoped reserved') - }) }) describe('scoped variables', () => { @@ -161,35 +150,4 @@ describe('scoped assemble dispatch', () => { expect(shaped).toHaveLength(1) }) - it('scoped owner-final contributions finalize only their assemblies and disappear with the scope', async () => { - const ctx = await mount() - const scope = await mintScope(ctx, 'child') - const key = scopeKeyOf(scope) - ctx.systemPrompt.section({ name: 'required', order: 10, text: 'required' }) - ctx.systemPrompt.tools(() => ({ schemas: [schema('required')] })) - scope.ctx.systemPrompt.section({ - name: 'required', order: 10, text: 'scoped required', ownerFinal: true, - }) - scope.ctx.systemPrompt.tools(() => ({ - schemas: [schema('required')], ownerFinalNames: ['required'], - })) - ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { - const result = await next() - result.sections = result.sections.filter(section => section.name !== 'required') - result.tools = result.tools.filter(tool => tool.name !== 'required') - return result - }, { prepend: true }) - - const scoped = await ctx.systemPrompt.assemble({ scope: key }) - const global = await ctx.systemPrompt.assemble() - expect(scoped.sections.some(section => section.name === 'required')).toBe(true) - expect(scoped.tools.some(tool => tool.name === 'required')).toBe(true) - expect(global.sections.some(section => section.name === 'required')).toBe(false) - expect(global.tools.some(tool => tool.name === 'required')).toBe(false) - - await scope.dispose() - const disposed = await ctx.systemPrompt.assemble({ scope: key }) - expect(disposed.sections.some(section => section.name === 'required')).toBe(false) - expect(disposed.tools.some(tool => tool.name === 'required')).toBe(false) - }) }) diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index dfdfef42dc..c7436711fa 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -213,67 +213,6 @@ describe('SystemPrompt', () => { expect(assembly.sections).toHaveLength(0) }) - describe('owner-final contributions', () => { - it('restores exact owner-final definitions after every listener, in canonical relative order', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - ctx.systemPrompt.section({ name: 'before', order: 10, text: 'before' }) - ctx.systemPrompt.section({ name: 'protected', order: 20, text: 'canonical section', ownerFinal: true }) - ctx.systemPrompt.section({ name: 'after', order: 30, text: 'after' }) - ctx.systemPrompt.tools(() => ({ schemas: [ - { name: 'alpha', description: 'alpha', parameters: {} }, - { name: 'protected', description: 'canonical tool', parameters: { type: 'object', properties: { answer: { type: 'number' } } } }, - { name: 'zulu', description: 'zulu', parameters: {} }, - ], ownerFinalNames: ['protected'] })) - - // Service-level finalization restores the canonical entries after the - // complete listener chain returns. - ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { - const result = await next() - return Object.freeze({ - sections: [ - ...result.sections.filter(section => section.name !== 'protected'), - { name: 'protected', order: -999, text: 'wrong section' }, - { name: 'protected', order: 999, text: 'duplicate section' }, - ], - tools: [ - ...result.tools.filter(tool => tool.name !== 'protected'), - { name: 'protected', description: 'wrong tool', parameters: {} }, - { name: 'protected', description: 'duplicate tool', parameters: {} }, - ], - variables: result.variables, - }) - }, { prepend: true }) - - const assembly = await ctx.systemPrompt.assemble() - const protectedSections = assembly.sections.filter(section => section.name === 'protected') - const protectedTools = assembly.tools.filter(tool => tool.name === 'protected') - expect(protectedSections).toEqual([{ name: 'protected', order: 20, text: 'canonical section' }]) - expect(protectedTools).toEqual([{ - name: 'protected', - description: 'canonical tool', - parameters: { type: 'object', properties: { answer: { type: 'number' } } }, - }]) - expect(assembly.sections.map(section => section.name).indexOf('protected')) - .toBeLessThan(assembly.sections.map(section => section.name).indexOf('after')) - expect(assembly.tools.map(tool => tool.name)).toEqual(['alpha', 'protected', 'zulu']) - }) - - it('makes an owner-final tool\'s canonical absence survive the waterfall', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - ctx.systemPrompt.tools(() => ({ schemas: [], ownerFinalNames: ['mode-hidden'] })) - ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { - const result = await next() - result.tools.push({ name: 'mode-hidden', description: 'fabricated', parameters: {} }) - return result - }) - - const assembly = await ctx.systemPrompt.assemble() - expect(assembly.tools.some(tool => tool.name === 'mode-hidden')).toBe(false) - }) - }) - it('assembles snapshots so one-step mutations do not leak into future assemblies', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index f4416a312c..a9411f6cba 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -11,11 +11,11 @@ tools: mode: native # native (default) | code | both ``` -`native` contributes the calling agent's visible end capabilities as wire function definitions. Under `code`, this registry's canonical contribution is the reserved `run_code` transport plus the generated `tools:sdk` prompt section (see [Code Mode](#code-mode)); an assembly listener may still deliberately add unrelated schemas. `both` contributes the visible native definitions, `run_code`, and the SDK section. In non-native modes both infrastructure pieces are owner-final rather than filterable capabilities: restrictions and assembly listeners cannot remove them, a scoped section cannot shadow the globally owner-final `tools:sdk`, and registering, shadowing, or explicitly filtering `run_code` fails loudly. Non-native modes require a loaded `ctx.codeRuntime` with `language: 'typescript'`; a missing or mismatched runtime rejects every prompt assembly with an actionable error, and a `systemPrompt.toolOrder` naming tools the mode no longer contributes rejects the assembly the same way. +`native` contributes the calling agent's visible end capabilities as wire function definitions. Under `code`, this registry contributes the reserved `run_code` transport plus the generated `tools:sdk` prompt section (see [Code Mode](#code-mode)); `both` contributes the visible native definitions and both infrastructure pieces. Restrictions cannot remove `run_code`, and registering, shadowing, or explicitly filtering that reserved name fails loudly. An expert `system-prompt/assemble` listener may replace any prompt or schema contribution; its returned assembly is authoritative, so the listener owns preserving Code Mode when the protocol should remain active. Non-native modes require a loaded `ctx.codeRuntime` with `language: 'typescript'`; a missing or mismatched runtime rejects every prompt assembly with an actionable error, and a `systemPrompt.toolOrder` naming tools the mode no longer contributes rejects the assembly the same way. ### Public API -- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. `timeoutMs`, when present, must be positive and finite. `ownerFinal: true` makes the tool's canonical wire presence or absence survive prompt assembly listeners. Disposed with the calling fiber. +- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. `timeoutMs`, when present, must be positive and finite. Disposed with the calling fiber. - `ctx.tools.restrict(filter: ToolRestriction): () => void` Scoped-only (throws on a plain context): mask the global end-capability surface for the calling agent — `allow` keeps only the listed global tools, `deny` removes them; multiple restrictions intersect; scope-local registrations are merged afterward. The readonly arrays compile once into private sets. Every listed name must exist in the current pre-restriction global registry; scope-local, unknown, and reserved `run_code` names fail loudly. A deny-list admits a later global tool unless it names that tool; an allow-list excludes later names; neither filters a later scope-local registration. `restrict({})` rejects. This is live registration composition, not a parent-derived authority ceiling; see the [agent-scope security non-goal](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals). - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)). @@ -28,11 +28,11 @@ tools: ### Live events -The live registry pipeline has three transformable waterfalls followed by the owner-final `tools/result` observation boundary; registry changes are deliberately unfiltered shared-state notifications. Exact signatures, dispatch modes, scope filtering, and failure-containment contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md), while the complete ordering is visualized in the generated [tool execution pipeline](../../../docs/tool-execution-pipeline.md). `tools/result` is live and observe-only; the similarly named `tool/result` is the durable session event the agent loop appends afterwards. +The live registry pipeline has three transformable waterfalls followed by the observe-only `tools/result` boundary; registry changes are deliberately unfiltered shared-state notifications. Exact signatures, dispatch modes, scope filtering, and failure-containment contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md), while the complete ordering is visualized in the generated [tool execution pipeline](../../../docs/tool-execution-pipeline.md). `tools/result` is live; the similarly named `tool/result` is the durable session event the agent loop appends afterwards. ### Key types -- `ToolDefinition` — `ToolSchema` + `execute(args, exec)`, optional presentation callbacks, cooperative `timeoutMs`, and optional `ownerFinal`. `ownerFinal` is reserved for protocol tools such as `run_code` and structured-output capture whose canonical wire state must survive assembly listeners. +- `ToolDefinition` — `ToolSchema` + `execute(args, exec)`, optional presentation callbacks, and cooperative `timeoutMs`. - `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token. - `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary. - `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object. @@ -132,11 +132,11 @@ const bash = defineTool({ Under `mode: code` (or `both`) the registry turns the tool surface into a programming API, per the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): the model writes a TypeScript program (the body of an async function) and passes it to the reserved wire transport `run_code`; the program runs in `ctx.codeRuntime` (the [code-execution seam](../../code-runtime/README.md) — the shipped backend is a worker thread) with one async binding per visible end-capability tool (`await tools.bash({...})`), and ONLY what it prints or returns re-enters the model's context. Scope restrictions change those SDK bindings but cannot remove or replace the transport itself. -- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. The registry protects this section and the `run_code` wire schema after the assembly waterfall, so Code Mode cannot silently lose either half of its transport. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is total: constructs outside the `defineTool` subset degrade to `unknown`, never throw. +- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is total: constructs outside the `defineTool` subset degrade to `unknown`, never throw. - **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized before dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A denial reaches the program as a binding rejection, and each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `:code:`; `deriveMessages()` does not surface that event. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. A sub-call's `additionalContext` is deliberately dropped because inserting it inside a running parent call would break tool-call/result adjacency. - **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from. -The wire collapse is the registry's own contribution (`systemPrompt.tools()` is mode-aware), so the logged `request/header` records it for free. With no deliberate schema-adding assembly listener, `code` assembles exactly `[run_code]`, pinned by tests and the snapshot goldens; contribution-owned finality guarantees that `run_code` and `tools:sdk` remain present, not that unrelated listener additions are erased. Try it: `pnpm run demo:code-mode` ([the coding-agent example's Code Mode overlay](../../../examples/coding-agent/README.md#code-mode)); `pnpm run demo:code-mode acp` serves the same mode over ACP instead of the REPL. +The wire collapse is the registry's own contribution (`systemPrompt.tools()` is mode-aware), so the logged `request/header` records it for free. When no assembly listener changes the registry's prompt or schema contributions, `code` assembles exactly `[run_code]`, pinned by tests and the snapshot goldens. Try it: `pnpm run demo:code-mode` ([the coding-agent example's Code Mode overlay](../../../examples/coding-agent/README.md#code-mode)); `pnpm run demo:code-mode acp` serves the same mode over ACP instead of the REPL. ### What is NOT here (TODO) diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 702a403b03..323fbeed2b 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -151,7 +151,6 @@ function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined { export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => CodeRuntime): ToolDefinition { return defineTool({ name: RUN_CODE_NAME, - ownerFinal: true, description: 'Execute a TypeScript program against the available tools. Write the BODY of an ' + 'async function (erasable syntax only; top-level `await` and `return` work) and ' diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index ab28324503..08edb8d8ae 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -199,12 +199,6 @@ export 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 @@ -457,8 +451,6 @@ interface ToolView { readonly knownNames: ReadonlySet /** Current global names that a scoped restriction may name. */ readonly restrictableNames: ReadonlySet - /** Canonical names whose wire presence or absence is owner-final. */ - readonly ownerFinalNames: ReadonlySet } /** @@ -491,11 +483,12 @@ interface ToolGuardRegistration { * 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). {@link restrict} masks the global layer per - * scope. One private visibility resolver feeds prompt assembly, - * {@link get}, and {@link 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. + * scope. One private visibility resolver feeds the registry's prompt + * contribution, {@link get}, and {@link execute} — and, under a non-native + * mode, the SDK section and `run_code`'s bindings — so those registry-owned + * presentation and dispatch paths agree. An expert `system-prompt/assemble` + * listener may deliberately replace the final wire composition and owns any + * resulting divergence. */ export class ToolRegistry extends Service { static inject = ['systemPrompt'] @@ -533,7 +526,6 @@ export class ToolRegistry extends Service { ctx.systemPrompt.section({ name: 'tools:sdk', order: SDK_SECTION_ORDER, - ownerFinal: true, // A lazy thunk over the live registry, per assembly CONTEXT: // regenerated at each assembly over the CALLING SCOPE's visible set // (scoped tools join, restricted globals vanish — the SDK declares @@ -570,19 +562,17 @@ export class ToolRegistry extends Service { private wireSchemas(scope?: ScopeKey): ToolProviderResult { const view = this.view(scope) const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false)) - const ownerFinalNames = [...view.ownerFinalNames] if (this.mode === 'native') { - return { schemas, knownNames: [...view.knownNames], ownerFinalNames } + return { schemas, knownNames: [...view.knownNames] } } this.requireCodeRuntime() if (this.mode === 'code') { return { schemas: schemas.filter(schema => schema.name === RUN_CODE_NAME), knownNames: [RUN_CODE_NAME], - ownerFinalNames, } } - return { schemas, knownNames: [...view.knownNames, RUN_CODE_NAME], ownerFinalNames } + return { schemas, knownNames: [...view.knownNames, RUN_CODE_NAME] } } /** @@ -634,15 +624,6 @@ export class ToolRegistry extends Service { if (this.codeTransport !== undefined && name === RUN_CODE_NAME) { throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the Code Mode presentation transport and cannot be registered or shadowed`) } - if (scope !== undefined && this.global.get(name)?.ownerFinal === true) { - throw new Error(`tool "${name}" is globally owner-final and cannot be shadowed in an agent scope`) - } - if (scope === undefined && definition.ownerFinal === true) { - const hasScopedShadow = [...this.scoped.values()].some(layer => layer.has(name)) - if (hasScopedShadow) { - throw new Error(`owner-final tool "${name}" cannot be registered while a scoped shadow exists`) - } - } const dispose = this.ctx.effect(function* (this: ToolRegistry) { const layer = scope === undefined ? this.global : this.layerFor(scope) if (layer.has(name)) { @@ -818,7 +799,7 @@ export class ToolRegistry extends Service { * Resolve every registry fact one scope needs in one layer traversal. The * visible map applies global restrictions, scoped shadowing, and the reserved * presentation transport; the other sets retain the pre-restriction facts - * needed by restriction and prompt-order validation and owner-final restore. + * needed by restriction and prompt-order validation. * @param scope - the viewing scope (the agent), or undefined for the global view. * @returns the complete derived view for that scope. */ @@ -827,29 +808,24 @@ export class ToolRegistry extends Service { const visible = new Map() const knownNames = new Set() const restrictableNames = new Set() - const ownerFinalNames = new Set() for (const [name, definition] of this.global) { knownNames.add(name) restrictableNames.add(name) - if (definition.ownerFinal === true) ownerFinalNames.add(name) if (this.admits(scope, name)) visible.set(name, definition) } // Scoped layer second: same-name entries REPLACE (shadow) the global ones, // and scope-local registrations are never part of the global filter above. for (const [name, definition] of layer ?? []) { knownNames.add(name) - if (definition.ownerFinal === true) ownerFinalNames.add(name) visible.set(name, definition) } // Presentation infrastructure is resolved last and outside capability - // filtering. Registration rejects this reserved name, so this set is an - // invariant assertion as well as protection against future layer changes. + // filtering. Registration rejects this reserved name, so the insertion is + // an invariant assertion as well as protection against future layer changes. if (this.codeTransport !== undefined) { visible.set(RUN_CODE_NAME, this.codeTransport) - // createRunCodeTool() owns this internal transport and always marks it owner-final. - ownerFinalNames.add(RUN_CODE_NAME) } - return { visible, knownNames, restrictableNames, ownerFinalNames } + return { visible, knownNames, restrictableNames } } /** @@ -867,8 +843,9 @@ export class ToolRegistry extends Service { /** * The model-facing schemas of everything `scope` can see — exactly the - * fields (`name`, `description`, `parameters`) sent to the model via the - * system-prompt assembly. Constructed EXPLICITLY rather than by stripping + * fields (`name`, `description`, `parameters`) this registry contributes to + * system-prompt assembly before its expert transformation waterfall. + * Constructed EXPLICITLY rather than by stripping * known non-schema members: a `ToolDefinition` also carries `execute` and the * optional `presentCall`/`presentResult` UI callbacks, and those (especially * the functions) must never leak into a model request. An allowlist can't diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index dc33a97299..d3a2d32e18 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -302,8 +302,6 @@ export interface DefineToolOptions { * is never sent to the model. */ readonly timeoutMs?: number - /** Make this protocol tool's canonical wire presence or absence owner-final. */ - readonly ownerFinal?: boolean /** * Tool execution function. `args` is typed as {@link InferArgs} — zero * casts needed. Returns either a bare {@link ContentBlock}`[]` (model-facing @@ -380,7 +378,6 @@ export function defineTool(options: DefineToolOptions): description: options.description, parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record, ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), - ...(options.ownerFinal === true ? { ownerFinal: true } : {}), async execute(args: unknown, exec: ToolExecution): Promise { // Validate the model-generated args before the typed body runs. On // mismatch we throw ToolArgsError; the registry turns it into an diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 3333b3e1f9..f9d84aadbf 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -123,7 +123,7 @@ describe('mode-aware wire contribution', () => { expect(sdk?.text).not.toContain('run_code(args:') }) - it.each(['code', 'both'] as const)('restores Code Mode infrastructure after assembly listeners in mode %s', async (mode) => { + it.each(['code', 'both'] as const)('treats expert assembly output as authoritative in mode %s', async (mode) => { const { ctx, systemPrompt } = await setup({ mode }) registerEcho(ctx) ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { @@ -136,8 +136,20 @@ describe('mode-aware wire contribution', () => { }, { prepend: true }) const assembly = await systemPrompt.assemble() - expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(true) - expect(assembly.tools.some(tool => tool.name === RUN_CODE_NAME)).toBe(true) + expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false) + expect(assembly.tools.some(tool => tool.name === RUN_CODE_NAME)).toBe(false) + }) + + it.each(['code', 'both'] as const)('lets one scope shadow the default SDK section in mode %s', async (mode) => { + const { ctx, systemPrompt } = await setup({ mode }) + registerEcho(ctx) + const { scope, agent } = await mintAgentScope(ctx) + scope.ctx.systemPrompt.section({ name: 'tools:sdk', order: 150, text: 'SCOPED SDK' }) + + const scoped = await systemPrompt.assemble({ scope: agent }) + const global = await systemPrompt.assemble() + expect(scoped.sections.find(section => section.name === 'tools:sdk')?.text).toBe('SCOPED SDK') + expect(global.sections.find(section => section.name === 'tools:sdk')?.text).toContain('declare const tools:') }) it("mode 'both' contributes every native schema plus run_code, and the SDK section", async () => { @@ -214,8 +226,6 @@ describe('mode-aware wire contribution', () => { expect(() => scope.ctx.tools.register(impostor)).toThrow(/reserved for the Code Mode presentation transport/) expect(() => ctx.tools.register(impostor)).toThrow(/reserved for the Code Mode presentation transport/) - expect(() => scope.ctx.systemPrompt.section({ name: 'tools:sdk', order: -999, text: 'malicious SDK' })) - .toThrow(/globally owner-final and cannot be shadowed/) expect(() => scope.ctx.tools.restrict({ allow: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/) expect(() => scope.ctx.tools.restrict({ deny: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/) scope.ctx.systemPrompt.section({ name: 'scoped-note', order: 149, text: 'safe note' }) diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index e2445fc555..6599112c2a 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -98,35 +98,6 @@ describe('scoped tool registration', () => { expect(() => scope.ctx.tools.register(tool('y'))).toThrow(/already registered in this scope/) }) - it('rejects either registration order between a global owner-final tool and a scoped shadow', async () => { - const first = await mount() - const { scope: firstScope } = await mintAgentScope(first, 'first') - first.tools.register({ ...tool('reserved'), ownerFinal: true }) - expect(() => firstScope.ctx.tools.register(tool('reserved'))) - .toThrow(/globally owner-final and cannot be shadowed/) - - const second = await mount() - const { scope: secondScope } = await mintAgentScope(second, 'second') - secondScope.ctx.tools.register(tool('reserved')) - expect(() => second.tools.register({ ...tool('reserved'), ownerFinal: true })) - .toThrow(/owner-final tool "reserved" cannot be registered while a scoped shadow exists/) - }) - - it('restores global and scoped owner-final tools removed by assembly middleware', async () => { - const ctx = await mount() - const { scope, key } = await mintAgentScope(ctx, 'owner-final') - ctx.tools.register({ ...tool('required'), ownerFinal: true }) - scope.ctx.tools.register({ ...tool('scoped-required'), ownerFinal: true }) - ctx.on('system-prompt/assemble', async assembly => ({ - ...assembly, - tools: assembly.tools.filter(schema => !schema.name.includes('required')), - })) - - expect((await ctx.systemPrompt.assemble()).tools.map(schema => schema.name)).toContain('required') - expect((await ctx.systemPrompt.assemble({ scope: key })).tools.map(schema => schema.name)) - .toEqual(expect.arrayContaining(['required', 'scoped-required'])) - }) - it('disposing the scope unwinds its registrations and leaves no residue', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'a') diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 6077a139cd..48fd0a201d 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -34,7 +34,7 @@ After fulfillment, the caller owns the run. Provider-plugin unload does not revo - A `structured_output` tool registered with the requested schema validates and stages the model's value. - An order-190 system-prompt section tells the child that the tool call is the terminal answer. -- Both contributions use `ownerFinal: true`, so their owners control their final presence after prompt/tool assembly while unrelated contributions remain extensible. +- Both contributions are ordinary child-scoped registrations. An expert `system-prompt/assemble` listener may replace them and therefore owns preserving the structured-output protocol for that child. - A `tools/result` observer commits a staged value only after that execution's authoritative final tool result succeeds, including the enclosing `run_code` result for Code Mode sub-dispatch. - A monotonic tool guard blocks later calls after capture, and `agent/turn-stop` ends the turn after the structured result commits. diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index 60a2291788..16ecad7bd4 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -16,14 +16,11 @@ * * The child scope's registrations enforce the contract: * - * - `ownerFinal: true` on the capture tool and instruction declares that the - * owning registrations control their final presence. Prompt assembly restores their canonical state - * after EVERY assembly listener. Canonical absence is protected too: pure - * Code Mode keeps `structured_output` in the SDK only and never grows a - * second native wire tool. Code Mode independently declares its SDK section - * and `run_code` transport owner-final. The loop logs the finalized assembly as the - * request header, so the demand is reconstructable log state, never a - * wire-only mutation. + * - The scoped capture tool and instruction are ordinary assembly inputs. The + * loop logs the assembled request header, so the demand is reconstructable + * log state rather than a wire-only mutation. As with every other assembly + * contribution, an expert `system-prompt/assemble` listener that deliberately + * removes or replaces either input owns the resulting composition. * - `agent/turn-stop` (serial, scoped): stop the child's turn once its output * is captured. This terminal checkpoint runs after the ordinary continuation * waterfall and steering folding, so listener order cannot resurrect a @@ -110,7 +107,6 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut childCtx.tools.register({ ...schemaEntry, - ownerFinal: true, execute(args: unknown, exec: ToolExecution): Promise { const violations = validateStructuredValue(schema, args) // ToolArgsError → isError result with INVALID_ARGS: the model retries @@ -128,7 +124,6 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut name: `tool:${STRUCTURED_OUTPUT_TOOL}`, order: 190, text: STRUCTURED_OUTPUT_INSTRUCTION, - ownerFinal: true, }) // Stop the child's turn once its output is captured. This monotonic serial diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 636b2b6161..3e23123622 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -418,9 +418,9 @@ describe('in-process structured output', () => { it('appends the structured instruction to the child REQUEST\'s system text (base prompt preserved)', async () => { const { ctx, parent, adapter } = await setup([toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 })]) // A context-wide section stands in for the deployment persona: the - // instruction must APPEND to whatever the prompt pipeline assembled, not - // replace it (AgentOptions has no prompt field — the instruction is - // per-request wire state added by the final-request listener). + // instruction must APPEND to the other scoped and global sections, not + // replace them (AgentOptions has no prompt field — the instruction is an + // ordinary child-scoped prompt registration). ctx.systemPrompt.section({ name: 'test:persona', order: 10, text: 'You are a counter.' }) const run = await ctx.subagents.start('spawn', structuredRequest(parent)) await run.result @@ -445,22 +445,6 @@ describe('in-process structured output', () => { }) const run = await ctx.subagents.start('spawn', structuredRequest(parent)) - // This listener is registered after the child's protection and prepended. - // Service finalization still restores the stripped transport and prompt - // parts, while removing the fabricated native capture tool. - ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { - const result = await next() - return { - sections: result.sections.filter(section => - section.name !== 'tools:sdk' && section.name !== `tool:${STRUCTURED_OUTPUT_TOOL}`), - tools: [ - ...result.tools.filter(tool => tool.name !== RUN_CODE_NAME), - { name: STRUCTURED_OUTPUT_TOOL, description: 'wrong native duplicate', parameters: {} }, - ], - variables: result.variables, - } - }, { prepend: true }) - const result = await run.result expect(result.structured).toEqual({ answer: 12 }) const request = adapter.requests[0]! @@ -610,66 +594,12 @@ describe('in-process structured output', () => { await runB.dispose() }) - it('protection replaces a conflicting injected schema, not merely ensuring presence', async () => { - const { ctx, parent, adapter } = await setup([ - toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }), - ]) - // A global listener that INJECTS a wrong-schema structured_output entry: - // protection restores the run's own canonical schema. - ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { - const replaced = await next() - return { - sections: replaced.sections, - tools: [ - ...replaced.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL), - { name: STRUCTURED_OUTPUT_TOOL, description: 'wrong', parameters: { type: 'object', properties: { bogus: { type: 'string' } } } }, - ], - variables: { ...replaced.variables }, - } - }) - const run = await ctx.subagents.start('spawn', structuredRequest(parent)) - const result = await run.result - expect(result.structured).toEqual({ answer: 5 }) - const entries = adapter.requests[0]!.tools!.filter(tool => tool.name === STRUCTURED_OUTPUT_TOOL) - expect(entries).toHaveLength(1) - expect(entries[0]!.parameters).toEqual(SCHEMA) - await run.dispose() - }) - - it('protection wins against a listener that replaces the assembly object', async () => { - const { ctx, parent, adapter } = await setup([ - toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }), - ]) - // A global (every-assembly) listener that returns a brand-new assembly - // WITHOUT the capture tool or instruction — the composition caveat that - // erases cooperative mutations. Service finalization restores both - // after the complete waterfall. - ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { - const replaced = await next() - return { - sections: replaced.sections.filter(section => section.name !== `tool:${STRUCTURED_OUTPUT_TOOL}`), - tools: replaced.tools.filter(tool => tool.name !== STRUCTURED_OUTPUT_TOOL), - variables: { ...replaced.variables }, - } - }) - const run = await ctx.subagents.start('spawn', structuredRequest(parent)) - const result = await run.result - expect(result.structured).toEqual({ answer: 5 }) - const entry = adapter.requests[0]!.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL) - expect(entry).toBeDefined() - expect(entry!.parameters).toEqual(SCHEMA) - const system = adapter.requests[0]!.system ?? '' - expect(system).toContain(STRUCTURED_OUTPUT_INSTRUCTION) - await run.dispose() - }) - - it('protection preserves the canonical tool position and section band', async () => { + it('places the capture tool and instruction in their canonical orders', async () => { const { ctx, parent, adapter } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }), ]) - // A global tool sorting lexicographically AFTER structured_output and a - // global section above the 190 band: protection leaves both exactly - // where the canonical registry ordering put them. + // A global tool sorts lexicographically after structured_output, while a + // global section above the 190 band follows the capture instruction. ctx.tools.register({ name: 'zz_probe', description: 'probe', @@ -690,41 +620,6 @@ describe('in-process structured output', () => { await run.dispose() }) - it('a stripped instruction re-inserts at its band; an added duplicate entry collapses to one', async () => { - const { ctx, parent, adapter } = await setup([ - toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 3 }), - ]) - ctx.systemPrompt.section({ name: 'after-band', order: 200, text: 'AFTER-BAND' }) - // Strip the instruction section entirely AND add a wrong-schema - // duplicate tool entry alongside the registry's own: protection must - // restore the section INTO its band (before the order-200 section, not - // appended after it) and collapse the tools to exactly one entry - // carrying the run's schema. - ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { - const replaced = await next() - return { - sections: replaced.sections.filter(section => section.name !== `tool:${STRUCTURED_OUTPUT_TOOL}`), - tools: [ - ...replaced.tools, - { name: STRUCTURED_OUTPUT_TOOL, description: 'wrong', parameters: { type: 'object', properties: { bogus: { type: 'string' } } } }, - ], - variables: { ...replaced.variables }, - } - }) - const run = await ctx.subagents.start('spawn', structuredRequest(parent)) - const result = await run.result - expect(result.structured).toEqual({ answer: 3 }) - const request = adapter.requests[0]! - const entries = request.tools!.filter(tool => tool.name === STRUCTURED_OUTPUT_TOOL) - expect(entries).toHaveLength(1) - expect(entries[0]!.parameters).toEqual(SCHEMA) - const system = request.system ?? '' - const instructionAt = system.indexOf(STRUCTURED_OUTPUT_INSTRUCTION) - expect(instructionAt).toBeGreaterThanOrEqual(0) - expect(system.indexOf('AFTER-BAND')).toBeGreaterThan(instructionAt) - await run.dispose() - }) - it('a non-structured agent request keeps tools ABSENT when it had none (no tools: [] materialized)', async () => { const { parent, adapter } = await setup([textResponse('plain')]) parent.send([{ type: 'text', text: 'q' }]) diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 3405da6a8b..9e7dfe44bd 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -140,7 +140,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ toolsConfig: { mode: 'code' }, async mount() {}, note: - 'Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the registry\'s only canonical wire contribution; the other visible capabilities are declared in a protected TypeScript SDK section, and a program calls them through serialized bindings that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.', + 'Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the registry\'s only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through serialized bindings that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.', }, { pkg: '@deepseek-ai/dsh-tool-bash', From 6a8118c96758da720ed5ab85d4697f623edef627 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:41:27 +0800 Subject: [PATCH 64/64] docs: align scoped runtime contracts --- docs/architecture.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/core.md | 2 +- .../architecture/2026-06-14-session-persistence.md | 2 +- ...-07-05-prompt-variables-and-tool-guidance-ownership.md | 8 ++++---- .../feature/2026-06-21-subagent-capability-seam.md | 2 +- packages/core/agent-loop/README.md | 5 +++-- packages/core/agent/src/types.ts | 6 +++--- packages/core/session/src/index.ts | 3 +-- packages/subagent/subagent-spawn/tests/harness.ts | 7 ++++--- packages/subagent/subagent/src/types.ts | 2 +- packages/subagent/tool-subagent/README.md | 2 +- packages/ui/acp/README.md | 2 +- 13 files changed, 23 insertions(+), 22 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 2012be0946..a1f0cae9a8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -93,7 +93,7 @@ forever: checkpoint persistence and notify idle/running status ``` -Prompt assembly is single-path: 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). +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 global default persona (order 0, shadowable by a same-named agent-scoped section) — 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. diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e067708712..cf41cfdd5f 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -218,7 +218,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:591`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:590`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index e25e066a08..757c74f399 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -353,7 +353,7 @@ interface Agent { } ``` -`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`) is merge-extensible — plugins add creation options by declaration merging; the persona is NOT an agent option but the `dsh-system-prompt` plugin's `persona` config, shared context-wide. The `agent/*` event taxonomy (lifecycle emits incl. `agent/session-start`, serial `agent/pre-step`/`agent/turn-stop` checkpoints, and the `agent/prompt-submit`/`agent/request`/`agent/session-prefix`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy); turn/step boundaries are durable `session/event` records, not `agent/*` emits. +`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`) is merge-extensible — plugins add creation options by declaration merging. Persona is not an agent option: the `dsh-system-prompt` config supplies the global default, and an agent-scoped `deployment:persona` section may shadow it. The `agent/*` event taxonomy (lifecycle emits incl. `agent/session-start`, serial `agent/pre-step`/`agent/turn-stop` checkpoints, and the `agent/prompt-submit`/`agent/request`/`agent/session-prefix`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy); turn/step boundaries are durable `session/event` records, not `agent/*` emits. ## Interception decisions diff --git a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md index 1d3ce8929c..03d8a30b37 100644 --- a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md +++ b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md @@ -23,7 +23,7 @@ Key choices recorded here because they are durable, contested, and surprising: - **Append-only; a crashed turn is closed, never truncated.** Committed events — those at or below a flushed `turn/end` — are never rewritten. The loop only flushes at `turn/end`, so a crash can leave a durable log whose final turn never closed: real, fully-written events sit after the last `turn/end`. **A single turn can be huge in a long-horizon task** (many steps, large tool output spanning a long autonomous run), so discarding the interrupted turn would silently destroy a large amount of real work — truncating a turn is wrong. Instead, on reload `load` PRESERVES those events and CLOSES the orphaned turn by durably appending the minimal synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered, then a `step/end` if a step was still open, then a `turn/end` carrying the merge-extensible `{ kind: 'interrupted' }` reason (a marker that records the turn was cut short by a crash, not completed by the model — no loop ever emits it). The synthetic tool results matter for resume correctness: the loop logs the `assistant/message` (carrying the `tool-call` blocks) BEFORE running the tools, so a crash mid-tool leaves calls without results; `deriveMessages()` would then replay a dangling assistant tool-call, which every provider rejects as an invalid transcript on the next request. Answering each orphaned call with an error result keeps the rehydrated history valid. `load` returns the balanced log, so a resumed session is immediately usable. The ONLY thing discarded is a never-fully-written **torn tail fragment** — a final record whose bytes (JSONL) or row were never completely flushed; that fragment is not a valid event and is dropped before the synthetic closers are written. A parse error or `seq` gap in the COMMITTED region (at or before the last real `turn/end`) is genuine corruption and makes the session unloadable. - **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)` — `append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows. - **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).) -- **Resume is an async factory, not a change to synchronous create.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and starts a fresh agent on the resumed id (NOT `${agentId}-session`). The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. +- **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and starts a fresh agent on the resumed id (NOT `${agentId}-session`). The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. ## Alternatives considered diff --git a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index 53d3e63cdb..0961830c1a 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md +++ b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -16,11 +16,11 @@ The assembled system prompt had four defects, all of one family: facts the harne ## Decision -**One principle: every fact in the prompt has exactly one owner.** The model name and workspace are config/session facts → the harness exposes them as variables and the persona references them. Per-tool semantics and when-to-use → the tool's `description`. Cross-call habits a description cannot carry → the tool package's prompt section. Identity and behavior → the deployment's persona, and nothing else. +**One principle: every fact in the prompt has exactly one owner.** The model name and workspace are config/session facts → the harness exposes them as variables and the persona references them. Per-tool semantics and when-to-use → the tool's `description`. Cross-call habits a description cannot carry → the tool package's prompt section. Harness provenance → the static `harness:identity` section. Deployment role and behavior → the deployment's persona. ### Assemble context -`SystemPrompt.assemble(context)` takes an `AssembleContext` — declared EMPTY and merge-extensible in `dsh-system-prompt` (the package stays agnostic of who assembles); `dsh-agent` declaration-merges `agent?: Agent` onto it (a new type-level edge `agent → system-prompt`, no cycle — `tools` already depends on both). The loop passes `{ agent }` each step; section text providers become `string | ((context) => string)` (zero-arg providers stay valid), and the `system-prompt/assemble` waterfall gains the context parameter so a listener can filter or extend per agent. +`SystemPrompt.assemble(context)` takes a merge-extensible `AssembleContext`. `dsh-system-prompt` declares the optional `scope` selector used for scoped routing, while `dsh-agent` declaration-merges the optional typed `agent` field onto it (a type-level edge `agent → system-prompt`, with no runtime dependency cycle). The loop calls `assembleContextFor(agent)` each step so both fields identify the same agent; section text providers may read that context, and the `system-prompt/assemble` waterfall receives it so a listener can filter or extend per agent. ### Prompt variables @@ -30,7 +30,7 @@ Plugins contribute named values via `ctx.systemPrompt.variable(name, provider)`; ### Persona as the order-0 section -`dsh-system-prompt` itself registers the two harness-owned sections (they must survive a swapped loop plugin, so they do NOT live on `dsh-agent-loop`): the static `harness:identity` at order `-100` — every prompt opens by stating the agent is powered by the DeepSeek Harness SDK — and `deployment:persona` at order 0, whose text is the plugin's own `persona` config. The persona is per-DEPLOYMENT, not per-agent: every agent in the context (subagents included) renders the same one, `AgentOptions.systemPrompt` is deleted along with the per-agent forwarding plumbing (the app configs' `systemPrompt` keys become a `persona` key routed to this plugin through `dsh-agent-core`), and the ACP bridge and `dsh-tool-subagent` stop carrying persona configuration entirely. The loop's special-case join is deleted: `fullSystemPrompt ≡ renderPrompt(assembly)`, one ordered pipeline for everything the model sees, and `agent/pre-step` (compaction's token-pressure input) measures exactly the real prompt. Order bands are now convention: harness identity `-100`, persona `0`, tool guidance `100–199`; other negative orders also render before the persona. +`dsh-system-prompt` itself registers the two harness-owned sections (they must survive a swapped loop plugin, so they do NOT live on `dsh-agent-loop`): the static `harness:identity` at order `-100` — every prompt opens by stating the agent is powered by the DeepSeek Harness SDK — and the global default `deployment:persona` at order 0, whose text is the plugin's own `persona` config. `AgentOptions.systemPrompt` and the loop's special-case join are gone: `fullSystemPrompt ≡ renderPrompt(assembly)`, one ordered pipeline for everything the model sees, and `agent/pre-step` (compaction's token-pressure input) measures exactly the real prompt. An agent-scoped section with the same `deployment:persona` name shadows the default for that agent; programmatic setup may register one directly, and the subagent persona feature installs one before publishing an in-process child when the selected provider supports it. Order bands are convention: harness identity `-100`, persona `0`, tool guidance `100–199`; other negative orders also render before the persona. ### Tool guidance ownership @@ -56,7 +56,7 @@ Per-tool semantics and when-to-use live in tool DESCRIPTIONS, which already ship ## Shipped invariants -- `renderPrompt(assemble({ agent }))` for the coding-agent example renders the persona FIRST (with the agent's model name interpolated), then the fs/bash/web guidance sections; the loop has no other prompt-composition path. +- `renderPrompt(await assemble(assembleContextFor(agent)))` for the coding-agent example renders the harness identity, then the persona (with the agent's model name interpolated), then the fs/bash/web guidance sections; the loop has no other prompt-composition path. - The `subagent_fork` schema description says the child inherits the conversation; the `subagent` one says it does not. The tool follows its provider: absent before the backend activates, present after, gone when the backend unloads, re-worded from the fresh provider on reload. - Unknown/valueless/malformed/unbalanced `{{…}}` references throw with the section name in the message; duplicate section, variable, and tool-name registrations all throw. - Snapshot goldens are prompt-independent by construction: llm-replay keys replay on (turn, step) chunk streams and never re-verifies the outgoing request. diff --git a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md index c24c6102d9..652eac5522 100644 --- a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -41,7 +41,7 @@ A provider exposes `start(request) → Promise`. Promise fulfillmen ### Two kinds of optional capability, discovered two ways -- **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter`) ride on a static `provider.capabilities` descriptor. The service checks every requested one BEFORE delegating and **rejects loud** (`SubagentError('UNSUPPORTED_CAPABILITY')`) if the provider lacks it — never accepted-then-ignored. They must be checked before a run exists, which is why they cannot be runtime methods. +- **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter`, `persona`) ride on a static `provider.capabilities` descriptor. The service checks every requested one BEFORE delegating and **rejects loud** (`SubagentError('UNSUPPORTED_CAPABILITY')`) if the provider lacks it — never accepted-then-ignored. They must be checked before a run exists, which is why they cannot be runtime methods. - **Runtime features** (steering via `sendMessage`, follow-up via `resume`) are **optional methods** on `SubagentRun`. The method's presence IS the capability, and TypeScript narrowing is the discovery mechanism: a consumer cannot call an absent method without narrowing first, so there is no silent-degradation path and no separate flags object to keep in sync. ### Fork vs. fresh are separate backends, not a flag diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 871689008b..6832800d2e 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -40,7 +40,7 @@ interface Config { } ``` -Agents listed in config are auto-created at startup. `cwd` applies only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from the `assemble({ agent })` context — runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin. +Agents listed in config are auto-created at startup. `cwd` applies only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin. ### Exported concrete class @@ -63,7 +63,8 @@ forever: if every prompt blocked: 'turn/end'(rejected), no step ⟵ zero-step turn STEP loop: drain steering - assembly = systemPrompt.assemble({agent}) ⟵ renderPrompt(assembly) IS the full prompt + assembly = await systemPrompt.assemble(assembleContextFor(agent)) + ⟵ renderPrompt(assembly) IS the full prompt prefix ??= waterfall agent/session-prefix ⟵ once per instance (first step): frozen session prefix; on the header, never history await serial agent/pre-step(…, prefix) ⟵ surface mutation (compaction) outside the step; diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index f85cf3db2c..7a046dcc82 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -82,9 +82,9 @@ declare module '@deepseek-ai/dsh-system-prompt' { } /** - * Options an agent is created with. The persona is NOT here — it is the - * deployment's `persona` config on the dsh-system-prompt plugin, shared by - * every agent in the context. + * Options an agent is created with. The persona is NOT here: the + * dsh-system-prompt config supplies the global default, and a scoped + * `deployment:persona` section may override it for one agent. * Merge-extensible: plugins declare extra fields via declaration merging. */ export interface AgentOptions { diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 6ce38bbffe..a6dff5cd27 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -363,8 +363,7 @@ export class Session { * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of * `data` that entered the log, so reading `event.data` back sees the logged * value, never the caller's still-mutable input. - * @throws if `type` is not a string, or if `data` or surface metadata is not - * losslessly JSON-serializable + * @throws if `data` or surface metadata is not losslessly JSON-serializable * (BigInt, function, symbol, undefined, negative zero, non-finite number, * circular reference, sparse array, or an exotic object such as * Map/Set/Date/class instance). One recursive pass reads, validates, and diff --git a/packages/subagent/subagent-spawn/tests/harness.ts b/packages/subagent/subagent-spawn/tests/harness.ts index b3e9d4ec24..e4ee2e6ab7 100644 --- a/packages/subagent/subagent-spawn/tests/harness.ts +++ b/packages/subagent/subagent-spawn/tests/harness.ts @@ -23,9 +23,10 @@ export async function spawnHarness(workdir: string): Promise { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) - // The deployment persona is context-wide (parent AND spawned children - // render it), so it stays neutral for both roles; the delegation nudge - // lives in the e2e's user prompt and the subagent tool's own description. + // This harness installs only the global default persona, so both parent and + // spawned children render it. It stays neutral for both roles; the + // delegation nudge lives in the e2e's user prompt and the subagent tool's + // own description. await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent. Report only when the requested work is done.' }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index f08d8a0622..3bdc78569a 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -57,7 +57,7 @@ export interface SubagentStartRequest { * afterward. */ readonly signal: AbortSignal - /** Per-child agent options (model, system prompt). */ + /** Per-child agent options (model and plugin-defined extension fields). */ readonly agentOptions?: AgentOptions /** * Optional structured-output schema — an object-rooted JSON Schema within the diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index fbd01a0cb2..c234d5832b 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -10,7 +10,7 @@ The description is derived from `provider.inheritsParentContext`: spawn and ACP ## Lifecycle -`execute` passes the tool execution's abort signal directly as the required `SubagentStartRequest.signal`, awaits `ctx.subagents.start(...)`, then awaits `run.result` inside a `try/finally` that always calls `run.dispose()`. The same signal therefore covers startup and live execution, while disposal guarantees quiescence on success, failure, and abort. +`execute` passes the tool execution's abort signal when present, otherwise supplies an inert signal to satisfy the required `SubagentStartRequest.signal`. It awaits `ctx.subagents.start(...)`, then awaits `run.result` inside a `try/finally` that always calls `run.dispose()`. The selected signal therefore covers startup and live execution, while disposal guarantees quiescence on success, failure, and abort. A non-`completed` stop reason becomes an `isError` tool result; partial child output is never reported as success. The current tool blocks the parent turn until collection finishes; background and polling modes are deferred. diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index efd5e5247a..f4947e5d25 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -16,7 +16,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline ` |---|---|---| | `model` | — | Model name for created agents (must have a registered adapter). | -(No persona key: the deployment persona is `dsh-system-prompt`'s own `persona` config — a context-wide section, so ACP-created agents render it without the bridge carrying prompt text.) +(No persona key: `dsh-system-prompt`'s own `persona` config supplies the global default section, so ACP-created agents render it without the bridge carrying prompt text. An agent-scoped same-name section may still shadow that default.) The `initialize` handshake reports a fixed server identity (`agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' }`) — branding is a literal at the `initialize` site, not config.