index.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. /**
  2. * THE concrete agent plugin: creates ReactLoopAgents, runs their loops, and
  3. * registers them in ctx.agents. Deliberately thin — every behavior beyond
  4. * "call the model, run the tools, repeat" belongs to plugins on the event
  5. * taxonomy.
  6. *
  7. * @module @deepseek-ai/dsh-agent-loop
  8. */
  9. import { Context, Service } from 'cordis'
  10. import { randomUUID } from 'node:crypto'
  11. import z from 'schemastery'
  12. import { AgentId } from '@deepseek-ai/dsh-agent'
  13. import type { Agent, AgentFactory, AgentOptions, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
  14. import type {} from '@deepseek-ai/dsh-llm'
  15. import { SessionId } from '@deepseek-ai/dsh-session'
  16. import type { Session } from '@deepseek-ai/dsh-session'
  17. import type {} from '@deepseek-ai/dsh-system-prompt'
  18. import type {} from '@deepseek-ai/dsh-tools'
  19. import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
  20. import { ReactLoopAgent } from './agent.ts'
  21. export { ReactLoopAgent } from './agent.ts'
  22. export { Inbox, type InboxMessage } from './inbox.ts'
  23. export { runLoop } from './loop.ts'
  24. declare module 'cordis' {
  25. interface Context {
  26. agentLoop: AgentLoop
  27. }
  28. }
  29. export interface Config {
  30. /** Agents created from configuration at startup. */
  31. agents: (AgentOptions & {
  32. id: string
  33. /**
  34. * If set, the config agent RESUMES this persisted session id instead of
  35. * starting a fresh `${id}-session-<uuid>`. Sourced from an env var in
  36. * cordis.yml (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`), so a
  37. * demo can continue a prior conversation without code changes. Requires a
  38. * `dsh-session-persistence` backend; the resume is deferred until that
  39. * service is available (via `ctx.inject`) and the loaded session's events
  40. * seed the live session so history continues.
  41. */
  42. resumeSessionId?: string
  43. })[]
  44. }
  45. /**
  46. * The agent-loop plugin (`ctx.agentLoop`): creates {@link ReactLoopAgent}s, runs
  47. * their loops, and registers them in `ctx.agents`. Also implements the
  48. * {@link AgentFactory} seam, so plugins create/resume agents through
  49. * `ctx.agents` (the interface) without depending on this concrete package.
  50. *
  51. * The loop itself is deliberately thin — every behavior beyond "call the
  52. * model, run the tools, repeat" belongs to plugins listening on the event
  53. * taxonomy declared in @deepseek-ai/dsh-agent.
  54. */
  55. export class AgentLoop extends Service implements AgentFactory {
  56. static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
  57. static Config: z<Config> = z.object({
  58. agents: z.array(z.object({
  59. id: z.string().required(),
  60. model: z.string(),
  61. systemPrompt: z.string(),
  62. resumeSessionId: z.string(),
  63. })).default([]),
  64. })
  65. constructor(ctx: Context, public config: Config) {
  66. super(ctx, 'agentLoop')
  67. // Provide the agent-creation factory to the registry (effect-scoped: the
  68. // slot is cleared on dispose).
  69. ctx.effect(() => this.ctx.agents.setFactory(this), 'agentLoop.setFactory()')
  70. for (const { id, resumeSessionId, ...options } of config.agents) {
  71. if (resumeSessionId !== undefined && resumeSessionId !== '') {
  72. // Resume a prior session instead of starting fresh. resume() needs
  73. // `ctx.sessionPersistence`, which may load AFTER this plugin (cordis.yml
  74. // lists the backend later). `ctx.inject(['sessionPersistence'], cb)`
  75. // runs `cb` with a child ctx once the service exists; the child reads
  76. // the persistence and hands it to resumeWith (which uses this.ctx — the
  77. // parent — for sessions/registry, all in AgentLoop's static inject). A
  78. // failed resume is contained + logged: startup must not crash.
  79. ctx.effect(() => {
  80. const fiber = this.ctx.inject(['sessionPersistence'], (childCtx: Context) => {
  81. void this.resumeWith(childCtx.sessionPersistence, { agentId: id, resumeSessionId, agentOptions: options })
  82. .catch((error: unknown) => {
  83. this.ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`)
  84. })
  85. })
  86. return () => void fiber.dispose()
  87. }, `agentLoop.resume(${id})`)
  88. } else {
  89. this.create(id, options)
  90. }
  91. }
  92. }
  93. /**
  94. * Config-driven create: an agent on a FRESH, non-colliding session id per run
  95. * (`${id}-session-<uuid>`, no cwd). Used for `cordis.yml`-configured agents
  96. * and as the shared core for the programmatic factory {@link createAgent}.
  97. *
  98. * Why a per-run id, not a fixed `${id}-session`: once a durable persistence
  99. * backend is loaded, a fixed id collides on the second run — the backend
  100. * refuses to re-create an id whose log already exists on disk (the SessionId
  101. * is the identity). A fresh id means each run is a new session.
  102. *
  103. * TODO(demo): each run starting a brand-new session is fine for demos but is
  104. * NOT real conversation continuity. A production config-driven agent needs a
  105. * deliberate resume-or-create policy (resume the prior session if one exists,
  106. * else start fresh) or an explicit caller-chosen session id — revisit when the
  107. * UI/ACP path owns session selection.
  108. *
  109. * TODO(sub-agents): spawn/fork land here — accept a parent agent reference;
  110. * fork seeds the new Session with the parent's event log, spawn starts
  111. * fresh; the child is returned as a regular Agent handle.
  112. */
  113. create(id: string, options: AgentOptions = {}): ReactLoopAgent {
  114. this.assertAgentIdFree(id)
  115. const session = this.ctx.sessions.create(`${id}-session-${randomUUID()}`, { meta: {} })
  116. return this.start(AgentId(id), options, session)
  117. }
  118. /**
  119. * Programmatic factory create ({@link AgentFactory}): an agent on a
  120. * caller-supplied `sessionId` (NOT `${id}-session`), with optional session
  121. * metadata (validated `cwd`, lineage). The ACP bridge uses this so the
  122. * client-generated session id becomes the live/persisted session id.
  123. */
  124. createAgent(options: CreateAgentOptions): Agent {
  125. // Check the agent id BEFORE creating the session: register() would reject a
  126. // duplicate id only AFTER sessions.create(), leaving an orphaned live
  127. // session (and lazy persistence state) that blocks reuse of that id.
  128. this.assertAgentIdFree(options.agentId)
  129. const session = this.ctx.sessions.create(options.sessionId, { meta: options.meta ?? {} })
  130. return this.start(AgentId(options.agentId), options.agentOptions ?? {}, session)
  131. }
  132. /**
  133. * Resume an agent on a persisted session ({@link AgentFactory}). Loads the
  134. * session log + metadata via `ctx.sessionPersistence`, reconstructs the live
  135. * session with the loaded events (so `lastTurnNumber`/`deriveMessages`
  136. * continue), and starts a fresh agent on it. The live session id is the
  137. * resumed id, NOT `${agentId}-session`.
  138. *
  139. * Requires `ctx.sessionPersistence`; rejects with a clear error if it is not
  140. * configured. NOT hard-injected (that would make non-persistent demos pend
  141. * forever) — callers that need resume (ACP) inject `sessionPersistence`, so
  142. * by the time this runs the service exists.
  143. */
  144. async resume(options: ResumeAgentOptions): Promise<Agent> {
  145. // Read the service through `ctx.get('sessionPersistence')` — a direct
  146. // global-store lookup keyed by the isolate symbol — NOT
  147. // `this.ctx.sessionPersistence`. AgentLoop deliberately does NOT inject
  148. // `sessionPersistence` (injecting it would pend non-persistent demos
  149. // forever). The `ctx.<name>` property proxy resolves a service by an
  150. // ancestor-only walk of the current fiber's parent chain; from AgentLoop's
  151. // own fiber (which lacks the inject) that walk never reaches the sibling
  152. // backend fiber and throws "cannot get property … without inject". Worse,
  153. // when the call arrives via a traceable shadow (e.g. the ACP bridge child
  154. // fiber → `ctx.agents.resume()` → `this.factory.resume()`), the walk starts
  155. // at the shadow's origin fiber and fails the same way. `ctx.get(name)`
  156. // sidesteps the fiber walk entirely (a store lookup by the global isolate
  157. // key), so resume works from any caller fiber. It is strict by default: a
  158. // backend that is not ACTIVE (absent, or mid-teardown) reads as undefined
  159. // and we reject below, rather than handing back an unusable handle.
  160. const persistence = this.ctx.get('sessionPersistence')
  161. if (persistence === undefined) {
  162. throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)')
  163. }
  164. return this.resumeWith(persistence, options)
  165. }
  166. /**
  167. * Resume against an EXPLICIT persistence handle. Factored out of {@link resume}
  168. * so the config-driven path can pass the handle it obtained from a
  169. * `ctx.inject(['sessionPersistence'], …)` child context: `this.ctx` (the
  170. * service's own fiber) did not inject `sessionPersistence`, so reading it
  171. * there from inside the inject child trips the cordis inject guard. The
  172. * sessions store + registry are still read through `this.ctx` (both are in
  173. * AgentLoop's static inject, so they resolve fine).
  174. */
  175. private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise<Agent> {
  176. this.assertAgentIdFree(options.agentId)
  177. const { meta, events } = await persistence.load(SessionId(options.resumeSessionId))
  178. // Re-check the agent id AFTER the await: the pre-load check above can go
  179. // stale while load() is pending (a concurrent resume/create may register the
  180. // same id). Re-checking immediately before sessions.create() keeps the
  181. // "no orphaned session on a duplicate id" guarantee under concurrency.
  182. this.assertAgentIdFree(options.agentId)
  183. // Reconstruct the live session with the FULL persisted header (createdAt,
  184. // cwd, lineage) so resume preserves identity, not just the cwd. The seed
  185. // events make lastTurnNumber/deriveMessages continue; the backend already
  186. // has state (cursor) from the load above, so onCreated is a no-op and the
  187. // seed is not re-persisted.
  188. const session = this.ctx.sessions.create(options.resumeSessionId, {
  189. seed: events,
  190. meta: {
  191. createdAt: meta.createdAt,
  192. ...meta.cwd !== undefined ? { cwd: meta.cwd } : {},
  193. ...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {},
  194. },
  195. })
  196. return this.start(AgentId(options.agentId), options.agentOptions ?? {}, session)
  197. }
  198. /**
  199. * Reject a duplicate agent id BEFORE any session is created, so a failed
  200. * factory call never leaves an orphaned live session (and lazy persistence
  201. * state) behind. `register()` enforces the same uniqueness, but only after
  202. * `sessions.create()` has already run.
  203. */
  204. private assertAgentIdFree(id: string): void {
  205. if (this.ctx.agents.get(id) !== undefined) {
  206. throw new Error(`agent "${id}" is already registered`)
  207. }
  208. }
  209. /** Shared: construct a ReactLoopAgent, register it, and start its loop (LIFO). */
  210. private start(id: AgentId, options: AgentOptions, session: Session): ReactLoopAgent {
  211. const agent = new ReactLoopAgent(this.ctx, id, options, session)
  212. // Generator effect: stop and unregister are independent disposables
  213. // (LIFO), so a throwing stop() cannot leak the registry entry.
  214. this.ctx.effect(function* (this: AgentLoop) {
  215. yield this.ctx.agents.register(agent)
  216. yield agent.start()
  217. }.bind(this), 'agentLoop.start()')
  218. return agent
  219. }
  220. }
  221. export default AgentLoop