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.`)