agent.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. /** Agent activation, composition, and model-selection policy owned by API Session. */
  2. import { mkdir } from 'node:fs/promises'
  3. import type { Context } from '@deepseek-ai/cordis'
  4. import { installModelSelection } from '@deepseek-ai/dsh-agent'
  5. import type {
  6. Agent, AgentOptions, AgentSetup, ModelSelection as AgentModelSelection, ModelSelectionRef,
  7. } from '@deepseek-ai/dsh-agent'
  8. import type {} from '@deepseek-ai/dsh-agent-default-model'
  9. import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets'
  10. import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
  11. import type {} from '@deepseek-ai/dsh-session-persistence'
  12. import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
  13. import type {} from '@deepseek-ai/dsh-typert-registry'
  14. import type { SessionError } from './types.ts'
  15. /** Cold Session identity absent from persistence. */
  16. export class ApiSessionNotFound extends Error {}
  17. /** Session identity whose lifecycle belongs to subagent routing. */
  18. export class ApiSessionSubagentOwnership extends Error {
  19. /** @param sessionId - identity reserved to subagent routing. */
  20. constructor(readonly sessionId: SessionId) {
  21. super(`session "${sessionId}" is a subagent session; use subagent delivery`)
  22. }
  23. }
  24. /** Explicit-id creation attempted to adopt a Session under another cwd. */
  25. export class ApiSessionCwdConflict extends Error {
  26. constructor(
  27. readonly sessionId: SessionId,
  28. readonly requestedCwd: string,
  29. readonly existingCwd: string | undefined,
  30. ) {
  31. super(
  32. existingCwd === undefined
  33. ? `session "${sessionId}" records no cwd and cannot be adopted for "${requestedCwd}"`
  34. : `session "${sessionId}" belongs to "${existingCwd}", not "${requestedCwd}"`,
  35. )
  36. }
  37. }
  38. /** Explicit-id creation attempted to adopt a Session under another preset. */
  39. export class ApiSessionPresetConflict extends Error {
  40. constructor(
  41. readonly sessionId: SessionId,
  42. readonly requestedPreset: string,
  43. readonly existingPreset: string | undefined,
  44. ) {
  45. super(
  46. existingPreset === undefined
  47. ? `session "${sessionId}" records no agent preset and cannot be adopted under "${requestedPreset}"`
  48. : `session "${sessionId}" runs agent preset "${existingPreset}", not "${requestedPreset}"`,
  49. )
  50. }
  51. }
  52. /** Failures produced while resolving one ordinary Session identity to its live Agent. */
  53. export type ApiSessionAgentError = Extract<
  54. SessionError,
  55. { readonly code: 'session-not-found' | 'agent-busy' | 'internal' }
  56. >
  57. /** Result of resolving one ordinary Session identity to its live Agent. */
  58. export type ApiSessionAgentResult =
  59. | { readonly agent: Agent }
  60. | { readonly error: ApiSessionAgentError }
  61. type InstalledSelection = ModelSelectionRef & { current: AgentModelSelection }
  62. /**
  63. * Test whether generic Session routing must leave an identity to subagent routing.
  64. * @param ctx - Host context carrying the Agent ownership registry.
  65. * @param session - attached or live Session whose ownership is tested.
  66. * @param agent - live Agent when one exists for the Session.
  67. * @returns whether subagent routing owns the Session identity.
  68. */
  69. export function hasApiSessionSubagentOwner(
  70. ctx: Context,
  71. session: Pick<Session, 'header'>,
  72. agent: Agent | undefined,
  73. ): boolean {
  74. if (session.header.origin === 'subagent') return true
  75. const parentId = session.header.parentSession
  76. if (parentId === undefined || agent === undefined) return false
  77. const parent = ctx.agents.get(parentId)
  78. return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent)
  79. }
  80. /**
  81. * Build the stable caller-facing subagent ownership rejection.
  82. * @param sessionId - Session identity owned by subagent routing.
  83. * @returns a stable Session-domain failure.
  84. */
  85. export function apiSessionSubagentOwnershipError(sessionId: SessionId): ApiSessionAgentError {
  86. return {
  87. code: 'agent-busy',
  88. message: `session "${sessionId}" is owned by subagent routing`,
  89. details: { reason: 'use subagent delivery for this child session' },
  90. }
  91. }
  92. /**
  93. * Inspect one cold Session without repairing, resuming, or publishing it.
  94. * @param ctx - Host context carrying Session persistence.
  95. * @param sessionId - durable Session identity.
  96. * @param signal - optional cancellation for persistence reads.
  97. * @returns the persisted header and complete event prefix.
  98. */
  99. export async function inspectApiSession(
  100. ctx: Context,
  101. sessionId: SessionId,
  102. signal?: AbortSignal,
  103. ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
  104. const persistence = ctx.get('sessionPersistence')
  105. if (persistence === undefined) {
  106. throw new Error('session persistence is not configured (load a dsh-session-persistence backend)')
  107. }
  108. const meta = (await persistence.list(signal)).find(candidate => candidate.id === sessionId)
  109. if (meta === undefined || meta.cwd === undefined) {
  110. throw new ApiSessionNotFound(`session "${sessionId}" not found`)
  111. }
  112. const inspected = await persistence.inspect(sessionId, signal)
  113. if (inspected.meta.cwd === undefined) {
  114. throw new ApiSessionNotFound(`session "${sessionId}" not found`)
  115. }
  116. return { meta: inspected.meta, events: [...inspected.events] }
  117. }
  118. /** Owns every operation that may create, resume, or configure a Web Agent. */
  119. export class ApiSessionAgentController {
  120. private readonly resumes = new Map<SessionId, Promise<Agent>>()
  121. private readonly creations = new Map<SessionId, Promise<Agent>>()
  122. private readonly selections = new WeakMap<Agent, InstalledSelection>()
  123. private readonly imageAdmissionChains = new WeakMap<Agent, Promise<void>>()
  124. /** @param ctx - Host context carrying Agent, model, persistence, and Typert services. */
  125. constructor(private readonly ctx: Context) {
  126. ctx.typert.lookups.configure('agent', async (sessionId: SessionId) => {
  127. const found = await this.resolveAgent(sessionId)
  128. if ('error' in found) throw new TypertLookupFailure(found.error)
  129. return found.agent
  130. })
  131. ctx.typert.lookups.configure('session', async (sessionId: SessionId) => {
  132. const found = await this.resolveAgent(sessionId)
  133. if ('error' in found) throw new TypertLookupFailure(found.error)
  134. return found.agent.session
  135. })
  136. ctx.typert.contexts.configureHost('agent', async (sessionId: SessionId) => {
  137. const found = await this.resolveAgent(sessionId)
  138. if ('error' in found) throw new TypertLookupFailure(found.error)
  139. return found.agent.ctx
  140. })
  141. }
  142. /**
  143. * Resolve or resume one ordinary Session, deduplicating concurrent resumes.
  144. * @param sessionId - ordinary Session identity.
  145. * @returns the live Agent or a stable Session-domain failure.
  146. */
  147. async resolveAgent(sessionId: SessionId): Promise<ApiSessionAgentResult> {
  148. const live = this.liveAgent(sessionId)
  149. if (live !== undefined) return live
  150. const attached = this.ctx.sessions.get(sessionId)
  151. if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) {
  152. return { error: apiSessionSubagentOwnershipError(sessionId) }
  153. }
  154. let resume = this.resumes.get(sessionId)
  155. if (resume === undefined) {
  156. resume = this.resume(sessionId).finally(() => { this.resumes.delete(sessionId) })
  157. this.resumes.set(sessionId, resume)
  158. }
  159. try {
  160. return { agent: await resume }
  161. } catch (error: unknown) {
  162. if (error instanceof ApiSessionNotFound) {
  163. return {
  164. error: {
  165. code: 'session-not-found',
  166. message: error.message,
  167. details: { sessionId },
  168. },
  169. }
  170. }
  171. if (error instanceof ApiSessionSubagentOwnership) {
  172. return { error: apiSessionSubagentOwnershipError(error.sessionId) }
  173. }
  174. const raced = this.liveAgent(sessionId)
  175. if (raced !== undefined) return raced
  176. const racedSession = this.ctx.sessions.get(sessionId)
  177. if (racedSession !== undefined && hasApiSessionSubagentOwner(this.ctx, racedSession, undefined)) {
  178. return { error: apiSessionSubagentOwnershipError(sessionId) }
  179. }
  180. return {
  181. error: {
  182. code: 'internal',
  183. message: `resume failed for session "${sessionId}": ${String(error)}`,
  184. details: {},
  185. },
  186. }
  187. }
  188. }
  189. /**
  190. * Resolve one requested identity, creating or resuming it once.
  191. * @param sessionId - requested Session identity.
  192. * @param cwd - directory the Session must own.
  193. * @param checkPersistedIdentity - whether to inspect a cold identity before creation.
  194. * @param presetId - optional Agent preset the Session must own.
  195. * @returns the matching live ordinary Agent.
  196. */
  197. async ensureSession(
  198. sessionId: SessionId,
  199. cwd: string,
  200. checkPersistedIdentity: boolean,
  201. presetId?: string,
  202. ): Promise<Agent> {
  203. let creation = this.creations.get(sessionId)
  204. if (creation === undefined) {
  205. creation = this.createOrAdopt(sessionId, cwd, checkPersistedIdentity, presetId)
  206. .catch((error: unknown) => {
  207. const live = this.ctx.agents.get(sessionId)
  208. if (live !== undefined) {
  209. if (hasApiSessionSubagentOwner(this.ctx, live.session, live)) {
  210. throw new ApiSessionSubagentOwnership(sessionId)
  211. }
  212. return live
  213. }
  214. const attached = this.ctx.sessions.get(sessionId)
  215. if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) {
  216. throw new ApiSessionSubagentOwnership(sessionId)
  217. }
  218. throw error
  219. })
  220. .finally(() => { this.creations.delete(sessionId) })
  221. this.creations.set(sessionId, creation)
  222. }
  223. const agent = await creation
  224. if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
  225. throw new ApiSessionSubagentOwnership(sessionId)
  226. }
  227. this.assertPresetUnchanged(sessionId, presetId, resolveSessionPreset(agent.session))
  228. if (agent.session.header.cwd !== cwd) {
  229. throw new ApiSessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
  230. }
  231. return agent
  232. }
  233. /**
  234. * Install or return the Session-local model selection used by prompt assembly.
  235. * @param agent - live Agent that owns the selection.
  236. * @returns the installed mutable selection reference.
  237. */
  238. selectionFor(agent: Agent): InstalledSelection {
  239. const installed = this.selections.get(agent)
  240. if (installed !== undefined) return installed
  241. let picked: AgentModelSelection | undefined
  242. const defaultModel = this.ctx.agentDefaultModel
  243. const selection: InstalledSelection = {
  244. get current(): AgentModelSelection {
  245. if (picked !== undefined) return picked
  246. const logged = agent.session.requestHeader()?.config
  247. if (logged === undefined) return defaultModel.currentSelection()
  248. return {
  249. provider: logged.provider,
  250. model: logged.model,
  251. ...(logged.reasoningEffort === undefined ? {} : { reasoningEffort: logged.reasoningEffort }),
  252. }
  253. },
  254. set current(next: AgentModelSelection) {
  255. picked = next
  256. },
  257. assembled: undefined,
  258. }
  259. installModelSelection(agent.ctx, selection)
  260. this.selections.set(agent, selection)
  261. return selection
  262. }
  263. /**
  264. * Serialize image admission and model selection for one Agent.
  265. * @param agent - live Agent that owns the serialization chain.
  266. * @param operation - asynchronous operation admitted after prior work settles.
  267. * @returns the operation result or rejection.
  268. */
  269. serializeImageAdmission<Value>(agent: Agent, operation: () => Promise<Value>): Promise<Value> {
  270. const result = (this.imageAdmissionChains.get(agent) ?? Promise.resolve()).then(operation)
  271. this.imageAdmissionChains.set(agent, result.then(() => undefined, () => undefined))
  272. return result
  273. }
  274. /**
  275. * Resolve the preset id and pre-publication Agent setup for a create or resume.
  276. * @param presetId - requested preset or the configured default when omitted.
  277. * @returns the resolved preset identity and Agent setup callback.
  278. */
  279. async composeAgent(presetId: string | undefined): Promise<{
  280. readonly agentPreset?: string
  281. readonly setup: AgentSetup
  282. }> {
  283. const presets = this.ctx.get('agentPresets')
  284. if (presets === undefined) return { setup: (agentCtx) => { this.installSelection(agentCtx) } }
  285. const resolvedId = (await presets.resolve(presetId)).id
  286. return {
  287. agentPreset: resolvedId,
  288. setup: async (agentCtx) => {
  289. this.installSelection(agentCtx)
  290. await presets.mount(agentCtx, resolvedId)
  291. },
  292. }
  293. }
  294. private liveAgent(sessionId: SessionId): ApiSessionAgentResult | undefined {
  295. const agent = this.ctx.agents.get(sessionId)
  296. if (agent === undefined) return undefined
  297. return hasApiSessionSubagentOwner(this.ctx, agent.session, agent)
  298. ? { error: apiSessionSubagentOwnershipError(sessionId) }
  299. : { agent }
  300. }
  301. private async resume(sessionId: SessionId): Promise<Agent> {
  302. const inspected = await inspectApiSession(this.ctx, sessionId)
  303. if (hasApiSessionSubagentOwner(this.ctx, { header: inspected.meta }, undefined)) {
  304. throw new ApiSessionSubagentOwnership(sessionId)
  305. }
  306. const composition = await this.composeAgent(resolveSessionPreset({
  307. header: inspected.meta,
  308. events: inspected.events,
  309. }))
  310. const published = this.ctx.sessions.get(sessionId)
  311. const live = this.ctx.agents.get(sessionId)
  312. if (published !== undefined && hasApiSessionSubagentOwner(this.ctx, published, live)) {
  313. throw new ApiSessionSubagentOwnership(sessionId)
  314. }
  315. return (await this.ctx.agents.resume({
  316. resumeSessionId: sessionId,
  317. agentOptions: this.agentOptions(),
  318. setup: composition.setup,
  319. })).agent
  320. }
  321. private async createOrAdopt(
  322. sessionId: SessionId,
  323. cwd: string,
  324. checkPersistedIdentity: boolean,
  325. presetId: string | undefined,
  326. ): Promise<Agent> {
  327. const attached = this.ctx.sessions.get(sessionId)
  328. const live = this.ctx.agents.get(sessionId)
  329. if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, live)) {
  330. throw new ApiSessionSubagentOwnership(sessionId)
  331. }
  332. if (live !== undefined) return live
  333. const persistence = checkPersistedIdentity ? this.ctx.get('sessionPersistence') : undefined
  334. const stored = persistence === undefined
  335. ? undefined
  336. : (await persistence.list()).find(header => header.id === sessionId)
  337. if (persistence !== undefined && stored !== undefined) {
  338. const inspected = await persistence.inspect(sessionId)
  339. if (hasApiSessionSubagentOwner(this.ctx, { header: inspected.meta }, undefined)) {
  340. throw new ApiSessionSubagentOwnership(sessionId)
  341. }
  342. if (inspected.meta.cwd !== cwd) {
  343. throw new ApiSessionCwdConflict(sessionId, cwd, inspected.meta.cwd)
  344. }
  345. const storedPreset = resolveSessionPreset({ header: inspected.meta, events: inspected.events })
  346. this.assertPresetUnchanged(sessionId, presetId, storedPreset)
  347. const composition = await this.composeAgent(storedPreset)
  348. return (await this.ctx.agents.resume({
  349. resumeSessionId: sessionId,
  350. agentOptions: this.agentOptions(),
  351. setup: composition.setup,
  352. })).agent
  353. }
  354. try {
  355. await mkdir(cwd, { recursive: true })
  356. } catch (error: unknown) {
  357. throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error })
  358. }
  359. const composition = await this.composeAgent(presetId)
  360. return (await this.ctx.agents.create({
  361. sessionId,
  362. agentOptions: this.agentOptions(),
  363. meta: {
  364. cwd,
  365. ...(composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset }),
  366. },
  367. setup: composition.setup,
  368. })).agent
  369. }
  370. private agentOptions(): AgentOptions {
  371. const { provider, model } = this.ctx.agentDefaultModel.currentSelection()
  372. return { provider, model }
  373. }
  374. private installSelection(agentCtx: Context): void {
  375. const agent = agentCtx.agent
  376. if (agent === undefined) throw new Error('api-session: Agent setup has no scoped Agent')
  377. this.selectionFor(agent)
  378. }
  379. private assertPresetUnchanged(
  380. sessionId: SessionId,
  381. requested: string | undefined,
  382. existing: string | undefined,
  383. ): void {
  384. if (requested === undefined || requested === existing) return
  385. throw new ApiSessionPresetConflict(sessionId, requested, existing)
  386. }
  387. }