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

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
},
}
}
}

View File

@@ -78,7 +78,7 @@ describe('config-driven session id', () => {
await ctx1.plugin(AgentLoop, { agents: [] })
await ctx1.plugin(SessionPersistenceJsonl, { root })
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')]))
const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sticky-1' }) as ReactLoopAgent
const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sticky-1' }).agent as ReactLoopAgent
a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()

View File

@@ -43,7 +43,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => {
const adapter = new MockAdapter([textResponse('hi')])
const { ctx } = await persistentHarness(adapter)
const agent = ctx.agents.create({ agentId: 'a1', sessionId: 'custom-session', meta: { cwd: '/w' } })
const { agent } = ctx.agents.create({ agentId: 'a1', sessionId: 'custom-session', meta: { cwd: '/w' } })
expect(agent.session.id).toBe('custom-session')
expect(agent.session.header.cwd).toBe('/w')
await ctx.fiber.dispose()
@@ -63,7 +63,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
it('createAgent works without meta (no cwd)', async () => {
const adapter = new MockAdapter([textResponse('hi')])
const { ctx } = await persistentHarness(adapter)
const agent = ctx.agents.create({ agentId: 'a-nometa', sessionId: 'nometa-session' })
const { agent } = ctx.agents.create({ agentId: 'a-nometa', sessionId: 'nometa-session' })
expect(agent.session.id).toBe('nometa-session')
expect(agent.session.header.cwd).toBeUndefined()
await ctx.fiber.dispose()
@@ -73,7 +73,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// Lifecycle 1: create a no-cwd session and run a turn.
const adapter1 = new MockAdapter([textResponse('a')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'nocwd-sess' }) as ReactLoopAgent
const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'nocwd-sess' }).agent as ReactLoopAgent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
@@ -89,7 +89,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.plugin(AgentLoop, { agents: [] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
const a2 = await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'nocwd-sess' }) as ReactLoopAgent
const a2 = (await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'nocwd-sess' })).agent as ReactLoopAgent
expect(a2.session.header.cwd).toBeUndefined()
await ctx2.fiber.dispose()
})
@@ -120,7 +120,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.plugin(AgentLoop, { agents: [] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
const a2 = await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'forked-sess' }) as ReactLoopAgent
const a2 = (await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'forked-sess' })).agent as ReactLoopAgent
expect(a2.session.header.parentSession).toBe('parent-sess')
expect(a2.session.header.cwd).toBe('/w')
await ctx2.fiber.dispose()
@@ -133,7 +133,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// disk, since a crash before the next turn would otherwise lose it.
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }) as ReactLoopAgent
const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }).agent as ReactLoopAgent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
@@ -158,7 +158,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// drop it on reload (the bug this guards).
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }) as ReactLoopAgent
const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }).agent as ReactLoopAgent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
@@ -176,7 +176,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.plugin(AgentLoop, { agents: [] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
const a2 = await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'inject-sess' }) as ReactLoopAgent
const a2 = (await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'inject-sess' })).agent as ReactLoopAgent
const flat = JSON.stringify(a2.session.deriveMessages())
expect(flat).toContain('background task 42 finished')
await ctx2.fiber.dispose()
@@ -186,7 +186,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// Lifecycle 1: run one full turn, persisting it.
const adapter1 = new MockAdapter([textResponse('first answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sess-resume', meta: { cwd: '/w' } }) as ReactLoopAgent
const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sess-resume', meta: { cwd: '/w' } }).agent as ReactLoopAgent
a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
const events1 = [...a1.session.events]
@@ -206,7 +206,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
const a2 = await ctx2.agents.resume({ agentId: 'main', resumeSessionId: 'sess-resume' }) as ReactLoopAgent
const a2 = (await ctx2.agents.resume({ agentId: 'main', resumeSessionId: 'sess-resume' })).agent as ReactLoopAgent
// The resumed session carries the prior history…
expect(a2.session.id).toBe('sess-resume')
expect(a2.session.events.length).toBe(events1.length)