feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced read/kill/wait/list, attachSurface misconfiguration fence, reported-flag notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/ task_kill, completion-notice injection, background prompt habit). Producers opt in via their own enableRunInBackground config: bash (stream kind; seam slimmed to resolve/run/start returning a BashProcess handle, bash_output/bash_kill deleted) and subagent (final-output kind; done settles after run.dispose()). Owner disposal drains tasks through the new awaited ctx.agents.onCleanup seam in the loop's disposal chain. Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
This commit is contained in:
@@ -16,7 +16,7 @@ This is the package to read to see **the whole plugin tree at once** — the tea
|
||||
@deepseek-ai/dsh-tools tool registry + tools/pre-execute/post-execute
|
||||
@deepseek-ai/dsh-agent agent registry + agent/* event vocabulary
|
||||
@deepseek-ai/dsh-invariants dev-mode event-contract assertions
|
||||
@deepseek-ai/dsh-tool-bash the model-facing bash/bash_output/bash_kill schemas
|
||||
@deepseek-ai/dsh-tool-bash the model-facing bash schema (background runs register with ctx.tasks)
|
||||
@deepseek-ai/dsh-agent-loop THE concrete loop (gets the forwarded `agents`)
|
||||
(dsh-system-prompt gets the forwarded `persona`)
|
||||
```
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tool-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tool-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
@@ -41,7 +43,9 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
*
|
||||
* Loads the fixed set of services every harness agent needs — `timer`, the LLM
|
||||
* service, the session store, system-prompt assembly, the tool registry, the
|
||||
* agent registry, the dev-mode invariants, the model-facing `bash` tool
|
||||
* agent registry, the background task registry + its `task_*` control tools,
|
||||
* the dev-mode invariants, the model-facing `bash` tool
|
||||
* schemas, and the concrete `agent-loop` — and forwards the loop's `agents`
|
||||
* list as its OWN config (default `[]`), so each app supplies its own
|
||||
* pre-created agents.
|
||||
@@ -50,8 +51,10 @@ import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt, { type Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import TaskService from '@deepseek-ai/dsh-tasks'
|
||||
import * as invariants from '@deepseek-ai/dsh-invariants'
|
||||
import * as toolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as toolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agent-loop'
|
||||
|
||||
export const name = 'agent-core'
|
||||
@@ -103,7 +106,9 @@ export function apply(ctx: Context, config: Config): void {
|
||||
})
|
||||
ctx.plugin(ToolRegistry)
|
||||
ctx.plugin(AgentRegistry)
|
||||
ctx.plugin(TaskService)
|
||||
ctx.plugin(invariants)
|
||||
ctx.plugin(toolBash)
|
||||
ctx.plugin(toolTasks)
|
||||
ctx.plugin(AgentLoop, { agents: config.agents ?? [] })
|
||||
}
|
||||
|
||||
@@ -81,7 +81,9 @@ describe('dsh-agent-core bundle', () => {
|
||||
})
|
||||
}
|
||||
const assembly = await ctx.get('systemPrompt')!.assemble()
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha'])
|
||||
// The rest-slot is lexicographic: the bundle's own task control tools
|
||||
// (tool-tasks needs no executor, unlike the pending bash tool) follow alpha.
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'task_kill', 'task_list', 'task_output'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -40,6 +40,12 @@
|
||||
},
|
||||
{
|
||||
"path": "../../bash/tool-bash"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tool-tasks"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ This is the only package in the harness that contains concrete loop logic. Every
|
||||
|
||||
`AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
|
||||
|
||||
- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions? }): AgentHandle` — programmatic create on a caller-supplied `sessionId` (e.g. an ACP-generated id), NOT `${id}-session`; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix. Returns an [`AgentHandle`](../agent/README.md) — the owner disposes it to tear down exactly this agent (stop loop + await quiescence + unregister + remove session).
|
||||
- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions? }): AgentHandle` — programmatic create on a caller-supplied `sessionId` (e.g. an ACP-generated id), NOT `${id}-session`; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix. Returns an [`AgentHandle`](../agent/README.md) — the owner disposes it to tear down exactly this agent (stop loop + await quiescence + drain the `ctx.agents.onCleanup` registrations + unregister + remove session).
|
||||
- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`.
|
||||
|
||||
The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle) — only the programmatic factory callers (the ACP bridge and in-process subagent backends) hold a handle and own per-agent teardown.
|
||||
|
||||
@@ -274,11 +274,15 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* chain — the runtime awaits each disposer's returned promise before the next:
|
||||
*
|
||||
* yield session-detach (disposed LAST — detach onAppend + remove entry)
|
||||
* yield register (disposed 2nd — unregister)
|
||||
* yield register (disposed 3rd — unregister)
|
||||
* yield cleanup-drain (disposed 2nd — await ctx.agents.drainCleanups)
|
||||
* yield stop-and-drain (disposed FIRST — request loop stop, await agent.done)
|
||||
*
|
||||
* So on teardown: the loop is stopped and AWAITED to exit (its final
|
||||
* `session/flush` + `turn/end` fire through the still-attached `onAppend`),
|
||||
* THEN the awaited per-agent cleanups drain (background tasks cancel and
|
||||
* reach quiescence while the agent is STILL registered — a settling task's
|
||||
* completion notice can still find it, and `agent/disposed` has not fired),
|
||||
* THEN the agent is unregistered, THEN the session is detached — capturing the
|
||||
* closing events before detach, whether the trigger is the handle's `dispose()`
|
||||
* OR a fiber unload. Rollback safety: each yield runs before the next mutation,
|
||||
@@ -304,6 +308,11 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
yield this.ctx.sessions.enter(session)
|
||||
this.ctx.sessions.announce(session)
|
||||
yield this.ctx.agents.register(agent)
|
||||
// Disposed 2nd (after stop-and-drain below, before unregister above):
|
||||
// drain the awaited per-agent cleanups — the AgentFactory dispose
|
||||
// contract that lets other plugins (ctx.tasks) tie resources to this
|
||||
// agent's quiescence. drainCleanups contains rejections itself.
|
||||
yield async () => { await this.ctx.agents.drainCleanups(agent.id) }
|
||||
// Fire AFTER register (a listener can ctx.agents.get(id) + inject()) and
|
||||
// BEFORE the loop's first turn. Contained: a throwing listener is logged,
|
||||
// never aborts construction (no open turn to balance here).
|
||||
|
||||
57
packages/core/agent-loop/tests/cleanup-drain.spec.ts
Normal file
57
packages/core/agent-loop/tests/cleanup-drain.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter } from './mock-adapter.ts'
|
||||
|
||||
async function harness() {
|
||||
const ctx = new Context()
|
||||
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'], new MockAdapter([]))
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('agent disposal drains onCleanup registrations', () => {
|
||||
it('awaits the cleanup after loop drain and before unregistration', async () => {
|
||||
const ctx = await harness()
|
||||
const handle = ctx.agents.create({ agentId: AgentId('owner'), sessionId: SessionId('owner-sess'), agentOptions: { model: 'mock' } })
|
||||
|
||||
const order: string[] = []
|
||||
ctx.on('agent/disposed', () => void order.push('agent/disposed'))
|
||||
let cleanupSettled = false
|
||||
ctx.agents.onCleanup(handle.agent.id, async () => {
|
||||
// The agent must STILL be registered while cleanups drain (a settling
|
||||
// task's completion notice can still find it by session id).
|
||||
order.push(`cleanup:registered=${ctx.agents.get(handle.agent.id) !== undefined}`)
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
cleanupSettled = true
|
||||
order.push('cleanup:done')
|
||||
})
|
||||
|
||||
await handle.dispose()
|
||||
// dispose() resolves only after the cleanup settled (awaited, not fired).
|
||||
expect(cleanupSettled).toBe(true)
|
||||
expect(order).toEqual(['cleanup:registered=true', 'cleanup:done', 'agent/disposed'])
|
||||
})
|
||||
|
||||
it('a rejecting cleanup never breaks the disposal chain', async () => {
|
||||
const ctx = await harness()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const handle = ctx.agents.create({ agentId: AgentId('owner'), sessionId: SessionId('owner-sess'), agentOptions: { model: 'mock' } })
|
||||
ctx.agents.onCleanup(handle.agent.id, () => Promise.reject(new Error('drain boom')))
|
||||
|
||||
await expect(handle.dispose()).resolves.toBeUndefined()
|
||||
expect(ctx.agents.get(handle.agent.id)).toBeUndefined()
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('drain boom'))
|
||||
})
|
||||
})
|
||||
@@ -20,7 +20,10 @@ Agent *creation* is provided by whichever plugin implements `AgentFactory` (phas
|
||||
- `ctx.agents.create(options: CreateAgentOptions): AgentHandle` — construct, start, AND register a new agent on a caller-supplied `sessionId` (with optional `meta.cwd`/`meta.parentSession`/`meta.seedLength` and optional `seed` events for forked children). Distinct from `register` (which only records). Throws 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)) and resume an agent on it. Async; rejects if no factory is registered, or if the factory finds session persistence unconfigured.
|
||||
|
||||
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit (quiescence — NOT just the `disposed` status flip), unregisters the agent, and removes its session from the store, in an order that captures the loop's final `session/flush` before the session is detached. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge and in-process subagent backends are production consumers; config-created agents are owned by the loop fiber and never need a handle.
|
||||
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit (quiescence — NOT just the `disposed` status flip), drains the registered per-agent cleanups, unregisters the agent, and removes its session from the store, in an order that captures the loop's final `session/flush` before the session is detached.
|
||||
|
||||
- `ctx.agents.onCleanup(agentId, cleanup: () => Promise<void>): () => void` — register an AWAITED per-agent cleanup: the agent's disposal chain runs it (after loop drain, before unregistration) and `AgentHandle.dispose()` resolves only after it settles. The seam for resources that must not outlive their owner (`ctx.tasks` background tasks) — the `agent/disposed` EMIT cannot promise that, because emit listeners are not awaited. Throws for an unregistered agent id; effect-scoped.
|
||||
- `ctx.agents.drainCleanups(agentId): Promise<void>` — LIFECYCLE OWNERS ONLY: run and detach every registered cleanup (registration order, per-cleanup containment, loops so a cleanup registered mid-drain still runs). Part of the `AgentFactory` dispose contract — a replacement loop must call it in its disposal chain. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge and in-process subagent backends are production consumers; config-created agents are owned by the loop fiber and never need a handle.
|
||||
|
||||
### Events
|
||||
|
||||
|
||||
@@ -88,6 +88,13 @@ export interface AgentHandle {
|
||||
* via {@link AgentRegistry.setFactory}. Kept on the `dsh-agent` interface so
|
||||
* consumers (e.g. the ACP bridge) program against `ctx.agents` without
|
||||
* depending on the concrete `dsh-agent-loop` package.
|
||||
*
|
||||
* Dispose contract: the handle's `dispose()` must, after draining the loop and
|
||||
* BEFORE unregistering the agent, await
|
||||
* {@link AgentRegistry.drainCleanups | ctx.agents.drainCleanups(agent.id)} —
|
||||
* that is what makes {@link AgentRegistry.onCleanup} registrations an awaited
|
||||
* quiescence guarantee for every plugin, whichever loop implementation is
|
||||
* installed.
|
||||
*/
|
||||
export interface AgentFactory {
|
||||
/**
|
||||
@@ -117,6 +124,7 @@ const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plug
|
||||
export class AgentRegistry extends Service {
|
||||
private store = new Map<AgentId, Agent>()
|
||||
private factory: AgentFactory | undefined
|
||||
private cleanups = new Map<AgentId, Set<() => Promise<void>>>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'agents')
|
||||
@@ -217,6 +225,66 @@ export class AgentRegistry extends Service {
|
||||
return this.store.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an AWAITED per-agent cleanup: the agent's disposal chain runs it
|
||||
* (via {@link drainCleanups}) after the loop has drained and before the agent
|
||||
* unregisters, and `AgentHandle.dispose()` resolves only after it settles.
|
||||
* This is the seam for resources that must not outlive their owning agent
|
||||
* (e.g. `ctx.tasks` background tasks) — the `agent/disposed` EMIT cannot
|
||||
* promise that, because emit listeners are not awaited. Throws for an agent
|
||||
* id not currently registered: a cleanup attached to a dead agent would
|
||||
* silently never run. Effect-scoped: disposed with the calling fiber.
|
||||
* @param agentId - the LIVE agent whose disposal must await this cleanup.
|
||||
* @param cleanup - awaited during disposal; a rejection is logged, never propagated.
|
||||
* @returns the disposer that detaches the cleanup without running it.
|
||||
*/
|
||||
onCleanup(agentId: AgentId, cleanup: () => Promise<void>): () => void {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
if (!this.store.has(agentId)) {
|
||||
throw new Error(`agent "${agentId}" is not registered (cleanup would never run)`)
|
||||
}
|
||||
let set = this.cleanups.get(agentId)
|
||||
if (set === undefined) {
|
||||
set = new Set()
|
||||
this.cleanups.set(agentId, set)
|
||||
}
|
||||
set.add(cleanup)
|
||||
return () => {
|
||||
set.delete(cleanup)
|
||||
// Guard the map removal with an identity check: after drainCleanups
|
||||
// detached this set, the same id may map to a FRESH set (a cleanup
|
||||
// registered mid-drain) that this stale disposer must not remove.
|
||||
if (set.size === 0 && this.cleanups.get(agentId) === set) this.cleanups.delete(agentId)
|
||||
}
|
||||
}, 'agents.onCleanup()')
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* Run and detach every cleanup registered for an agent (registration order,
|
||||
* awaited sequentially, per-cleanup containment — a rejecting cleanup is
|
||||
* logged and never starves the ones after it or the caller's disposal chain).
|
||||
* For LIFECYCLE OWNERS ONLY: the agent factory's disposal chain calls this
|
||||
* between loop drain and unregistration (part of the {@link AgentFactory}
|
||||
* dispose contract); other plugins register via {@link onCleanup}, never
|
||||
* drain. Loops until no cleanups remain, so one registered DURING the drain
|
||||
* (from a settling task) still runs instead of leaking.
|
||||
* @param agentId - the agent being disposed.
|
||||
* @returns resolves when every registered cleanup has settled.
|
||||
*/
|
||||
async drainCleanups(agentId: AgentId): Promise<void> {
|
||||
for (let set = this.cleanups.get(agentId); set !== undefined; set = this.cleanups.get(agentId)) {
|
||||
this.cleanups.delete(agentId)
|
||||
for (const cleanup of set) {
|
||||
try {
|
||||
await cleanup()
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`agent "${agentId}": disposal cleanup threw: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* All live agents, in registration order.
|
||||
* @returns a fresh array; mutating it does not affect the registry.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { Agent, AgentId } from '@deepseek-ai/dsh-agent'
|
||||
@@ -76,6 +76,129 @@ describe('AgentRegistry', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentRegistry.onCleanup / drainCleanups', () => {
|
||||
it('drains cleanups in registration order, awaiting each', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const agent = stubAgent('a1')
|
||||
ctx.agents.register(agent)
|
||||
|
||||
const ran: string[] = []
|
||||
ctx.agents.onCleanup(agent.id, async () => {
|
||||
ran.push('first:start')
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
ran.push('first:end')
|
||||
})
|
||||
ctx.agents.onCleanup(agent.id, () => {
|
||||
ran.push('second')
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
await ctx.agents.drainCleanups(agent.id)
|
||||
// Sequential await: the second cleanup starts only after the first settled.
|
||||
expect(ran).toEqual(['first:start', 'first:end', 'second'])
|
||||
// Drained cleanups are detached: a second drain is a no-op.
|
||||
await ctx.agents.drainCleanups(agent.id)
|
||||
expect(ran).toEqual(['first:start', 'first:end', 'second'])
|
||||
})
|
||||
|
||||
it('contains a rejecting cleanup: logged, later cleanups still run', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const agent = stubAgent('a1')
|
||||
ctx.agents.register(agent)
|
||||
|
||||
let ranAfter = false
|
||||
ctx.agents.onCleanup(agent.id, () => Promise.reject(new Error('cleanup boom')))
|
||||
ctx.agents.onCleanup(agent.id, () => {
|
||||
ranAfter = true
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
await expect(ctx.agents.drainCleanups(agent.id)).resolves.toBeUndefined()
|
||||
expect(ranAfter).toBe(true)
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup boom'))
|
||||
})
|
||||
|
||||
it('rejects a cleanup for an agent that is not registered', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
expect(() => ctx.agents.onCleanup(AgentId('ghost'), () => Promise.resolve()))
|
||||
.toThrow('agent "ghost" is not registered')
|
||||
})
|
||||
|
||||
it('detaches without running on disposer call and on fiber dispose (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const agent = stubAgent('a1')
|
||||
ctx.agents.register(agent)
|
||||
|
||||
let ranA = false
|
||||
let ranB = false
|
||||
const detach = ctx.agents.onCleanup(agent.id, () => {
|
||||
ranA = true
|
||||
return Promise.resolve()
|
||||
})
|
||||
detach()
|
||||
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.agents.onCleanup(agent.id, () => {
|
||||
ranB = true
|
||||
return Promise.resolve()
|
||||
})
|
||||
}, { inject: ['agents'] }))
|
||||
await fiber.dispose()
|
||||
|
||||
await ctx.agents.drainCleanups(agent.id)
|
||||
expect(ranA).toBe(false)
|
||||
expect(ranB).toBe(false)
|
||||
})
|
||||
|
||||
it('runs a cleanup registered during the drain instead of leaking it', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const agent = stubAgent('a1')
|
||||
ctx.agents.register(agent)
|
||||
|
||||
const ran: string[] = []
|
||||
ctx.agents.onCleanup(agent.id, () => {
|
||||
ran.push('outer')
|
||||
// A settling task registering follow-up cleanup mid-drain: the drain
|
||||
// loop must pick up the fresh set rather than strand it.
|
||||
ctx.agents.onCleanup(agent.id, () => {
|
||||
ran.push('mid-drain')
|
||||
return Promise.resolve()
|
||||
})
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
await ctx.agents.drainCleanups(agent.id)
|
||||
expect(ran).toEqual(['outer', 'mid-drain'])
|
||||
})
|
||||
|
||||
it('a stale disposer from a drained set does not remove a fresh registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const agent = stubAgent('a1')
|
||||
ctx.agents.register(agent)
|
||||
|
||||
const detachOld = ctx.agents.onCleanup(agent.id, () => Promise.resolve())
|
||||
await ctx.agents.drainCleanups(agent.id)
|
||||
|
||||
let ranFresh = false
|
||||
ctx.agents.onCleanup(agent.id, () => {
|
||||
ranFresh = true
|
||||
return Promise.resolve()
|
||||
})
|
||||
// The old registration's disposer fires after its set was drained; the
|
||||
// identity guard must keep it away from the fresh set under the same id.
|
||||
detachOld()
|
||||
await ctx.agents.drainCleanups(agent.id)
|
||||
expect(ranFresh).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentRegistry factory seam', () => {
|
||||
/** A stub AgentFactory that records calls and returns a stub agent. */
|
||||
function stubFactory() {
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
|
||||
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
|
||||
const catalog = await collectToolCatalog()
|
||||
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
|
||||
expect(names).toEqual(['bash', 'bash_kill', 'bash_output', 'edit', 'read', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'write'])
|
||||
expect(names).toEqual(['bash', 'edit', 'read', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'web_fetch', 'web_search', 'write'])
|
||||
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
|
||||
for (const entry of catalog) {
|
||||
for (const schema of entry.schemas) {
|
||||
|
||||
Reference in New Issue
Block a user