Merge remote-tracking branch 'origin/worktree-agent-scope-design' into codex/trim-ai-prose

This commit is contained in:
Tianyi Cui
2026-07-13 12:07:23 +08:00
33 changed files with 148 additions and 126 deletions

View File

@@ -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> | 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> | 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<AgentHandle>` — 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<AgentHandle>` — 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.

View File

@@ -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> | 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> | 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
}

View File

@@ -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: Agent, turn: number): Promise<ContinuationStop | undefined> | ContinuationStop | undefined
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number): ContinuationStop | undefined
// ---- error notifications (emit) ----
/**

View File

@@ -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<ContinuationStop | undefined>
expectTypeOf<AsyncTurnStopListener>().not.toExtend<TurnStopListener>()
expectTypeOf<ReturnType<TurnStopListener>>().toEqualTypeOf<ContinuationStop | undefined>()
})
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'])

View File

@@ -13,9 +13,9 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool-
### Public API
- `ctx.systemPrompt.section(section: PromptSection): () => Promise<void> | 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> | 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> | 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<PromptAssembly>` 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

View File

@@ -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> | 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> | 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> | 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
}

View File

@@ -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.

View File

@@ -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({})
})

View File

@@ -15,11 +15,11 @@ tools:
### Public API
- `ctx.tools.register(definition: ToolDefinition): () => Promise<void> | 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> | 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> | 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<ToolExecutionResult>` 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

View File

@@ -147,7 +147,7 @@ declare module 'cordis' {
*/
'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
/**
* 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<ToolRegistry>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): Promise<void> | void
'tools/result'(this: Scoped<ToolRegistry>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): 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> | 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> | 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> | 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<void> {
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)}`)
}
}))
}
}
/**

View File

@@ -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]

View File

@@ -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<string> {
}
describe('scoped tool registration', () => {
it('keeps final-result observers synchronous', () => {
type ToolResultListener = Events['tools/result']
type AsyncToolResultListener = () => Promise<void>
expectTypeOf<AsyncToolResultListener>().not.toExtend<ToolResultListener>()
expectTypeOf<ReturnType<ToolResultListener>>().toEqualTypeOf<undefined>()
})
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('<unprintable thrown value>')
})

View File

@@ -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()
})