feat(agent): return an AgentHandle with an async per-agent disposer

The agent factory (`ctx.agents.create`/`resume`, the `AgentFactory` seam)
now returns `AgentHandle = { agent; dispose(): Promise<void> }` instead of a
bare `Agent`. The disposer is a capability: only the holder can tear down
exactly this agent — stop its loop, await the loop's exit (true quiescence,
not just the `disposed` status flip), unregister it, and remove its session
from the store.

The teardown ORDER is load-bearing for durability. The loop appends its
final `turn/end` + runs `session/flush` AFTER an abort, delivered through
`session.onAppend` → `session/event`; if the session-store effect (which
detaches `onAppend`) were torn down first, those closing events would never
reach persistence. So `dispose()`:
  1. runs the register+start effect disposer (sync: request loop stop),
  2. `await agent.done` (loop exits, final flush captured), THEN
  3. runs the session disposer (detach onAppend + delete store entry).

`SessionStore.createOwned()` exposes the session-create effect's disposer
(plain `create()` discards it — fiber-owned). `AgentLoop` funnels both
factory entrypoints (`createAgent`, `resumeWith`) through a shared
`startOwned` that composes the ordered teardown; the config path keeps a
fiber-owned agent by discarding the handle.

`ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for
the owner that created it.
This commit is contained in:
Tianyi Cui
2026-06-20 06:44:35 +08:00
parent 9ee22bc6f6
commit 2a4d89a4bd
6 changed files with 133 additions and 41 deletions
+59 -16
View File
@@ -11,7 +11,7 @@ import { Context, Service } from 'cordis'
import { randomUUID } from 'node:crypto'
import z from 'schemastery'
import { AgentId } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentFactory, AgentOptions, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
import type { AgentFactory, AgentHandle, AgentOptions, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
@@ -120,23 +120,28 @@ export class AgentLoop extends Service implements AgentFactory {
*/
create(id: string, options: AgentOptions = {}): ReactLoopAgent {
this.assertAgentIdFree(id)
// Config/programmatic path: the session is owned by THIS fiber (the plain
// create()), so disposing the AgentLoop/caller fiber removes it. No
// AgentHandle is needed — the register+start effect is fiber-owned too.
const session = this.ctx.sessions.create(`${id}-session-${randomUUID()}`, { meta: {} })
return this.start(AgentId(id), options, session)
const { agent } = this.start(AgentId(id), options, session)
return agent
}
/**
* Programmatic factory create ({@link AgentFactory}): an agent on a
* caller-supplied `sessionId` (NOT `${id}-session`), with optional session
* metadata (validated `cwd`, lineage). The ACP bridge uses this so the
* client-generated session id becomes the live/persisted session id.
* client-generated session id becomes the live/persisted session id. Returns
* an {@link AgentHandle} the owner disposes to tear down exactly this agent.
*/
createAgent(options: CreateAgentOptions): Agent {
createAgent(options: CreateAgentOptions): AgentHandle {
// Check the agent id BEFORE creating the session: register() would reject a
// duplicate id only AFTER sessions.create(), leaving an orphaned live
// session (and lazy persistence state) that blocks reuse of that id.
this.assertAgentIdFree(options.agentId)
const session = this.ctx.sessions.create(options.sessionId, { meta: options.meta ?? {} })
return this.start(AgentId(options.agentId), options.agentOptions ?? {}, session)
const owned = this.ctx.sessions.createOwned(options.sessionId, { meta: options.meta ?? {} })
return this.startOwned(AgentId(options.agentId), options.agentOptions ?? {}, owned)
}
/**
@@ -151,7 +156,7 @@ export class AgentLoop extends Service implements AgentFactory {
* forever) — callers that need resume (ACP) inject `sessionPersistence`, so
* by the time this runs the service exists.
*/
async resume(options: ResumeAgentOptions): Promise<Agent> {
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
// Read the service through `ctx.get('sessionPersistence')` — a direct
// global-store lookup keyed by the isolate symbol — NOT
// `this.ctx.sessionPersistence`. AgentLoop deliberately does NOT inject
@@ -183,7 +188,7 @@ export class AgentLoop extends Service implements AgentFactory {
* sessions store + registry are still read through `this.ctx` (both are in
* AgentLoop's static inject, so they resolve fine).
*/
private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise<Agent> {
private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise<AgentHandle> {
this.assertAgentIdFree(options.agentId)
const { meta, events } = await persistence.load(SessionId(options.resumeSessionId))
// Re-check the agent id AFTER the await: the pre-load check above can go
@@ -196,7 +201,7 @@ export class AgentLoop extends Service implements AgentFactory {
// events make lastTurnNumber/deriveMessages continue; the backend already
// has state (cursor) from the load above, so onCreated is a no-op and the
// seed is not re-persisted.
const session = this.ctx.sessions.create(options.resumeSessionId, {
const owned = this.ctx.sessions.createOwned(options.resumeSessionId, {
seed: events,
meta: {
createdAt: meta.createdAt,
@@ -204,7 +209,7 @@ export class AgentLoop extends Service implements AgentFactory {
...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {},
},
})
return this.start(AgentId(options.agentId), options.agentOptions ?? {}, session)
return this.startOwned(AgentId(options.agentId), options.agentOptions ?? {}, owned)
}
/**
@@ -219,16 +224,54 @@ export class AgentLoop extends Service implements AgentFactory {
}
}
/** Shared: construct a ReactLoopAgent, register it, and start its loop (LIFO). */
private start(id: AgentId, options: AgentOptions, session: Session): ReactLoopAgent {
/**
* Shared: construct a ReactLoopAgent, register it, and start its loop. The
* register + loop-stop disposers live in ONE generator effect so they run
* LIFO on dispose (the loop-stop disposer — yielded last — runs first, then
* the registry unregister), so a throwing stop() cannot leak the registry
* entry. Returns the agent plus the effect's disposer (`disposeAgent`); the
* effect is owned by the caller fiber, so disposing that fiber also tears the
* agent down — the disposer is for an OWNER that needs to tear down ONE agent.
*/
private start(id: AgentId, options: AgentOptions, session: Session): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } {
const agent = new ReactLoopAgent(this.ctx, id, options, session)
// Generator effect: stop and unregister are independent disposables
// (LIFO), so a throwing stop() cannot leak the registry entry.
this.ctx.effect(function* (this: AgentLoop) {
const dispose = this.ctx.effect(function* (this: AgentLoop) {
yield this.ctx.agents.register(agent)
yield agent.start()
}.bind(this), 'agentLoop.start()')
return agent
return { agent, disposeAgent: async () => { await dispose() } }
}
/**
* Build an {@link AgentHandle} for an OWNED session + agent. The handle's
* `dispose()` tears down exactly this agent in the order durability requires:
*
* 1. run `disposeAgent` — the register+start effect's disposer. LIFO runs
* `agent.start()`'s (synchronous) disposer first: it sets `disposed`,
* aborts the in-flight step, and unblocks the loop's idle wait. Then the
* registry unregister runs. The loop has NOT necessarily exited yet — the
* start disposer only REQUESTS exit, it does not await it.
* 2. `await agent.done` — the loop-exit promise. The loop unwinds and runs
* its final `session/flush` + `turn/end`, delivered through the still-
* attached `session.onAppend` → `session/event`, so persistence captures
* the closing events. Only now is the agent truly quiescent.
* 3. run the session disposer — detach `onAppend` and remove the store
* entry. Done LAST so step 2's final flush is not dropped.
*/
private startOwned(
id: AgentId,
options: AgentOptions,
owned: { session: Session; dispose: () => Promise<void> },
): AgentHandle {
const { agent, disposeAgent } = this.start(id, options, owned.session)
return {
agent,
dispose: async () => {
await disposeAgent() // stop the loop (sync) + unregister
await agent.done // wait for the loop to actually exit (final flush captured)
await owned.dispose() // detach onAppend + remove the session store entry
},
}
}
}