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