fix(scope): align merged agent event contracts
This commit is contained in:
@@ -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.
|
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
|
### 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 |
|
| Event family | Scope source |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `agent/*`, including `agent/turn-stop` | The event's agent |
|
| `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 |
|
| `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` |
|
| `system-prompt/assemble` | `AssembleContext.scope` |
|
||||||
| `session/created`, `session/event`, `session/flush` | The owner scope captured when the session enters the store |
|
| `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.
|
`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
|
### `tools/result` observes the authoritative live outcome
|
||||||
|
|
||||||
@@ -450,11 +451,16 @@ execute(input):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ordinaryDecision = await tools/pre-execute(execution)
|
gate = await tools/pre-execute(execution)
|
||||||
if ordinaryDecision allows:
|
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)
|
denial = firstRegisteredGuardDenial(execution)
|
||||||
else:
|
else:
|
||||||
denial = ordinaryDecision.denial
|
denial = decision.denial
|
||||||
|
|
||||||
if denial exists:
|
if denial exists:
|
||||||
result = errorResult(denial)
|
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
|
### 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.
|
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 Loader from '@cordisjs/plugin-loader'
|
||||||
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||||
import * as agentCore from '../src/index.ts'
|
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'
|
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||||
|
|
||||||
async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
|
async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
|
||||||
|
const agent = { session: { header: { cwd } } } as unknown as Agent
|
||||||
const empty: Message[] = []
|
const empty: Message[] = []
|
||||||
return await ctx.waterfall(
|
return await agentEvents(ctx, agent).waterfall(
|
||||||
'agent/session-prefix', { session: { header: { cwd } } } as never,
|
'agent/session-prefix', empty, new AbortController().signal,
|
||||||
empty, new AbortController().signal, () => Promise.resolve(empty),
|
() => Promise.resolve(empty),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -311,6 +311,36 @@ describe('agent/session-start', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('agent/session-prefix', () => {
|
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 () => {
|
it('composes once per loop instance and fronts every request; the header records it; history stays untouched', async () => {
|
||||||
const adapter = new MockAdapter([
|
const adapter = new MockAdapter([
|
||||||
toolCallResponse('c1', 'echo', { text: 'ping' }),
|
toolCallResponse('c1', 'echo', { text: 'ping' }),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Context } from 'cordis'
|
|||||||
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
|
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
|
||||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||||
|
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||||
import SkillService from '@deepseek-ai/dsh-skill'
|
import SkillService from '@deepseek-ai/dsh-skill'
|
||||||
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
|
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
|
||||||
import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
|
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
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
function agentForCwd(cwd: string): never {
|
function agentForCwd(cwd: string): Agent {
|
||||||
return { session: { header: { cwd } } } as never
|
return { session: { header: { cwd } } } as unknown as Agent
|
||||||
}
|
}
|
||||||
|
|
||||||
async function composePrefix(ctx: Context, cwd: string, signal = new AbortController().signal): Promise<Message[]> {
|
async function composePrefix(ctx: Context, cwd: string, signal = new AbortController().signal): Promise<Message[]> {
|
||||||
|
const agent = agentForCwd(cwd)
|
||||||
const empty: Message[] = []
|
const empty: Message[] = []
|
||||||
return await ctx.waterfall(
|
return await agentEvents(ctx, agent).waterfall(
|
||||||
'agent/session-prefix', agentForCwd(cwd), empty, signal,
|
'agent/session-prefix', empty, signal,
|
||||||
() => Promise.resolve(empty),
|
() => Promise.resolve(empty),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
"@cordisjs/plugin-loader": "workspace:^",
|
"@cordisjs/plugin-loader": "workspace:^",
|
||||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||||
"@deepseek-ai/dsh-acp": "workspace:^",
|
"@deepseek-ai/dsh-acp": "workspace:^",
|
||||||
|
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||||
"@deepseek-ai/dsh-agent-core": "workspace:^",
|
"@deepseek-ai/dsh-agent-core": "workspace:^",
|
||||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { join } from 'node:path'
|
|||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import Loader from '@cordisjs/plugin-loader'
|
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 { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||||
import * as acpAgent from '../src/index.ts'
|
import * as acpAgent from '../src/index.ts'
|
||||||
@@ -36,10 +37,11 @@ async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function composePrefix(ctx: Context): Promise<Message[]> {
|
async function composePrefix(ctx: Context): Promise<Message[]> {
|
||||||
|
const agent = { session: { header: { cwd: '/tmp' } } } as unknown as Agent
|
||||||
const empty: Message[] = []
|
const empty: Message[] = []
|
||||||
return await ctx.waterfall(
|
return await agentEvents(ctx, agent).waterfall(
|
||||||
'agent/session-prefix', { session: { header: { cwd: '/tmp' } } } as never,
|
'agent/session-prefix', empty, new AbortController().signal,
|
||||||
empty, new AbortController().signal, () => Promise.resolve(empty),
|
() => Promise.resolve(empty),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@
|
|||||||
{
|
{
|
||||||
"path": "../acp"
|
"path": "../acp"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "../../core/agent"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "../../core/agent-core"
|
"path": "../../core/agent-core"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { join } from 'node:path'
|
|||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import Loader from '@cordisjs/plugin-loader'
|
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 type { Message } from '@deepseek-ai/dsh-llm'
|
||||||
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||||
import * as stdioAgent from '../src/index.ts'
|
import * as stdioAgent from '../src/index.ts'
|
||||||
@@ -43,10 +43,11 @@ async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function composePrefix(ctx: Context): Promise<Message[]> {
|
async function composePrefix(ctx: Context): Promise<Message[]> {
|
||||||
|
const agent = { session: { header: { cwd: '/tmp' } } } as unknown as Agent
|
||||||
const empty: Message[] = []
|
const empty: Message[] = []
|
||||||
return await ctx.waterfall(
|
return await agentEvents(ctx, agent).waterfall(
|
||||||
'agent/session-prefix', { session: { header: { cwd: '/tmp' } } } as never,
|
'agent/session-prefix', empty, new AbortController().signal,
|
||||||
empty, new AbortController().signal, () => Promise.resolve(empty),
|
() => Promise.resolve(empty),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+3
@@ -1243,6 +1243,9 @@ importers:
|
|||||||
'@deepseek-ai/dsh-acp':
|
'@deepseek-ai/dsh-acp':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../acp
|
version: link:../acp
|
||||||
|
'@deepseek-ai/dsh-agent':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../core/agent
|
||||||
'@deepseek-ai/dsh-agent-core':
|
'@deepseek-ai/dsh-agent-core':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../core/agent-core
|
version: link:../../core/agent-core
|
||||||
|
|||||||
Reference in New Issue
Block a user