2026-06-11 12:39:27 +08:00
|
|
|
/**
|
2026-06-19 10:13:33 +08:00
|
|
|
* THE concrete agent plugin: creates ReactLoopAgents, runs their loops, and
|
2026-06-11 12:39:27 +08:00
|
|
|
* registers them in ctx.agents. Deliberately thin — every behavior beyond
|
|
|
|
|
* "call the model, run the tools, repeat" belongs to plugins on the event
|
|
|
|
|
* taxonomy.
|
|
|
|
|
*
|
|
|
|
|
* @module @deepseek-ai/dsh-agent-loop
|
|
|
|
|
*/
|
|
|
|
|
|
2026-06-11 10:54:31 +08:00
|
|
|
import { Context, Service } from 'cordis'
|
2026-06-15 21:05:46 +08:00
|
|
|
import { randomUUID } from 'node:crypto'
|
2026-06-11 10:54:31 +08:00
|
|
|
import z from 'schemastery'
|
2026-06-11 15:17:56 +08:00
|
|
|
import { AgentId } from '@deepseek-ai/dsh-agent'
|
2026-06-20 06:44:35 +08:00
|
|
|
import type { AgentFactory, AgentHandle, AgentOptions, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
|
2026-06-11 10:54:31 +08:00
|
|
|
import type {} from '@deepseek-ai/dsh-llm'
|
2026-06-15 21:12:14 +08:00
|
|
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
|
|
|
import type { Session } from '@deepseek-ai/dsh-session'
|
2026-06-11 10:54:31 +08:00
|
|
|
import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
|
|
|
import type {} from '@deepseek-ai/dsh-tools'
|
2026-06-16 22:28:01 +08:00
|
|
|
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
2026-06-19 10:13:33 +08:00
|
|
|
import { ReactLoopAgent } from './agent.ts'
|
2026-06-11 10:54:31 +08:00
|
|
|
|
2026-06-19 10:13:33 +08:00
|
|
|
export { ReactLoopAgent } from './agent.ts'
|
2026-06-11 10:54:31 +08:00
|
|
|
export { Inbox, type InboxMessage } from './inbox.ts'
|
|
|
|
|
export { runLoop } from './loop.ts'
|
|
|
|
|
|
|
|
|
|
declare module 'cordis' {
|
|
|
|
|
interface Context {
|
|
|
|
|
agentLoop: AgentLoop
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface Config {
|
|
|
|
|
/** Agents created from configuration at startup. */
|
2026-06-16 22:28:01 +08:00
|
|
|
agents: (AgentOptions & {
|
|
|
|
|
id: string
|
|
|
|
|
/**
|
|
|
|
|
* If set, the config agent RESUMES this persisted session id instead of
|
|
|
|
|
* starting a fresh `${id}-session-<uuid>`. Sourced from an env var in
|
|
|
|
|
* cordis.yml (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`), so a
|
|
|
|
|
* demo can continue a prior conversation without code changes. Requires a
|
|
|
|
|
* `dsh-session-persistence` backend; the resume is deferred until that
|
|
|
|
|
* service is available (via `ctx.inject`) and the loaded session's events
|
|
|
|
|
* seed the live session so history continues.
|
|
|
|
|
*/
|
|
|
|
|
resumeSessionId?: string
|
|
|
|
|
})[]
|
2026-06-11 10:54:31 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-06-19 10:13:33 +08:00
|
|
|
* The agent-loop plugin (`ctx.agentLoop`): creates {@link ReactLoopAgent}s, runs
|
2026-06-15 21:12:14 +08:00
|
|
|
* their loops, and registers them in `ctx.agents`. Also implements the
|
|
|
|
|
* {@link AgentFactory} seam, so plugins create/resume agents through
|
|
|
|
|
* `ctx.agents` (the interface) without depending on this concrete package.
|
2026-06-11 10:54:31 +08:00
|
|
|
*
|
|
|
|
|
* The loop itself is deliberately thin — every behavior beyond "call the
|
|
|
|
|
* model, run the tools, repeat" belongs to plugins listening on the event
|
|
|
|
|
* taxonomy declared in @deepseek-ai/dsh-agent.
|
|
|
|
|
*/
|
2026-06-15 21:12:14 +08:00
|
|
|
export class AgentLoop extends Service implements AgentFactory {
|
2026-06-11 10:54:31 +08:00
|
|
|
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
|
|
|
|
|
|
|
|
|
|
static Config: z<Config> = z.object({
|
|
|
|
|
agents: z.array(z.object({
|
|
|
|
|
id: z.string().required(),
|
|
|
|
|
model: z.string(),
|
|
|
|
|
systemPrompt: z.string(),
|
2026-06-16 22:28:01 +08:00
|
|
|
resumeSessionId: z.string(),
|
2026-06-11 10:54:31 +08:00
|
|
|
})).default([]),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
constructor(ctx: Context, public config: Config) {
|
|
|
|
|
super(ctx, 'agentLoop')
|
2026-06-15 21:12:14 +08:00
|
|
|
// Provide the agent-creation factory to the registry (effect-scoped: the
|
|
|
|
|
// slot is cleared on dispose).
|
|
|
|
|
ctx.effect(() => this.ctx.agents.setFactory(this), 'agentLoop.setFactory()')
|
2026-06-16 22:28:01 +08:00
|
|
|
for (const { id, resumeSessionId, ...options } of config.agents) {
|
|
|
|
|
if (resumeSessionId !== undefined && resumeSessionId !== '') {
|
|
|
|
|
// Resume a prior session instead of starting fresh. resume() needs
|
|
|
|
|
// `ctx.sessionPersistence`, which may load AFTER this plugin (cordis.yml
|
|
|
|
|
// lists the backend later). `ctx.inject(['sessionPersistence'], cb)`
|
|
|
|
|
// runs `cb` with a child ctx once the service exists; the child reads
|
|
|
|
|
// the persistence and hands it to resumeWith (which uses this.ctx — the
|
|
|
|
|
// parent — for sessions/registry, all in AgentLoop's static inject). A
|
|
|
|
|
// failed resume is contained + logged: startup must not crash.
|
|
|
|
|
ctx.effect(() => {
|
|
|
|
|
const fiber = this.ctx.inject(['sessionPersistence'], (childCtx: Context) => {
|
|
|
|
|
void this.resumeWith(childCtx.sessionPersistence, { agentId: id, resumeSessionId, agentOptions: options })
|
|
|
|
|
.catch((error: unknown) => {
|
|
|
|
|
this.ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
return () => void fiber.dispose()
|
|
|
|
|
}, `agentLoop.resume(${id})`)
|
|
|
|
|
} else {
|
|
|
|
|
this.create(id, options)
|
|
|
|
|
}
|
2026-06-11 10:54:31 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-06-15 21:12:14 +08:00
|
|
|
* Config-driven create: an agent on a FRESH, non-colliding session id per run
|
|
|
|
|
* (`${id}-session-<uuid>`, no cwd). Used for `cordis.yml`-configured agents
|
|
|
|
|
* and as the shared core for the programmatic factory {@link createAgent}.
|
2026-06-11 10:54:31 +08:00
|
|
|
*
|
2026-06-15 21:12:14 +08:00
|
|
|
* Why a per-run id, not a fixed `${id}-session`: once a durable persistence
|
|
|
|
|
* backend is loaded, a fixed id collides on the second run — the backend
|
|
|
|
|
* refuses to re-create an id whose log already exists on disk (the SessionId
|
|
|
|
|
* is the identity). A fresh id means each run is a new session.
|
2026-06-15 21:05:46 +08:00
|
|
|
*
|
|
|
|
|
* TODO(demo): each run starting a brand-new session is fine for demos but is
|
|
|
|
|
* NOT real conversation continuity. A production config-driven agent needs a
|
|
|
|
|
* deliberate resume-or-create policy (resume the prior session if one exists,
|
|
|
|
|
* else start fresh) or an explicit caller-chosen session id — revisit when the
|
|
|
|
|
* UI/ACP path owns session selection.
|
|
|
|
|
*
|
2026-06-11 10:54:31 +08:00
|
|
|
* TODO(sub-agents): spawn/fork land here — accept a parent agent reference;
|
|
|
|
|
* fork seeds the new Session with the parent's event log, spawn starts
|
|
|
|
|
* fresh; the child is returned as a regular Agent handle.
|
|
|
|
|
*/
|
2026-06-19 10:13:33 +08:00
|
|
|
create(id: string, options: AgentOptions = {}): ReactLoopAgent {
|
2026-06-15 21:12:14 +08:00
|
|
|
this.assertAgentIdFree(id)
|
2026-06-20 06:44:35 +08:00
|
|
|
// 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.
|
2026-06-15 21:05:46 +08:00
|
|
|
const session = this.ctx.sessions.create(`${id}-session-${randomUUID()}`, { meta: {} })
|
2026-06-20 06:44:35 +08:00
|
|
|
const { agent } = this.start(AgentId(id), options, session)
|
|
|
|
|
return agent
|
2026-06-15 21:12:14 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
2026-06-20 06:44:35 +08:00
|
|
|
* client-generated session id becomes the live/persisted session id. Returns
|
|
|
|
|
* an {@link AgentHandle} the owner disposes to tear down exactly this agent.
|
2026-06-15 21:12:14 +08:00
|
|
|
*/
|
2026-06-20 06:44:35 +08:00
|
|
|
createAgent(options: CreateAgentOptions): AgentHandle {
|
2026-06-15 21:12:14 +08:00
|
|
|
// 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)
|
2026-06-20 06:44:35 +08:00
|
|
|
const owned = this.ctx.sessions.createOwned(options.sessionId, { meta: options.meta ?? {} })
|
|
|
|
|
return this.startOwned(AgentId(options.agentId), options.agentOptions ?? {}, owned)
|
2026-06-15 21:12:14 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Resume an agent on a persisted session ({@link AgentFactory}). Loads the
|
|
|
|
|
* session log + metadata via `ctx.sessionPersistence`, reconstructs the live
|
|
|
|
|
* session with the loaded events (so `lastTurnNumber`/`deriveMessages`
|
|
|
|
|
* continue), and starts a fresh agent on it. The live session id is the
|
|
|
|
|
* resumed id, NOT `${agentId}-session`.
|
|
|
|
|
*
|
2026-06-15 23:55:40 +08:00
|
|
|
* Requires `ctx.sessionPersistence`; rejects with a clear error if it is not
|
2026-06-15 21:12:14 +08:00
|
|
|
* configured. NOT hard-injected (that would make non-persistent demos pend
|
|
|
|
|
* forever) — callers that need resume (ACP) inject `sessionPersistence`, so
|
|
|
|
|
* by the time this runs the service exists.
|
|
|
|
|
*/
|
2026-06-20 06:44:35 +08:00
|
|
|
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
|
2026-06-18 03:48:33 +08:00
|
|
|
// 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
|
|
|
|
|
// `sessionPersistence` (injecting it would pend non-persistent demos
|
|
|
|
|
// forever). The `ctx.<name>` property proxy resolves a service by an
|
|
|
|
|
// ancestor-only walk of the current fiber's parent chain; from AgentLoop's
|
|
|
|
|
// own fiber (which lacks the inject) that walk never reaches the sibling
|
|
|
|
|
// backend fiber and throws "cannot get property … without inject". Worse,
|
|
|
|
|
// when the call arrives via a traceable shadow (e.g. the ACP bridge child
|
|
|
|
|
// fiber → `ctx.agents.resume()` → `this.factory.resume()`), the walk starts
|
|
|
|
|
// at the shadow's origin fiber and fails the same way. `ctx.get(name)`
|
|
|
|
|
// sidesteps the fiber walk entirely (a store lookup by the global isolate
|
|
|
|
|
// key), so resume works from any caller fiber. It is strict by default: a
|
|
|
|
|
// backend that is not ACTIVE (absent, or mid-teardown) reads as undefined
|
|
|
|
|
// and we reject below, rather than handing back an unusable handle.
|
|
|
|
|
const persistence = this.ctx.get('sessionPersistence')
|
2026-06-15 21:12:14 +08:00
|
|
|
if (persistence === undefined) {
|
|
|
|
|
throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)')
|
|
|
|
|
}
|
2026-06-16 22:28:01 +08:00
|
|
|
return this.resumeWith(persistence, options)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Resume against an EXPLICIT persistence handle. Factored out of {@link resume}
|
|
|
|
|
* so the config-driven path can pass the handle it obtained from a
|
|
|
|
|
* `ctx.inject(['sessionPersistence'], …)` child context: `this.ctx` (the
|
|
|
|
|
* service's own fiber) did not inject `sessionPersistence`, so reading it
|
|
|
|
|
* there from inside the inject child trips the cordis inject guard. The
|
|
|
|
|
* sessions store + registry are still read through `this.ctx` (both are in
|
|
|
|
|
* AgentLoop's static inject, so they resolve fine).
|
|
|
|
|
*/
|
2026-06-20 06:44:35 +08:00
|
|
|
private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise<AgentHandle> {
|
2026-06-16 22:28:01 +08:00
|
|
|
this.assertAgentIdFree(options.agentId)
|
2026-06-15 21:12:14 +08:00
|
|
|
const { meta, events } = await persistence.load(SessionId(options.resumeSessionId))
|
|
|
|
|
// Re-check the agent id AFTER the await: the pre-load check above can go
|
|
|
|
|
// stale while load() is pending (a concurrent resume/create may register the
|
|
|
|
|
// same id). Re-checking immediately before sessions.create() keeps the
|
|
|
|
|
// "no orphaned session on a duplicate id" guarantee under concurrency.
|
|
|
|
|
this.assertAgentIdFree(options.agentId)
|
|
|
|
|
// Reconstruct the live session with the FULL persisted header (createdAt,
|
|
|
|
|
// cwd, lineage) so resume preserves identity, not just the cwd. The seed
|
|
|
|
|
// 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.
|
2026-06-20 06:44:35 +08:00
|
|
|
const owned = this.ctx.sessions.createOwned(options.resumeSessionId, {
|
2026-06-15 21:12:14 +08:00
|
|
|
seed: events,
|
|
|
|
|
meta: {
|
|
|
|
|
createdAt: meta.createdAt,
|
|
|
|
|
...meta.cwd !== undefined ? { cwd: meta.cwd } : {},
|
|
|
|
|
...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {},
|
|
|
|
|
},
|
|
|
|
|
})
|
2026-06-20 06:44:35 +08:00
|
|
|
return this.startOwned(AgentId(options.agentId), options.agentOptions ?? {}, owned)
|
2026-06-15 21:12:14 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Reject a duplicate agent id BEFORE any session is created, so a failed
|
|
|
|
|
* factory call never leaves an orphaned live session (and lazy persistence
|
|
|
|
|
* state) behind. `register()` enforces the same uniqueness, but only after
|
|
|
|
|
* `sessions.create()` has already run.
|
|
|
|
|
*/
|
|
|
|
|
private assertAgentIdFree(id: string): void {
|
|
|
|
|
if (this.ctx.agents.get(id) !== undefined) {
|
|
|
|
|
throw new Error(`agent "${id}" is already registered`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 06:44:35 +08:00
|
|
|
/**
|
|
|
|
|
* 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> } {
|
2026-06-19 10:13:33 +08:00
|
|
|
const agent = new ReactLoopAgent(this.ctx, id, options, session)
|
2026-06-20 06:44:35 +08:00
|
|
|
const dispose = this.ctx.effect(function* (this: AgentLoop) {
|
2026-06-11 12:18:52 +08:00
|
|
|
yield this.ctx.agents.register(agent)
|
|
|
|
|
yield agent.start()
|
2026-06-15 21:12:14 +08:00
|
|
|
}.bind(this), 'agentLoop.start()')
|
2026-06-20 06:44:35 +08:00
|
|
|
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
|
|
|
|
|
},
|
|
|
|
|
}
|
2026-06-11 10:54:31 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default AgentLoop
|