Merge remote-tracking branch 'origin/worktree-agent-scope-design' into codex/trim-ai-prose
This commit is contained in:
@@ -65,10 +65,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
key: 'agents',
|
||||
summary: 'Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package.',
|
||||
methods: [
|
||||
'setFactory(factory: AgentFactory): () => Promise<void> | void',
|
||||
'setFactory(factory: AgentFactory): () => void',
|
||||
'async create(options: CreateAgentOptions): Promise<AgentHandle>',
|
||||
'async resume(options: ResumeAgentOptions): Promise<AgentHandle>',
|
||||
'register(agent: Agent): () => Promise<void> | void',
|
||||
'register(agent: Agent): () => void',
|
||||
'enter(agent: Agent): () => void',
|
||||
'announce(agent: Agent): void',
|
||||
'get(id: AgentId): Agent | undefined',
|
||||
@@ -169,8 +169,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
key: 'skills',
|
||||
summary: 'Registry of skill providers.',
|
||||
methods: [
|
||||
'registerProvider(provider: SkillProvider): () => Promise<void> | void',
|
||||
'register(skill: SkillRegistration): () => Promise<void> | void',
|
||||
'registerProvider(provider: SkillProvider): () => void',
|
||||
'register(skill: SkillRegistration): () => void',
|
||||
'async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]>',
|
||||
'async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined>',
|
||||
],
|
||||
@@ -179,7 +179,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
key: 'subagents',
|
||||
summary: 'Named provider registry and capability-checked start surface.',
|
||||
methods: [
|
||||
'registerProvider(provider: SubagentProvider): () => Promise<void> | void',
|
||||
'registerProvider(provider: SubagentProvider): () => void',
|
||||
'getProvider(name: string): SubagentProvider | undefined',
|
||||
'list(): string[]',
|
||||
'async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>',
|
||||
@@ -189,9 +189,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
key: 'systemPrompt',
|
||||
summary: 'Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, named prompt variables, and owner-final contributions; the agent loop calls `assemble(context)` once per step.',
|
||||
methods: [
|
||||
'section(section: PromptSection): () => Promise<void> | void',
|
||||
'tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise<void> | void',
|
||||
'variable(name: string, provider: (context: AssembleContext) => string | undefined): () => Promise<void> | void',
|
||||
'section(section: PromptSection): () => void',
|
||||
'tools(provider: (context: AssembleContext) => ToolProviderResult): () => void',
|
||||
'variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void',
|
||||
'async assemble(context: AssembleContext = {}): Promise<PromptAssembly>',
|
||||
],
|
||||
},
|
||||
@@ -199,9 +199,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
key: 'tools',
|
||||
summary: 'Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute` → `tools/result` pipeline.',
|
||||
methods: [
|
||||
'register(definition: ToolDefinition): () => Promise<void> | void',
|
||||
'restrict(filter: ToolRestriction): () => Promise<void> | void',
|
||||
'guard(guard: ToolGuard): () => Promise<void> | void',
|
||||
'register(definition: ToolDefinition): () => void',
|
||||
'restrict(filter: ToolRestriction): () => void',
|
||||
'guard(guard: ToolGuard): () => void',
|
||||
'get(name: string, scope?: ScopeKey): ToolDefinition | undefined',
|
||||
'schemas(scope?: ScopeKey): ToolSchema[]',
|
||||
'async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>',
|
||||
@@ -311,7 +311,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
{
|
||||
name: 'agent/turn-stop',
|
||||
mode: 'serial',
|
||||
signature: '\'agent/turn-stop\'(this: Scoped<Agent>, agent: Agent, turn: number): Promise<ContinuationStop | undefined> | ContinuationStop | undefined',
|
||||
signature: '\'agent/turn-stop\'(this: Scoped<Agent>, agent: Agent, turn: number): ContinuationStop | undefined',
|
||||
summary: 'Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` waterfall, any `continue.reason`, and the pending-steering continuation override have been folded.',
|
||||
},
|
||||
{
|
||||
@@ -442,9 +442,9 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'tools/result',
|
||||
mode: 'parallel',
|
||||
signature: '\'tools/result\'(this: Scoped<ToolRegistry>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): Promise<void> | void',
|
||||
summary: 'Awaited notification of the authoritative FINAL tool outcome, after the complete pre/execute/post pipeline, final lossless-JSON validation, and outer error normalization.',
|
||||
mode: 'emit',
|
||||
signature: '\'tools/result\'(this: Scoped<ToolRegistry>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): undefined',
|
||||
summary: 'Synchronous notification of the authoritative FINAL tool outcome, after the complete pre/execute/post pipeline, final lossless-JSON validation, and outer error normalization.',
|
||||
},
|
||||
{
|
||||
name: 'workflow/agent-end',
|
||||
|
||||
@@ -186,7 +186,7 @@ export function sandboxDefineTool(options: Parameters<typeof defineTool>[0]): To
|
||||
* @param tool - a definition produced by {@link sandboxDefineTool}; anything else is rejected.
|
||||
* @returns the registry disposer for the registration.
|
||||
*/
|
||||
export function sandboxRegisterTool(ctx: Context, tool: unknown): () => Promise<void> | void {
|
||||
export function sandboxRegisterTool(ctx: Context, tool: unknown): () => void {
|
||||
assertDynamicTool(tool)
|
||||
return ctx.tools.register(tool)
|
||||
}
|
||||
@@ -210,7 +210,7 @@ const CTX_VERBS = new Set(['on', 'once', 'provide', 'timeout', 'interval', 'setT
|
||||
function sandboxTools(ctx: Context): Record<string, unknown> {
|
||||
// Resolve reads and writes through the mount's own scope.
|
||||
return {
|
||||
register: (tool: unknown): (() => Promise<void> | void) => sandboxRegisterTool(ctx, tool),
|
||||
register: (tool: unknown): (() => void) => sandboxRegisterTool(ctx, tool),
|
||||
schemas: () => ctx.tools.schemas(scopeOf(ctx)),
|
||||
get: (name: string) => ctx.tools.schemas(scopeOf(ctx)).find(schema => schema.name === name),
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) ----
|
||||
/**
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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({})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}`)
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>')
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.skills.registerProvider(provider): () => Promise<void> | void` Registers a readonly provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registry borrows the provider object and invokes its methods directly. The registration is effect-scoped and HMR-safe, and the exact Cordis disposer supports ordered composite teardown.
|
||||
- `ctx.skills.registerProvider(provider): () => void` Registers a readonly provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registry borrows the provider object and invokes its methods directly. The registration is effect-scoped and HMR-safe, and the exact Cordis disposer supports ordered composite teardown.
|
||||
- `ctx.skills.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns model-invocable summaries for the current workspace, merged across providers and sorted by name.
|
||||
- `ctx.skills.get(name, { cwd?, signal? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it, including disabled-for-model skills.
|
||||
- `ctx.skills.register(skill): () => Promise<void> | void` Registers a readonly runtime embedded skill, adding `provider: "runtime"` when omitted. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown.
|
||||
- `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill, adding `provider: "runtime"` when omitted. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown.
|
||||
|
||||
### Config
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ export class SkillService extends Service {
|
||||
* @returns the exact Cordis effect disposer that unregisters this provider;
|
||||
* composite effects may yield it directly to preserve teardown ordering.
|
||||
*/
|
||||
registerProvider(provider: SkillProvider): () => Promise<void> | void {
|
||||
registerProvider(provider: SkillProvider): () => void {
|
||||
const name = provider.name
|
||||
if (name === RUNTIME_PROVIDER) {
|
||||
throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)
|
||||
@@ -210,6 +210,7 @@ export class SkillService extends Service {
|
||||
}
|
||||
ctx.emit('skill/provider-added', provider)
|
||||
}, 'skills.registerProvider()')
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
}
|
||||
|
||||
@@ -225,7 +226,7 @@ export class SkillService extends Service {
|
||||
* contribution and invalidates caches; composite effects may yield it
|
||||
* directly to preserve teardown ordering.
|
||||
*/
|
||||
register(skill: SkillRegistration): () => Promise<void> | void {
|
||||
register(skill: SkillRegistration): () => void {
|
||||
validateRuntimeSkill(skill)
|
||||
const existing = this.runtime.get(skill.name)
|
||||
if (existing !== undefined) {
|
||||
@@ -245,6 +246,7 @@ export class SkillService extends Service {
|
||||
invalidateCache()
|
||||
}
|
||||
}, 'skills.register()')
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ describe('SkillService registry', () => {
|
||||
},
|
||||
})).toThrow('reserved')
|
||||
|
||||
await disposeMemory()
|
||||
disposeMemory()
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['same-rank-skill', 'shadowed'])
|
||||
})
|
||||
|
||||
@@ -538,7 +538,7 @@ describe('SkillService registry', () => {
|
||||
path: 'memory://runtime-skill',
|
||||
metadata: { owner: 'tests' },
|
||||
})
|
||||
await disposeRuntime()
|
||||
disposeRuntime()
|
||||
await ctx.skills.list({ cwd: '/tmp/first-cache-key' })
|
||||
await ctx.skills.list({ cwd: '/tmp/second-cache-key' })
|
||||
|
||||
@@ -615,7 +615,7 @@ describe('SkillService registry', () => {
|
||||
|
||||
const pending = ctx.skills.list()
|
||||
await started
|
||||
await dispose()
|
||||
dispose()
|
||||
release?.()
|
||||
|
||||
expect(await pending).toEqual([])
|
||||
@@ -673,9 +673,9 @@ describe('SkillService registry', () => {
|
||||
|
||||
const disposeFirst = ctx.skills.register({ name: 'same-skill', description: 'First', source: 'runtime', content: 'first' })
|
||||
const disposeSecond = ctx.skills.register({ name: 'same-skill', description: 'Second', source: 'runtime', content: 'second' })
|
||||
await disposeSecond()
|
||||
disposeSecond()
|
||||
expect((await ctx.skills.get('same-skill'))?.description).toBe('First')
|
||||
await disposeFirst()
|
||||
disposeFirst()
|
||||
expect(await ctx.skills.get('same-skill')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -149,7 +149,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
|
||||
// The capture COMMIT observes the immutable, authoritative result after the
|
||||
// complete pipeline and outer error normalization. This notification cannot
|
||||
// transform the outcome, so there is no wrapper outside the commit verdict.
|
||||
childCtx.on('tools/result', function (this: unknown, exec, result): void {
|
||||
childCtx.on('tools/result', function (this: unknown, exec, result) {
|
||||
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
|
||||
const entry = staged.get(exec)
|
||||
if (entry === undefined) return
|
||||
|
||||
@@ -742,7 +742,7 @@ describe('in-process structured output', () => {
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
// A backend hot-reload mid-run must not unregister the capture tool out
|
||||
// from under the live child: the registration rides the CHILD's fiber.
|
||||
await disposeProvider()
|
||||
disposeProvider()
|
||||
const result = await run.result
|
||||
expect(result.structured).toEqual({ answer: 4 })
|
||||
const child = ctx.agents.get(run.id)!
|
||||
|
||||
@@ -134,8 +134,9 @@ export class SubagentService extends Service {
|
||||
* @param provider - the trusted provider implementation.
|
||||
* @returns the exact Cordis effect disposer.
|
||||
*/
|
||||
registerProvider(provider: SubagentProvider): () => Promise<void> | void {
|
||||
registerProvider(provider: SubagentProvider): () => void {
|
||||
const name = provider.name
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return this.ctx.effect(function* (this: SubagentService) {
|
||||
if (this.providers.has(name)) {
|
||||
throw new SubagentError(`a subagent provider named "${name}" is already registered`, 'DUPLICATE_PROVIDER')
|
||||
|
||||
@@ -74,7 +74,7 @@ describe('SubagentService', () => {
|
||||
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
|
||||
expect(provider.startCount).toBe(1)
|
||||
|
||||
await dispose()
|
||||
dispose()
|
||||
expect(added).toEqual(['alpha'])
|
||||
expect(removed).toEqual(['alpha'])
|
||||
expect(subagents.getProvider('alpha')).toBeUndefined()
|
||||
@@ -213,7 +213,7 @@ describe('SubagentService', () => {
|
||||
ctx.on('subagent/provider-removed', name => void heard.push(name))
|
||||
const dispose = subagents.registerProvider(new StubProvider('contained'))
|
||||
|
||||
await dispose()
|
||||
dispose()
|
||||
await Promise.resolve()
|
||||
expect(heard).toEqual(['contained'])
|
||||
expect(warnings.some(message => message.includes('sync boom'))).toBe(true)
|
||||
|
||||
@@ -212,7 +212,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// available — deriving the wording from THAT provider — and unregister it
|
||||
// when the provider goes away, so the description can never outlive or
|
||||
// predate the provider it describes.
|
||||
let disposeTool: (() => Promise<void> | void) | undefined
|
||||
let disposeTool: (() => void) | undefined
|
||||
const mount = (provider: SubagentProvider): void => {
|
||||
const wording = providerWording(provider.inheritsParentContext)
|
||||
disposeTool = ctx.tools.register(defineTool({
|
||||
@@ -283,7 +283,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
})
|
||||
ctx.on('subagent/provider-removed', (name) => {
|
||||
if (name !== config.provider || disposeTool === undefined) return
|
||||
void disposeTool()
|
||||
disposeTool()
|
||||
disposeTool = undefined
|
||||
})
|
||||
const present = ctx.subagents.getProvider(config.provider)
|
||||
|
||||
Reference in New Issue
Block a user