fix(scope): align merged agent event contracts

This commit is contained in:
Tianyi Cui
2026-07-11 23:22:57 +08:00
parent 56e34619d7
commit 172005e0e6
9 changed files with 70 additions and 21 deletions
@@ -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<T>` 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<T>` 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.
@@ -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<Message[]> {
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),
)
}
@@ -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' }),
@@ -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<Conte
return ctx
}
function agentForCwd(cwd: string): never {
return { session: { header: { cwd } } } as never
function agentForCwd(cwd: string): Agent {
return { session: { header: { cwd } } } as unknown as Agent
}
async function composePrefix(ctx: Context, cwd: string, signal = new AbortController().signal): Promise<Message[]> {
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),
)
}
+1
View File
@@ -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:^",
@@ -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<Message[]> {
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),
)
}
+3
View File
@@ -23,6 +23,9 @@
{
"path": "../acp"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/agent-core"
},
@@ -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<Message[]> {
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),
)
}
+3
View File
@@ -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