Add the durable session-persistence capability seam (ADR 0016): an
abstract SessionPersistence service (dsh-session-persistence,
ctx.sessionPersistence) defining create/append/load/list/has/delete/
update over the existing SessionEvent — no parallel persisted type — and
a first implementation (dsh-session-persistence-jsonl): an append-only
JSONL log per session with crash-safe atomic writes, truncation-repair
of a never-committed crash tail, and a read/replay path. SessionMeta
(format version, cwd, lineage) travels out-of-log via session.header.
A shared runPersistenceContract suite holds every backend to the same
append-only / contiguous-seq / lazy-materialization / serializability
semantics.
Config-driven create() now uses a per-run ${id}-session-<uuid> session
id so a fixed name no longer collides with an on-disk log once a durable
backend is loaded; each run is a new session (a demo simplification). The
examples drop their hand-rolled session-jsonl.ts and load the JSONL
backend via cordis.yml; CI smoke-loads it too.
The agent-facing create/resume factory that consumes load() is a
separate seam, deferred to a follow-up; this change stops at the load
primitive and does not reach into the loop.
96 lines
3.4 KiB
TypeScript
96 lines
3.4 KiB
TypeScript
/**
|
|
* THE concrete agent plugin: creates LoopAgents, runs their loops, and
|
|
* 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
|
|
*/
|
|
|
|
import { Context, Service } from 'cordis'
|
|
import { randomUUID } from 'node:crypto'
|
|
import z from 'schemastery'
|
|
import { AgentId } from '@deepseek-ai/dsh-agent'
|
|
import type { AgentOptions } from '@deepseek-ai/dsh-agent'
|
|
import type {} from '@deepseek-ai/dsh-llm'
|
|
import type {} from '@deepseek-ai/dsh-session'
|
|
import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
import type {} from '@deepseek-ai/dsh-tools'
|
|
import { LoopAgent } from './agent.ts'
|
|
|
|
export { LoopAgent } from './agent.ts'
|
|
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. */
|
|
agents: (AgentOptions & { id: string })[]
|
|
}
|
|
|
|
/**
|
|
* The agent-loop plugin (`ctx.agentLoop`): creates {@link LoopAgent}s, runs
|
|
* their loops, and registers them in `ctx.agents`.
|
|
*
|
|
* 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.
|
|
*/
|
|
export class AgentLoop extends Service {
|
|
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(),
|
|
})).default([]),
|
|
})
|
|
|
|
constructor(ctx: Context, public config: Config) {
|
|
super(ctx, 'agentLoop')
|
|
for (const { id, ...options } of config.agents) {
|
|
this.create(id, options)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create an agent, start its loop, and register it. Returns the agent.
|
|
* Disposed with the calling fiber.
|
|
*
|
|
* The session id is per-run (`${id}-session-<uuid>`, no fixed name): once a
|
|
* durable persistence backend is loaded, a fixed `${id}-session` 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.
|
|
*
|
|
* 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.
|
|
*
|
|
* 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.
|
|
*/
|
|
create(id: string, options: AgentOptions = {}): LoopAgent {
|
|
const session = this.ctx.sessions.create(`${id}-session-${randomUUID()}`, { meta: {} })
|
|
const agent = new LoopAgent(this.ctx, AgentId(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) {
|
|
yield this.ctx.agents.register(agent)
|
|
yield agent.start()
|
|
}.bind(this), 'agentLoop.create()')
|
|
return agent
|
|
}
|
|
}
|
|
|
|
export default AgentLoop
|