Merge remote-tracking branch 'origin/master' into codex/cordis-catalog-type-links
This commit is contained in:
@@ -8,7 +8,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
|
||||
| Group | Role | Release expectation |
|
||||
|---|---|---|
|
||||
| [`core/`](core/README.md) | Product API spine: session, system-prompt, tools, agent, and the concrete loop | Product — stable surface |
|
||||
| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface |
|
||||
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
|
||||
| [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface |
|
||||
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
|
||||
|
||||
@@ -63,8 +63,12 @@ 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.',
|
||||
summary: 'Agent service (`ctx.agents`): tracks live agents and carries the initiating Agent through one process-local asynchronous driver chain.',
|
||||
methods: [
|
||||
'currentInitiator(): Agent | undefined',
|
||||
'requireInitiator(): Agent',
|
||||
'withInitiator<T>(agent: Agent, operation: () => T): T',
|
||||
'withoutInitiator<T>(operation: () => T): T',
|
||||
'setFactory(factory: AgentFactory): () => void',
|
||||
'async create(options: CreateAgentOptions): Promise<AgentHandle>',
|
||||
'async resume(options: ResumeAgentOptions): Promise<AgentHandle>',
|
||||
|
||||
@@ -8,11 +8,11 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, and co
|
||||
| `session/` | Event-sourced session log + in-memory store | `ctx.sessions` |
|
||||
| `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` |
|
||||
| `tools/` | Scoped tool registry + pre-policy, guards, around-dispatch, post-policy, and final-result observation | `ctx.tools` |
|
||||
| `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
|
||||
| `agent/` | Agent interface, live registry, process-local initiator scope, `agent/*` event vocabulary | `ctx.agents` |
|
||||
| `agent-loop/` | Concrete plugin implementing the public `Agent` contract and owning the loop driver | `ctx.agentLoop` |
|
||||
|
||||
`scope/` is the one non-service package here: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) the registries and the loop build per-agent scoping on — it sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle.
|
||||
|
||||
`agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop; everything else in `core/` is interface/vocabulary. Plugins depend on the `agent` vocabulary, never on `agent-loop` directly, so the loop stays swappable.
|
||||
`agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop. It runs each driver inside `ctx.agents.withInitiator()`. Extension plugins depend on `agent`, including when they need the initiating Agent, and never on `agent-loop` directly, so the loop stays swappable.
|
||||
|
||||
The default composition that wires this spine into a runnable agent lives in [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md): one bundle plugin that loads the control spine plus selected default capabilities (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + the local [skill family](../skill/README.md) + `tool-bash` + workspace-context + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. It sits in `examples/` — ready-to-run demo/reference bundles — not in `core/`: `core/` ships the swappable spine pieces, while a demo bundle picks one concrete composition of them and adds a front door.
|
||||
|
||||
@@ -50,7 +50,7 @@ The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publicati
|
||||
|
||||
### Loop lifecycle (`loop.ts`)
|
||||
|
||||
The internal loop driver runs one agent for its whole lifetime:
|
||||
The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`, so process-local asynchronous continuations can recover the initiating Agent. Creation, persistence load, and unpublished setup stay outside the driver boundary; explicit Agent fields remain authoritative at service, worker, process, persistence, and wire boundaries. The [agent service](../agent/README.md#initiating-agent-scope) owns propagation, teardown, and detached-work rules.
|
||||
|
||||
Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history.
|
||||
|
||||
|
||||
@@ -387,7 +387,7 @@ export class ReactLoopAgent implements Agent {
|
||||
[startDriver](): void {
|
||||
if (this._status === 'disposed') return
|
||||
this.driverStarted = true
|
||||
this.done = runLoop(this.loopCtx, this, {
|
||||
this.done = this.loopCtx.agents.withInitiator(this, () => runLoop(this.loopCtx, this, {
|
||||
inbox: this.#inbox,
|
||||
maxParallelToolCalls: this.maxParallelToolCalls,
|
||||
setStatus: (status) => { this.setStatus(status) },
|
||||
@@ -400,7 +400,7 @@ export class ReactLoopAgent implements Agent {
|
||||
withToolBatch: run => this.withToolBatch(run),
|
||||
// Pre-step cancellation re-parks without emitting a status transition.
|
||||
settleIdle: () => { this.settleIdleWaiters() },
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
335
packages/core/agent-loop/tests/agent-initiator.spec.ts
Normal file
335
packages/core/agent-loop/tests/agent-initiator.spec.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context, type Fiber } from 'cordis'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
interface Harness {
|
||||
ctx: Context
|
||||
agentsFiber: Fiber
|
||||
loopFiber: Fiber
|
||||
}
|
||||
|
||||
async function harness(adapter: LlmAdapter): Promise<Harness> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
const agentsFiber = await ctx.plugin(AgentRegistry)
|
||||
const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return { ctx, agentsFiber, loopFiber }
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function send(agent: Agent, text: string): void {
|
||||
agent.send([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
/** Adapter that holds both drivers at the same awaited continuation. */
|
||||
class OverlapAdapter extends LlmAdapter {
|
||||
private readonly bothStarted = Promise.withResolvers<boolean>()
|
||||
private starts = 0
|
||||
readonly observations: { sessionId: SessionId | undefined; before: Agent; after: Agent }[] = []
|
||||
|
||||
constructor(private readonly ctx: Context) {
|
||||
super()
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
const before = this.ctx.agents.requireInitiator()
|
||||
this.starts += 1
|
||||
if (this.starts === 2) this.bothStarted.resolve(true)
|
||||
await this.bothStarted.promise
|
||||
await Promise.resolve()
|
||||
const after = this.ctx.agents.requireInitiator()
|
||||
this.observations.push({ sessionId: options.sessionId, before, after })
|
||||
yield* textResponse('done')
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only transport that materializes ambient identity at its request boundary. */
|
||||
class TestCapabilityTransport {
|
||||
readonly requests: { path: string; headers: Record<string, string> }[] = []
|
||||
|
||||
constructor(private readonly agents: AgentRegistry) {}
|
||||
|
||||
async request(path: string): Promise<Record<string, string>> {
|
||||
await Promise.resolve()
|
||||
const headers = {
|
||||
'X-Harness-Session-Id': this.agents.requireInitiator().session.id,
|
||||
}
|
||||
this.requests.push({ path, headers })
|
||||
return headers
|
||||
}
|
||||
}
|
||||
|
||||
/** Adapter whose first call waits for cancellation and whose later calls complete. */
|
||||
class ReloadAdapter extends LlmAdapter {
|
||||
readonly firstStarted = Promise.withResolvers<boolean>()
|
||||
firstAgentDuringAbort: Agent | undefined
|
||||
laterAgent: Agent | undefined
|
||||
calls = 0
|
||||
agents: AgentRegistry | undefined
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
const agents = this.agents
|
||||
if (agents === undefined) throw new Error('agent service missing')
|
||||
this.calls += 1
|
||||
if (this.calls === 1) {
|
||||
this.firstStarted.resolve(true)
|
||||
try {
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
const abort = (): void => { reject(new Error('aborted')) }
|
||||
if (options.signal?.aborted === true) abort()
|
||||
else options.signal?.addEventListener('abort', abort, { once: true })
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
await Promise.resolve()
|
||||
this.firstAgentDuringAbort = agents.requireInitiator()
|
||||
throw error
|
||||
}
|
||||
return
|
||||
}
|
||||
await Promise.resolve()
|
||||
this.laterAgent = agents.requireInitiator()
|
||||
yield* textResponse('reloaded')
|
||||
}
|
||||
}
|
||||
|
||||
describe('AgentLoop initiator scope', () => {
|
||||
it('keeps overlapping driver continuations bound to their exact Agents', async () => {
|
||||
const ctx = new Context()
|
||||
const adapter = new OverlapAdapter(ctx)
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
const a = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' })
|
||||
const b = ctx.agentLoop.create(SessionId('b'), { provider: 'mock', model: 'mock' })
|
||||
const idleA = waitForIdle(ctx, a)
|
||||
const idleB = waitForIdle(ctx, b)
|
||||
send(a, 'a')
|
||||
send(b, 'b')
|
||||
await Promise.all([idleA, idleB])
|
||||
|
||||
expect(adapter.observations).toHaveLength(2)
|
||||
expect(adapter.observations).toEqual(expect.arrayContaining([
|
||||
{ sessionId: a.session.id, before: a, after: a },
|
||||
{ sessionId: b.session.id, before: b, after: b },
|
||||
]))
|
||||
expect(ctx.agents.currentInitiator()).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps child setup under the parent boundary and restores the parent while the child driver remains active', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('spawn', 'spawn-child', {}),
|
||||
toolCallResponse('observe', 'observe-child', {}),
|
||||
textResponse('child done'),
|
||||
textResponse('parent done'),
|
||||
])
|
||||
const { ctx } = await harness(adapter)
|
||||
let parentDuringSetup: Agent | undefined
|
||||
let explicitChild: Agent | undefined
|
||||
let childDuringDriver: Agent | undefined
|
||||
let parentWhileChildDriverActive: Agent | undefined
|
||||
let child: Agent | undefined
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'spawn-child',
|
||||
description: 'create one child agent',
|
||||
parameters: {},
|
||||
execute: async (_args, exec) => {
|
||||
if (exec.agent === undefined) throw new Error('parent agent missing')
|
||||
const handle = await exec.agent.ctx.agents.create({
|
||||
sessionId: SessionId('child-session'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
setup: (agentCtx) => {
|
||||
parentDuringSetup = ctx.agents.requireInitiator()
|
||||
explicitChild = agentCtx.agent
|
||||
agentCtx.tools.register(defineTool({
|
||||
name: 'observe-child',
|
||||
description: 'observe child execution identity',
|
||||
parameters: {},
|
||||
execute: async () => {
|
||||
await Promise.resolve()
|
||||
childDuringDriver = ctx.agents.requireInitiator()
|
||||
return [{ type: 'text', text: 'observed' }]
|
||||
},
|
||||
}))
|
||||
},
|
||||
})
|
||||
child = handle.agent
|
||||
parentWhileChildDriverActive = ctx.agents.requireInitiator()
|
||||
send(handle.agent, 'run child')
|
||||
await handle.agent.whenIdle()
|
||||
await handle.dispose()
|
||||
return [{ type: 'text', text: 'child completed' }]
|
||||
},
|
||||
}))
|
||||
|
||||
const parentHandle = await ctx.agents.create({
|
||||
sessionId: SessionId('parent-session'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const idle = waitForIdle(ctx, parentHandle.agent)
|
||||
send(parentHandle.agent, 'spawn')
|
||||
await idle
|
||||
|
||||
expect(parentDuringSetup).toBe(parentHandle.agent)
|
||||
expect(explicitChild).toBe(child)
|
||||
expect(childDuringDriver).toBe(child)
|
||||
expect(parentWhileChildDriverActive).toBe(parentHandle.agent)
|
||||
expect(ctx.agents.currentInitiator()).toBeUndefined()
|
||||
await parentHandle.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps agentless direct tools ambient-free and builds trusted transport headers internally', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('capability', 'capability-request', { path: '/v1/capability' }),
|
||||
textResponse('done'),
|
||||
])
|
||||
const { ctx } = await harness(adapter)
|
||||
const transport = new TestCapabilityTransport(ctx.agents)
|
||||
let directAmbient: Agent | undefined
|
||||
let captured: Agent | undefined
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'agentless-probe',
|
||||
description: 'observe an agentless call',
|
||||
parameters: {},
|
||||
execute: async () => {
|
||||
await Promise.resolve()
|
||||
directAmbient = ctx.agents.currentInitiator()
|
||||
return [{ type: 'text', text: 'ok' }]
|
||||
},
|
||||
}))
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'capability-request',
|
||||
description: 'call the test capability transport',
|
||||
parameters: { path: { type: 'string' } },
|
||||
execute: async (args) => {
|
||||
captured = ctx.agents.requireInitiator()
|
||||
const path = (args as { path: string }).path
|
||||
const headers = await transport.request(path)
|
||||
return [{ type: 'text', text: JSON.stringify(headers) }]
|
||||
},
|
||||
}))
|
||||
|
||||
const direct = await ctx.tools.execute({
|
||||
callId: CallId('direct'),
|
||||
name: 'agentless-probe',
|
||||
arguments: {},
|
||||
})
|
||||
expect(direct.isError).toBe(false)
|
||||
expect(directAmbient).toBeUndefined()
|
||||
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('transport-session'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const idle = waitForIdle(ctx, handle.agent)
|
||||
send(handle.agent, 'call transport')
|
||||
await idle
|
||||
|
||||
expect(transport.requests).toEqual([{
|
||||
path: '/v1/capability',
|
||||
headers: { 'X-Harness-Session-Id': 'transport-session' },
|
||||
}])
|
||||
const schema = adapter.requests[0]?.tools?.find(tool => tool.name === 'capability-request')
|
||||
expect(JSON.stringify(schema?.parameters)).not.toMatch(/session|harness/i)
|
||||
const call = handle.agent.session.events.find(event => event.type === 'tool/call')
|
||||
expect(call?.type === 'tool/call' ? call.data.arguments : undefined)
|
||||
.toBe(JSON.stringify({ path: '/v1/capability' }))
|
||||
expect(captured).toBe(handle.agent)
|
||||
|
||||
await handle.dispose()
|
||||
expect(captured?.status).toBe('disposed')
|
||||
expect(ctx.agents.currentInitiator()).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('drains the old driver before disabling ALS during agent-service restart', async () => {
|
||||
const adapter = new ReloadAdapter()
|
||||
const { ctx, agentsFiber, loopFiber } = await harness(adapter)
|
||||
const oldService = ctx.agents
|
||||
adapter.agents = oldService
|
||||
const oldHandle = await ctx.agents.create({
|
||||
sessionId: SessionId('before-restart-session'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const oldAgent = oldHandle.agent
|
||||
send(oldAgent, 'block')
|
||||
await adapter.firstStarted.promise
|
||||
|
||||
await agentsFiber.restart()
|
||||
await loopFiber.await()
|
||||
expect(adapter.firstAgentDuringAbort?.id).toBe(oldAgent.id)
|
||||
expect(adapter.firstAgentDuringAbort?.session).toBe(oldAgent.session)
|
||||
expect(oldAgent.status).toBe('disposed')
|
||||
expect(() => oldService.currentInitiator()).toThrow('agent initiator scope is disposed')
|
||||
expect(ctx.agents).not.toBe(oldService)
|
||||
adapter.agents = ctx.agents
|
||||
|
||||
const newHandle = await ctx.agents.create({
|
||||
sessionId: SessionId('after-restart-session'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const newAgent = newHandle.agent
|
||||
const idle = waitForIdle(ctx, newAgent)
|
||||
send(newAgent, 'continue')
|
||||
await idle
|
||||
expect(adapter.laterAgent?.id).toBe(newAgent.id)
|
||||
expect(adapter.laterAgent?.session).toBe(newAgent.session)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps ALS readable while root disposal drains sibling AgentLoop fibers', async () => {
|
||||
const ctx = new Context()
|
||||
const adapter = new ReloadAdapter()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const service = ctx.agents
|
||||
adapter.agents = service
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('root-dispose-session'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const agent = handle.agent
|
||||
send(agent, 'block')
|
||||
await adapter.firstStarted.promise
|
||||
|
||||
await ctx.fiber.dispose()
|
||||
expect(adapter.firstAgentDuringAbort?.id).toBe(agent.id)
|
||||
expect(adapter.firstAgentDuringAbort?.session).toBe(agent.session)
|
||||
expect(agent.status).toBe('disposed')
|
||||
expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
|
||||
})
|
||||
})
|
||||
@@ -264,6 +264,7 @@ describe('Agent', () => {
|
||||
// early-return branch.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const session = ctx.sessions.create(SessionId('test'))
|
||||
const prepared = prepareReactLoopAgent(
|
||||
ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# dsh-agent
|
||||
|
||||
Agent interface, registry, and `agent/*` event vocabulary. Every plugin (UI, hooks, orchestrators) programs against the `Agent` handle defined here — it has zero loop dependency, so the loop is swappable.
|
||||
Agent interface, registry, process-local initiator scope, and `agent/*` event vocabulary. Every plugin (UI, hooks, orchestrators) programs against the `Agent` handle defined here — it has zero loop dependency, so the loop is swappable.
|
||||
|
||||
## Service: `AgentRegistry` (ctx key: `agents`)
|
||||
|
||||
Tracks live agents so UI, hook, and orchestrator plugins can find them without importing the concrete loop package.
|
||||
Tracks live agents and carries the initiating Agent through asynchronous driver work without importing the concrete loop package.
|
||||
|
||||
### Public API
|
||||
|
||||
@@ -17,6 +17,17 @@ The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-
|
||||
- `ctx.agents.list(): Agent[]`
|
||||
- `ctx.agents.roots(): Agent[]` — live agents created without an owning agent context; a resumed lineage-bearing session can still be a runtime root.
|
||||
|
||||
#### Initiating Agent scope
|
||||
|
||||
`AgentLoop` runs each concrete driver's complete lifetime inside an initiator boundary. Concurrent drivers remain isolated: a child driver's continuations carry the child, while the parent continuation regains the parent as soon as `withInitiator()` returns; drain tracking continues until the child driver's Promise settles. Creation, persistence load, and unpublished setup remain outside the child's boundary, so setup initiated by a parent inherits the parent while `agentCtx.agent` identifies the child explicitly.
|
||||
|
||||
- `ctx.agents.currentInitiator(): Agent | undefined` — read the inherited initiator without requiring one.
|
||||
- `ctx.agents.requireInitiator(): Agent` — read it or throw `no initiating agent is active`.
|
||||
- `ctx.agents.withInitiator(agent, operation)` — run with one exact Agent and preserve the operation's exact synchronous value or Promise.
|
||||
- `ctx.agents.withoutInitiator(operation)` — hide an inherited initiator for unrelated process-local work.
|
||||
|
||||
The scope carries the `Agent` itself and is process-local. Ambient presence is neither liveness proof nor authorization; explicit Agent fields remain authoritative at service, worker, process, persistence, and wire boundaries. Teardown rejects new boundaries, lets injected dependents and returned-Promise boundaries drain, then disables the underlying `AsyncLocalStorage`; unreturned work remains owned by the subsystem that detached it. If a boundary's inherited async chain starts an owning Cordis fiber's unload, that nested boundary chain is released from the drain so the unload cannot wait on itself; its continuations observe the disposed service after teardown. The [initiator-scope decision](../../../docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md) owns the detailed boundary and teardown contract.
|
||||
|
||||
#### Factory seam (creation)
|
||||
|
||||
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.
|
||||
@@ -72,6 +83,8 @@ The handle every plugin programs against:
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Initiator scope is process-local** — workers, child processes, HTTP, durable queues, and restarts materialize any required identity explicitly.
|
||||
- **Ambient identity may outlive liveness** — consumers still check `agent.status`, cancellation, and the owning capability contract before lifecycle-sensitive work.
|
||||
- **Inter-agent channels beyond delegation** — shared state, streaming child output, and background/poll semantics remain outside the current synchronous `ctx.subagents` seam.
|
||||
- **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead.
|
||||
- **No public step-only abort** — `cancel()` clears ALL pending work (queued + steering + in-flight); an abort that preserves queued prompts returns only with a named consumer ([stop-surface RFC](../../../docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md)).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-agent",
|
||||
"description": "Agent interface, registry, and event vocabulary for the DeepSeek Harness",
|
||||
"description": "Agent interface, registry, initiator scope, and event vocabulary for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* Agent registry service. Tracks live agents so plugins can find them without
|
||||
* depending on the concrete loop package. Agent creation belongs to the loop.
|
||||
* Agent service: live registry, factory delegation, and process-local
|
||||
* initiator scope. Concrete creation and driving belong to the loop.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-agent
|
||||
*/
|
||||
|
||||
import { Context, getTraceable, Service, symbols } from 'cordis'
|
||||
import { Context, FiberState, getTraceable, Service, symbols } from 'cordis'
|
||||
import type { Fiber } from 'cordis'
|
||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||
import { isPromise } from 'node:util/types'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -173,6 +176,8 @@ export interface AgentFactory {
|
||||
|
||||
/** Thrown when create/resume is called before an agent factory is registered. */
|
||||
const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plugin)'
|
||||
const NO_INITIATOR_MESSAGE = 'no initiating agent is active'
|
||||
const DISPOSED_INITIATOR_MESSAGE = 'agent initiator scope is disposed'
|
||||
|
||||
/** All mutable lifecycle state for one exact registry entry. */
|
||||
interface AgentEntry {
|
||||
@@ -186,21 +191,38 @@ interface AgentEntry {
|
||||
detachRequested: boolean
|
||||
}
|
||||
|
||||
/** One tracked boundary plus its inherited nesting chain. */
|
||||
interface InitiatorRun {
|
||||
active: boolean
|
||||
readonly parent: InitiatorRun | undefined
|
||||
}
|
||||
|
||||
/** Plain holder prevents Cordis from tracing the factory field before the caller context is known. */
|
||||
interface FactorySlot {
|
||||
readonly target: AgentFactory
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent registry (`ctx.agents`): tracks live agents so UI, hook, and
|
||||
* orchestrator plugins can find them without depending on the concrete loop
|
||||
* package. Agent *creation* is provided by whichever plugin implements the
|
||||
* {@link AgentFactory} (`@deepseek-ai/dsh-agent-loop`), registered via
|
||||
* {@link setFactory}.
|
||||
* Agent service (`ctx.agents`): tracks live agents and carries the initiating
|
||||
* Agent through one process-local asynchronous driver chain. Agent *creation*
|
||||
* is provided by whichever plugin implements the {@link AgentFactory}
|
||||
* (`@deepseek-ai/dsh-agent-loop`), registered via {@link setFactory}.
|
||||
*
|
||||
* Initiator methods provide same-process causal attribution only. Ambient
|
||||
* presence is neither liveness proof nor authorization; subjects and owners
|
||||
* remain explicit, as does identity at worker, process, persistence, and wire
|
||||
* boundaries. Returned Promise boundaries drain during teardown, except a
|
||||
* nested lineage that starts an owning-fiber unload is excluded from its own drain.
|
||||
*/
|
||||
export class AgentRegistry extends Service {
|
||||
private store = new Map<SessionId, AgentEntry>()
|
||||
private factory: FactorySlot | undefined
|
||||
private readonly initiators = new AsyncLocalStorage<Agent | undefined>()
|
||||
private readonly initiatorRuns = new AsyncLocalStorage<InitiatorRun>()
|
||||
private initiatorState: 'active' | 'closing' | 'disposed' = 'active'
|
||||
private activeInitiatorRuns = 0
|
||||
private initiatorDrain: PromiseWithResolvers<void> | undefined
|
||||
private initiatorDisposal: Promise<void> | undefined
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'agents')
|
||||
@@ -211,6 +233,75 @@ export class AgentRegistry extends Service {
|
||||
// accessor body never needs to resolve a scope itself. Effect-scoped:
|
||||
// unwinds with this service's fiber.
|
||||
ctx.accessor('agent', { get: () => undefined })
|
||||
ctx.on('internal/status', (fiber) => {
|
||||
if (fiber.state === FiberState.UNLOADING && this.hasLifecycleAncestor(fiber)) {
|
||||
this.closeInitiators()
|
||||
}
|
||||
})
|
||||
ctx.effect(function* (this: AgentRegistry) {
|
||||
yield () => this.disposeInitiators()
|
||||
yield () => { this.closeInitiators() }
|
||||
}.bind(this), 'agents.initiatorLifecycle()')
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the Agent that initiated the inherited asynchronous driver chain.
|
||||
* Use this optional form for logging, tracing, metrics, or host attribution
|
||||
* that also supports agentless calls. When a parent creates a child, setup
|
||||
* reports the causal parent while `agentCtx.agent` identifies the child.
|
||||
* @returns the inherited Agent, or `undefined` outside an initiator boundary
|
||||
* and inside an explicit clearing boundary.
|
||||
* @throws when this service instance has been disposed.
|
||||
*/
|
||||
currentInitiator(): Agent | undefined {
|
||||
this.assertInitiatorsReadable()
|
||||
return this.initiators.getStore()
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the initiating Agent and fail when no initiator boundary is active.
|
||||
* Use this for private helpers contractually below a driver, or for a
|
||||
* deployment-owned outbound request whose contract forbids agentless calls.
|
||||
* Generic or direct-call seams use optional lookup or explicit request fields.
|
||||
* @returns the inherited Agent.
|
||||
* @throws when no initiator is active or this service instance has been disposed.
|
||||
*/
|
||||
requireInitiator(): Agent {
|
||||
const agent = this.currentInitiator()
|
||||
if (agent === undefined) throw new Error(NO_INITIATOR_MESSAGE)
|
||||
return agent
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an operation with one exact Agent as its process-local initiator. The
|
||||
* exact synchronous value or Promise returned by the operation is preserved.
|
||||
* Custom drivers and test harnesses wrap their complete returned foreground
|
||||
* lifetime.
|
||||
* A queue or wire receiver may establish this boundary only after validating
|
||||
* explicit identity and resolving the exact live Agent; this method does neither.
|
||||
* Detached work remains owned by the subsystem that starts it.
|
||||
* @param agent - initiating Agent to inherit; presence is neither liveness proof nor authorization.
|
||||
* @param operation - synchronous or asynchronous operation to invoke.
|
||||
* @returns the exact value returned by `operation`.
|
||||
* @throws when the initiator scope is closing/disposed, or when `operation` throws.
|
||||
*/
|
||||
withInitiator<T>(agent: Agent, operation: () => T): T {
|
||||
return this.runWithInitiator(agent, operation)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an operation inside a boundary that hides any inherited initiating
|
||||
* Agent. The exact synchronous value or Promise is preserved.
|
||||
* Use this while creating lazy shared timers, queue pumps, pool maintenance,
|
||||
* watchers, or exporters so they do not inherit the first Agent that happens
|
||||
* to initialize them. It clears only initiator attribution, not explicit
|
||||
* fields, and does not own or drain detached resources.
|
||||
* @param operation - synchronous or asynchronous operation to invoke without an initiator.
|
||||
* @returns the exact value returned by `operation`.
|
||||
* @throws when the initiator scope is closing/disposed, or when `operation` throws.
|
||||
*/
|
||||
withoutInitiator<T>(operation: () => T): T {
|
||||
return this.runWithInitiator(undefined, operation)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -471,6 +562,92 @@ export class AgentRegistry extends Service {
|
||||
.filter(entry => entry.owner === undefined)
|
||||
.map(entry => entry.agent)
|
||||
}
|
||||
|
||||
/** Reject new initiator boundaries while inherited continuations drain. */
|
||||
private closeInitiators(): void {
|
||||
if (this.initiatorState === 'active') this.initiatorState = 'closing'
|
||||
}
|
||||
|
||||
/** Wait for returned-Promise boundaries, then invalidate retained references. */
|
||||
private disposeInitiators(): Promise<void> {
|
||||
return (this.initiatorDisposal ??= (async () => {
|
||||
this.closeInitiators()
|
||||
this.releaseReentrantInitiatorRuns()
|
||||
if (this.activeInitiatorRuns !== 0) {
|
||||
this.initiatorDrain ??= Promise.withResolvers<void>()
|
||||
await this.initiatorDrain.promise
|
||||
}
|
||||
this.initiatorState = 'disposed'
|
||||
this.initiators.disable()
|
||||
this.initiatorRuns.disable()
|
||||
})())
|
||||
}
|
||||
|
||||
/** Establish one tracked initiator or clearing boundary. */
|
||||
private runWithInitiator<T>(agent: Agent | undefined, operation: () => T): T {
|
||||
if (this.initiatorState !== 'active') throw new Error(DISPOSED_INITIATOR_MESSAGE)
|
||||
const run: InitiatorRun = {
|
||||
active: true,
|
||||
parent: this.initiatorRuns.getStore(),
|
||||
}
|
||||
this.activeInitiatorRuns += 1
|
||||
let result: T
|
||||
try {
|
||||
result = this.initiatorRuns.run(run, () => this.initiators.run(agent, operation))
|
||||
} catch (error: unknown) {
|
||||
this.releaseInitiatorRun(run)
|
||||
throw error
|
||||
}
|
||||
if (isPromise(result)) {
|
||||
try {
|
||||
void Promise.prototype.then.call(
|
||||
result,
|
||||
() => { this.releaseInitiatorRun(run) },
|
||||
() => { this.releaseInitiatorRun(run) },
|
||||
)
|
||||
} catch {
|
||||
// A branded Promise may expose a failing @@species. Observer setup did
|
||||
// not attach, so preserve the exact return without leaking the run.
|
||||
this.releaseInitiatorRun(run)
|
||||
}
|
||||
} else {
|
||||
this.releaseInitiatorRun(run)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Whether one unloading fiber owns this service's lifecycle. */
|
||||
private hasLifecycleAncestor(candidate: Fiber): boolean {
|
||||
let fiber = this.ctx.fiber
|
||||
while (true) {
|
||||
if (fiber === candidate) return true
|
||||
const parent = fiber.parent.fiber
|
||||
if (parent === fiber) return false
|
||||
fiber = parent
|
||||
}
|
||||
}
|
||||
|
||||
private assertInitiatorsReadable(): void {
|
||||
if (this.initiatorState === 'disposed') throw new Error(DISPOSED_INITIATOR_MESSAGE)
|
||||
}
|
||||
|
||||
/** Exclude the boundary chain that initiated this teardown from its own drain. */
|
||||
private releaseReentrantInitiatorRuns(): void {
|
||||
let run = this.initiatorRuns.getStore()
|
||||
while (run !== undefined) {
|
||||
this.releaseInitiatorRun(run)
|
||||
run = run.parent
|
||||
}
|
||||
}
|
||||
|
||||
private releaseInitiatorRun(run: InitiatorRun): void {
|
||||
if (!run.active) return
|
||||
run.active = false
|
||||
this.activeInitiatorRuns -= 1
|
||||
if (this.activeInitiatorRuns !== 0) return
|
||||
this.initiatorDrain?.resolve()
|
||||
this.initiatorDrain = undefined
|
||||
}
|
||||
}
|
||||
|
||||
export default AgentRegistry
|
||||
|
||||
265
packages/core/agent/tests/agent-initiator.spec.ts
Normal file
265
packages/core/agent/tests/agent-initiator.spec.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
function agent(id: string): Agent {
|
||||
return { id: SessionId(id) } as Agent
|
||||
}
|
||||
|
||||
async function harness(): Promise<{
|
||||
ctx: Context
|
||||
service: AgentRegistry
|
||||
dispose: () => Promise<void>
|
||||
}> {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(AgentRegistry)
|
||||
return {
|
||||
ctx,
|
||||
service: ctx.agents,
|
||||
dispose: fiber.dispose,
|
||||
}
|
||||
}
|
||||
|
||||
/** Fail a lifecycle regression promptly instead of waiting for Vitest's suite timeout. */
|
||||
async function promptly<T>(task: Promise<T>): Promise<T> {
|
||||
const timeout = Promise.withResolvers<never>()
|
||||
const timer = setTimeout(() => { timeout.reject(new Error('initiator teardown did not settle promptly')) }, 1000)
|
||||
try {
|
||||
return await Promise.race([task, timeout.promise])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
describe('AgentRegistry initiator scope', () => {
|
||||
it('reports an absent initiator and requires an active boundary', async () => {
|
||||
const { service, dispose } = await harness()
|
||||
expect(service.currentInitiator()).toBeUndefined()
|
||||
expect(() => service.requireInitiator()).toThrow('no initiating agent is active')
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('preserves exact synchronous and Promise return identities across await', async () => {
|
||||
const { service, dispose } = await harness()
|
||||
const initiator = agent('identity')
|
||||
const value = { result: true }
|
||||
expect(service.withInitiator(initiator, () => {
|
||||
expect(service.requireInitiator()).toBe(initiator)
|
||||
return value
|
||||
})).toBe(value)
|
||||
|
||||
const promise = service.withInitiator(initiator, async () => {
|
||||
expect(service.requireInitiator()).toBe(initiator)
|
||||
await Promise.resolve()
|
||||
expect(service.requireInitiator()).toBe(initiator)
|
||||
return value
|
||||
})
|
||||
expect(service.withInitiator(initiator, () => promise)).toBe(promise)
|
||||
await expect(promise).resolves.toBe(value)
|
||||
expect(service.currentInitiator()).toBeUndefined()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('tracks a branded Promise without calling its overridable then property', async () => {
|
||||
const { service, dispose } = await harness()
|
||||
const initiator = agent('overridden-then')
|
||||
const release = Promise.withResolvers<boolean>()
|
||||
void Object.defineProperty(release.promise, 'then', {
|
||||
value: () => { throw new Error('overridden then called') },
|
||||
})
|
||||
|
||||
const pending = service.withInitiator(initiator, () => release.promise)
|
||||
expect(pending).toBe(release.promise)
|
||||
|
||||
let disposed = false
|
||||
const disposal = dispose().then(() => { disposed = true })
|
||||
await Promise.resolve()
|
||||
expect(disposed).toBe(false)
|
||||
|
||||
release.resolve(true)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
void Promise.prototype.then.call(pending, resolve, reject)
|
||||
})
|
||||
await disposal
|
||||
expect(disposed).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves a settled branded Promise when its species blocks observer construction', async () => {
|
||||
const { service, dispose } = await harness()
|
||||
const initiator = agent('invalid-species')
|
||||
const promise = Promise.resolve()
|
||||
const constructor = {}
|
||||
Object.defineProperty(constructor, Symbol.species, {
|
||||
get: () => { throw new Error('invalid species') },
|
||||
})
|
||||
void Object.defineProperty(promise, 'constructor', { value: constructor })
|
||||
|
||||
expect(service.withInitiator(initiator, () => promise)).toBe(promise)
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('isolates overlapping initiators', async () => {
|
||||
const { service, dispose } = await harness()
|
||||
const a = agent('a')
|
||||
const b = agent('b')
|
||||
const bothStarted = Promise.withResolvers<boolean>()
|
||||
const release = Promise.withResolvers<boolean>()
|
||||
let starts = 0
|
||||
const run = (initiator: Agent): Promise<void> => service.withInitiator(initiator, async () => {
|
||||
expect(service.requireInitiator()).toBe(initiator)
|
||||
starts += 1
|
||||
if (starts === 2) bothStarted.resolve(true)
|
||||
await release.promise
|
||||
expect(service.requireInitiator()).toBe(initiator)
|
||||
})
|
||||
|
||||
const pending = [run(a), run(b)]
|
||||
await bothStarted.promise
|
||||
expect(service.currentInitiator()).toBeUndefined()
|
||||
release.resolve(true)
|
||||
await Promise.all(pending)
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('restores nested and explicitly cleared boundaries', async () => {
|
||||
const { service, dispose } = await harness()
|
||||
const parent = agent('parent')
|
||||
const child = agent('child')
|
||||
|
||||
service.withInitiator(parent, () => {
|
||||
expect(service.requireInitiator()).toBe(parent)
|
||||
service.withInitiator(child, () => { expect(service.requireInitiator()).toBe(child) })
|
||||
expect(service.requireInitiator()).toBe(parent)
|
||||
service.withoutInitiator(() => {
|
||||
expect(service.currentInitiator()).toBeUndefined()
|
||||
expect(() => service.requireInitiator()).toThrow('no initiating agent is active')
|
||||
})
|
||||
expect(service.requireInitiator()).toBe(parent)
|
||||
})
|
||||
expect(service.currentInitiator()).toBeUndefined()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('restores the parent after synchronous throws and rejected operations', async () => {
|
||||
const { service, dispose } = await harness()
|
||||
const parent = agent('parent')
|
||||
const child = agent('child')
|
||||
const syncError = new Error('sync failure')
|
||||
const asyncError = new Error('async failure')
|
||||
|
||||
service.withInitiator(parent, () => {
|
||||
expect(() => service.withInitiator(child, () => { throw syncError })).toThrow(syncError)
|
||||
expect(service.requireInitiator()).toBe(parent)
|
||||
})
|
||||
await expect(service.withInitiator(child, async () => {
|
||||
await Promise.resolve()
|
||||
throw asyncError
|
||||
})).rejects.toBe(asyncError)
|
||||
expect(service.currentInitiator()).toBeUndefined()
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('stops new boundaries, drains active Promises, and invalidates retained references', async () => {
|
||||
const { ctx, service, dispose } = await harness()
|
||||
const initiator = agent('draining')
|
||||
const release = Promise.withResolvers<boolean>()
|
||||
const pending = service.withInitiator(initiator, async () => {
|
||||
await release.promise
|
||||
expect(service.requireInitiator()).toBe(initiator)
|
||||
})
|
||||
let disposed = false
|
||||
const disposal = dispose().then(() => { disposed = true })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(() => service.withInitiator(initiator, () => 1)).toThrow('agent initiator scope is disposed')
|
||||
expect(() => service.withoutInitiator(() => 1)).toThrow('agent initiator scope is disposed')
|
||||
expect(disposed).toBe(false)
|
||||
expect(ctx.get('agents')).toBeUndefined()
|
||||
release.resolve(true)
|
||||
await pending
|
||||
await disposal
|
||||
expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
|
||||
expect(() => service.requireInitiator()).toThrow('agent initiator scope is disposed')
|
||||
})
|
||||
|
||||
it('drains cross-realm Promise boundaries before disposal', async () => {
|
||||
const { service, dispose } = await harness()
|
||||
const initiator = agent('cross-realm')
|
||||
const release = Promise.withResolvers<boolean>()
|
||||
const operation = runInNewContext(
|
||||
'(async () => { await release; inspect() })',
|
||||
{
|
||||
release: release.promise,
|
||||
inspect: () => { expect(service.requireInitiator()).toBe(initiator) },
|
||||
},
|
||||
) as () => Promise<void>
|
||||
const pending = service.withInitiator(initiator, operation)
|
||||
expect(pending).not.toBeInstanceOf(Promise)
|
||||
|
||||
let disposed = false
|
||||
const disposal = dispose().then(() => { disposed = true })
|
||||
await Promise.resolve()
|
||||
expect(disposed).toBe(false)
|
||||
|
||||
release.resolve(true)
|
||||
await pending
|
||||
await disposal
|
||||
expect(disposed).toBe(true)
|
||||
})
|
||||
|
||||
it('does not self-deadlock when a boundary returns service disposal', async () => {
|
||||
const { service, dispose } = await harness()
|
||||
const initiator = agent('service-disposer')
|
||||
|
||||
const returned = service.withInitiator(initiator, dispose)
|
||||
await promptly(returned)
|
||||
|
||||
expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
|
||||
})
|
||||
|
||||
it('does not self-deadlock when nested boundaries return ancestor disposal', async () => {
|
||||
const { ctx, service } = await harness()
|
||||
const parent = agent('parent-disposer')
|
||||
const child = agent('child-disposer')
|
||||
let disposal: Promise<void> | undefined
|
||||
|
||||
const returned = service.withInitiator(parent, () => service.withInitiator(child, () => {
|
||||
disposal = ctx.fiber.dispose()
|
||||
return disposal
|
||||
}))
|
||||
expect(returned).toBe(disposal)
|
||||
|
||||
await promptly(returned)
|
||||
expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
|
||||
})
|
||||
|
||||
it('excludes an asynchronous teardown initiator while draining unrelated boundaries', async () => {
|
||||
const { ctx, service } = await harness()
|
||||
const initiator = agent('async-disposer')
|
||||
const unrelated = agent('unrelated')
|
||||
const release = Promise.withResolvers<boolean>()
|
||||
const pending = service.withInitiator(unrelated, async () => {
|
||||
await release.promise
|
||||
expect(service.requireInitiator()).toBe(unrelated)
|
||||
})
|
||||
|
||||
const returned = service.withInitiator(initiator, async () => {
|
||||
await Promise.resolve()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
let disposed = false
|
||||
void returned.then(() => { disposed = true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(disposed).toBe(false)
|
||||
|
||||
release.resolve(true)
|
||||
await pending
|
||||
await promptly(returned)
|
||||
|
||||
expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
|
||||
})
|
||||
})
|
||||
@@ -16,7 +16,7 @@ Read this package for the whole plugin tree and its composition order.
|
||||
@deepseek-ai/dsh-tools registry + guarded pre/around/post/final-result pipeline
|
||||
@deepseek-ai/dsh-skill skill provider registry
|
||||
@deepseek-ai/dsh-skill-local local filesystem skill provider
|
||||
@deepseek-ai/dsh-agent agent registry + agent/* event vocabulary
|
||||
@deepseek-ai/dsh-agent agent registry + initiator scope + agent/* events
|
||||
@deepseek-ai/dsh-tasks generic background-task registry
|
||||
@deepseek-ai/dsh-invariants dev-mode event-contract assertions
|
||||
@deepseek-ai/dsh-tool-bash the model-facing bash schema
|
||||
|
||||
Reference in New Issue
Block a user