Files
deepseek-harness/packages/agent/src/index.ts
T
Tianyi Cui 2df41ee1d3 fix: make the six registration methods atomic under a throwing change-listener (P1-1)
llm.registerAdapter, agents.register, sessions.create, systemPrompt.section,
systemPrompt.tools, and tools.register each mutated state, emitted a change
event, then returned the disposer. In Cordis a synchronous throw before the
effect returns its disposer leaves nothing for the fiber to collect, so a
throwing change-listener leaked the registry entry permanently — HMR/dispose
could not clean it, and the duplicate-name/already-exists check stayed wedged
until restart.

Convert each to the generator-effect pattern already proven in
AgentLoop.create: mutate state, `yield` the disposer that undoes it (collected
before the next step runs, so it is torn down if a later step throws), THEN
emit the change event. The existing duplicate-name throws are unchanged — they
fire before any mutation, so they correctly leak nothing. No public API change:
generator effects are still synchronous SyncEffects and register() keeps
returning its fire-and-forget disposer wrapper.

Tests: a listener-throw rollback test for all six methods — register with a
change-listener that throws, assert the call throws AND the registry is clean
(entry absent; a subsequent listener-free register of the same name succeeds
and contributes exactly once). For systemPrompt (no duplicate-name check) the
two tests assert assembly is clean. Verified each fails against the pre-fix
emit-before-return-disposer form.
2026-06-15 01:06:13 +08:00

69 lines
2.2 KiB
TypeScript

/**
* Agent registry service. Tracks live agents so plugins can find them without
* depending on the concrete loop package. Agent creation belongs to the loop.
*
* @module @deepseek-ai/dsh-agent
*/
import { Context, Service } from 'cordis'
import type { Agent } from './types.ts'
export * from './types.ts'
declare module 'cordis' {
interface Context {
agents: AgentRegistry
}
}
/**
* 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* belongs to whichever plugin implements the Agent
* interface (phase 1: `@deepseek-ai/dsh-agent-loop`).
*/
export class AgentRegistry extends Service {
private store = new Map<string, Agent>()
constructor(ctx: Context) {
super(ctx, 'agents')
}
/**
* Register a live agent. Throws if an agent with the same id is already
* registered. Emits `agent/created` on registration and `agent/disposed`
* when the calling fiber is disposed. Returns the disposer.
*/
register(agent: Agent): () => void {
const dispose = this.ctx.effect(function* (this: AgentRegistry) {
if (this.store.has(agent.id)) {
throw new Error(`agent "${agent.id}" is already registered`)
}
this.store.set(agent.id, agent)
// Yield the rollback BEFORE emitting `agent/created`: a generator effect
// collects each yielded disposer before the next step runs, so a
// throwing `agent/created` listener rolls the entry back instead of
// leaking it (a leak would wedge the duplicate-id check until restart).
// The duplicate throw above fires before any mutation — it leaks nothing.
yield () => {
this.store.delete(agent.id)
this.ctx.emit('agent/disposed', agent)
}
this.ctx.emit('agent/created', agent)
}.bind(this), 'agents.register()')
// ctx.effect's disposer returns Promise<void>; our disposer API is
// synchronous fire-and-forget — discard the (always-resolved) promise.
return () => void dispose()
}
get(id: string): Agent | undefined {
return this.store.get(id)
}
list(): Agent[] {
return [...this.store.values()]
}
}
export default AgentRegistry