agent.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  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 type {} from '@deepseek-ai/dsh-agent-presets'
  10. import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
  11. import type { Session, SessionId } from '@deepseek-ai/dsh-session'
  12. import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
  13. import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
  14. import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
  15. import type {} from '@deepseek-ai/dsh-typert-registry'
  16. import type { ModelSelection } from './types.ts'
  17. /** Cold Session identity absent from persistence. */
  18. export class ApiSessionNotFound extends Error {}
  19. /** Session identity whose lifecycle belongs to subagent routing. */
  20. export class ApiSessionSubagentOwnership extends Error {
  21. /** @param sessionId - identity reserved to subagent routing. */
  22. constructor(readonly sessionId: SessionId) {
  23. super(`session "${sessionId}" is a subagent session; use subagent delivery`)
  24. }
  25. }
  26. /** Explicit-id creation attempted to adopt a Session under another cwd. */
  27. export class ApiSessionCwdConflict extends Error {
  28. constructor(
  29. readonly sessionId: SessionId,
  30. readonly requestedCwd: string,
  31. readonly existingCwd: string | undefined,
  32. ) {
  33. super(
  34. existingCwd === undefined
  35. ? `session "${sessionId}" records no cwd and cannot be adopted for "${requestedCwd}"`
  36. : `session "${sessionId}" belongs to "${existingCwd}", not "${requestedCwd}"`,
  37. )
  38. }
  39. }
  40. /** Explicit-id creation attempted to adopt a Session under another preset. */
  41. export class ApiSessionPresetConflict extends Error {
  42. constructor(
  43. readonly sessionId: SessionId,
  44. readonly requestedPreset: string,
  45. readonly existingPreset: string | undefined,
  46. ) {
  47. super(
  48. existingPreset === undefined
  49. ? `session "${sessionId}" records no agent preset and cannot be adopted under "${requestedPreset}"`
  50. : `session "${sessionId}" runs agent preset "${existingPreset}", not "${requestedPreset}"`,
  51. )
  52. }
  53. }
  54. /** Failures produced while resolving one ordinary Session identity to its live Agent. */
  55. export type ApiSessionAgentError = RemoteError<'session/not-found' | 'session/agent-busy' | 'session/writer-held' | 'gateway/internal'>
  56. /** Result of resolving one ordinary Session identity to its live Agent. */
  57. export type ApiSessionAgentResult =
  58. | { readonly agent: Agent }
  59. | { readonly error: ApiSessionAgentError }
  60. type InstalledSelection = ModelSelectionRef & {
  61. current: AgentModelSelection
  62. consume(provider: string, model: string, reasoningEffort: string | undefined): boolean
  63. }
  64. /**
  65. * Test whether generic Session routing must leave an identity to subagent routing.
  66. * @param ctx - Host context carrying the Agent ownership registry.
  67. * @param session - attached or live Session whose ownership is tested.
  68. * @param agent - live Agent when one exists for the Session.
  69. * @returns whether subagent routing owns the Session identity.
  70. */
  71. export function hasApiSessionSubagentOwner(
  72. ctx: Context,
  73. session: Pick<Session, 'header'>,
  74. agent: Agent | undefined,
  75. ): boolean {
  76. if (session.header.origin === 'subagent') return true
  77. const parentId = session.header.parentSession
  78. if (parentId === undefined || agent === undefined) return false
  79. const parent = ctx.agents.get(parentId)
  80. return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent)
  81. }
  82. /**
  83. * Build the stable caller-facing subagent ownership rejection.
  84. * @param sessionId - Session identity owned by subagent routing.
  85. * @returns a stable Session-domain failure.
  86. */
  87. export function apiSessionSubagentOwnershipError(sessionId: SessionId): ApiSessionAgentError {
  88. return new RemoteError(
  89. 'session/agent-busy',
  90. `session "${sessionId}" is owned by subagent routing`,
  91. { reason: 'use subagent delivery for this child session' },
  92. )
  93. }
  94. /**
  95. * Inspect one cold Session without repairing, resuming, or publishing it.
  96. * @param ctx - Host context carrying Session persistence.
  97. * @param sessionId - durable Session identity.
  98. * @param signal - optional cancellation for persistence reads.
  99. * @returns the persisted header and complete event prefix.
  100. */
  101. export async function inspectApiSession(
  102. ctx: Context,
  103. sessionId: SessionId,
  104. signal?: AbortSignal,
  105. ): Promise<SessionInspection> {
  106. try {
  107. using observation = await ctx.sessionQuery.observeSession(sessionId, {
  108. ...(signal === undefined ? {} : { signal }),
  109. projectionMode: 'none',
  110. })
  111. if (observation.header.cwd === undefined) {
  112. throw new ApiSessionNotFound(`session "${sessionId}" not found`)
  113. }
  114. return {
  115. meta: observation.header,
  116. inheritedEventCount: observation.inheritedEventCount,
  117. events: [...observation.events],
  118. }
  119. } catch (error: unknown) {
  120. if (error instanceof SessionQueryError
  121. && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
  122. throw new ApiSessionNotFound(`session "${sessionId}" not found`)
  123. }
  124. throw error
  125. }
  126. }
  127. /** Owns every operation that may create, resume, or configure a Web Agent. */
  128. export class ApiSessionAgentController {
  129. private readonly resumes = new Map<SessionId, Promise<Agent>>()
  130. private readonly creations = new Map<SessionId, Promise<Agent>>()
  131. private readonly selections = new WeakMap<Agent, InstalledSelection>()
  132. private readonly imageAdmissionChains = new WeakMap<Agent, Promise<void>>()
  133. /** @param ctx - Host context carrying Agent, model, persistence, and Typert services. */
  134. constructor(private readonly ctx: Context) {
  135. ctx.typert.lookups.configure('agent', async (sessionId: SessionId) => {
  136. const found = await this.resolveAgent(sessionId)
  137. if ('error' in found) throw found.error
  138. return found.agent
  139. })
  140. ctx.typert.lookups.configure('session', async (sessionId: SessionId) => {
  141. const found = await this.resolveAgent(sessionId)
  142. if ('error' in found) throw found.error
  143. return found.agent.session
  144. })
  145. ctx.typert.contexts.configureHost('agent', async (sessionId: SessionId) => {
  146. const found = await this.resolveAgent(sessionId)
  147. if ('error' in found) throw found.error
  148. return found.agent.ctx
  149. })
  150. }
  151. /**
  152. * Resolve or resume one ordinary Session, deduplicating concurrent resumes.
  153. * @param sessionId - ordinary Session identity.
  154. * @returns the live Agent or a stable Session-domain failure.
  155. */
  156. async resolveAgent(sessionId: SessionId): Promise<ApiSessionAgentResult> {
  157. return this.resolve(sessionId)
  158. }
  159. /**
  160. * Resolve one ordinary Session from an already-retained exact observation.
  161. * @param observation - Host-owned observation whose preparation stays pinned through setup.
  162. * @returns the live Agent or a stable Session-domain failure.
  163. */
  164. async resolveObservedAgent(observation: SessionObservation): Promise<ApiSessionAgentResult> {
  165. return this.resolve(observation.header.id, observation)
  166. }
  167. private async resolve(
  168. sessionId: SessionId,
  169. observation?: SessionObservation,
  170. ): Promise<ApiSessionAgentResult> {
  171. const live = this.liveAgent(sessionId)
  172. if (live !== undefined) return live
  173. const attached = this.ctx.sessions.get(sessionId)
  174. if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) {
  175. return { error: apiSessionSubagentOwnershipError(sessionId) }
  176. }
  177. let resume = this.resumes.get(sessionId)
  178. if (resume === undefined) {
  179. resume = this.resume(sessionId, observation).finally(() => { this.resumes.delete(sessionId) })
  180. this.resumes.set(sessionId, resume)
  181. }
  182. try {
  183. const agent = await resume
  184. // A shared resume can publish an identity that subagent routing adopts
  185. // before every waiter observes it; apply the live ownership policy again.
  186. const published = this.liveAgent(sessionId)
  187. return published ?? { agent }
  188. } catch (error: unknown) {
  189. if (error instanceof ApiSessionNotFound) {
  190. return { error: new RemoteError('session/not-found', error.message, { sessionId }) }
  191. }
  192. if (error instanceof ApiSessionSubagentOwnership) {
  193. return { error: apiSessionSubagentOwnershipError(error.sessionId) }
  194. }
  195. const raced = this.liveAgent(sessionId)
  196. if (raced !== undefined) return raced
  197. const racedSession = this.ctx.sessions.get(sessionId)
  198. if (racedSession !== undefined && hasApiSessionSubagentOwner(this.ctx, racedSession, undefined)) {
  199. return { error: apiSessionSubagentOwnershipError(sessionId) }
  200. }
  201. if (error instanceof Error && error.name === 'SessionAlreadyOwnedError') {
  202. return { error: new RemoteError('session/writer-held', error.message, { sessionId }) }
  203. }
  204. return {
  205. error: new RemoteError(
  206. 'gateway/internal',
  207. `resume failed for session "${sessionId}": ${String(error)}`,
  208. {},
  209. ),
  210. }
  211. }
  212. }
  213. /**
  214. * Resolve one requested identity, creating or resuming it once.
  215. * @param sessionId - requested Session identity.
  216. * @param cwd - directory the Session must own.
  217. * @param checkPersistedIdentity - whether to inspect a cold identity before creation.
  218. * @param presetId - optional Agent preset the Session must own.
  219. * @returns the matching live ordinary Agent.
  220. */
  221. async ensureSession(
  222. sessionId: SessionId,
  223. cwd: string,
  224. checkPersistedIdentity: boolean,
  225. presetId?: string,
  226. ): Promise<Agent> {
  227. let creation = this.creations.get(sessionId)
  228. if (creation === undefined) {
  229. creation = this.createOrAdopt(sessionId, cwd, checkPersistedIdentity, presetId)
  230. .catch((error: unknown) => {
  231. const live = this.ctx.agents.get(sessionId)
  232. if (live !== undefined) {
  233. if (hasApiSessionSubagentOwner(this.ctx, live.session, live)) {
  234. throw new ApiSessionSubagentOwnership(sessionId)
  235. }
  236. return live
  237. }
  238. const attached = this.ctx.sessions.get(sessionId)
  239. if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) {
  240. throw new ApiSessionSubagentOwnership(sessionId)
  241. }
  242. throw error
  243. })
  244. .finally(() => { this.creations.delete(sessionId) })
  245. this.creations.set(sessionId, creation)
  246. }
  247. const agent = await creation
  248. if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
  249. throw new ApiSessionSubagentOwnership(sessionId)
  250. }
  251. if (presetId !== undefined) {
  252. this.assertPresetUnchanged(sessionId, presetId, this.presetForSession(agent.session))
  253. }
  254. if (agent.session.header.cwd !== cwd) {
  255. throw new ApiSessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
  256. }
  257. return agent
  258. }
  259. /**
  260. * Install or return the Session-local model selection used by prompt assembly.
  261. * @param agent - live Agent that owns the selection.
  262. * @returns the installed mutable selection reference.
  263. */
  264. selectionFor(agent: Agent): InstalledSelection {
  265. const installed = this.selections.get(agent)
  266. if (installed !== undefined) return installed
  267. const projectionState = this.ctx.sessionProjections.stateOf(agent.session, 'modelSelection')
  268. if (projectionState === undefined) {
  269. throw new Error('api-session: required modelSelection projection is not registered')
  270. }
  271. let picked = projectionState.pending === null
  272. ? undefined
  273. : agentModelSelection(projectionState.pending)
  274. const defaultModel = this.ctx.agentDefaultModel
  275. const selection: InstalledSelection = {
  276. get current(): AgentModelSelection {
  277. if (picked !== undefined) return picked
  278. const loggedHeader = agent.session.requestHeader()
  279. if (loggedHeader === undefined) return defaultModel.currentSelection()
  280. const logged = loggedHeader.config
  281. return {
  282. provider: logged.provider,
  283. model: logged.model,
  284. // An effort the adapter defaulted is not a conversation choice: restoring
  285. // it as one would make an unchanged default read as a request change.
  286. ...(logged.reasoningEffort === undefined
  287. || loggedHeader.adapterDefaults?.reasoningEffort === true
  288. ? {}
  289. : { reasoningEffort: logged.reasoningEffort }),
  290. }
  291. },
  292. set current(next: AgentModelSelection) {
  293. picked = next
  294. },
  295. consume(provider: string, model: string, reasoningEffort: string | undefined): boolean {
  296. if (picked?.provider !== provider
  297. || picked.model !== model
  298. || picked.reasoningEffort !== reasoningEffort) return false
  299. picked = undefined
  300. return true
  301. },
  302. assembled: undefined,
  303. }
  304. installModelSelection(agent.ctx, selection)
  305. this.selections.set(agent, selection)
  306. return selection
  307. }
  308. /**
  309. * Commit and cache one validated selection for the next prompt assembly.
  310. * @param agent - live Agent that owns the selection.
  311. * @param selection - validated selection to record and apply.
  312. */
  313. selectForNextRequest(agent: Agent, selection: AgentModelSelection): void {
  314. agent.session.append('model/selection', selection)
  315. this.selectionFor(agent).current = selection
  316. }
  317. /**
  318. * Let a matching durable request header retire the execution cache.
  319. * @param agent - live Agent whose request was recorded.
  320. * @param provider - provider route used by the request.
  321. * @param model - provider-owned model used by the request.
  322. * @param reasoningEffort - adapter-owned effort used by the request.
  323. * @returns whether the pending selection was consumed.
  324. */
  325. consumeSelection(
  326. agent: Agent,
  327. provider: string,
  328. model: string,
  329. reasoningEffort: string | undefined,
  330. ): boolean {
  331. return this.selections.get(agent)?.consume(provider, model, reasoningEffort) ?? false
  332. }
  333. /**
  334. * Read the current Agent preset from the Session projection.
  335. * @param session - live Session whose projection state is available.
  336. * @returns the current preset, or undefined when the capability is absent.
  337. */
  338. presetForSession(session: Session): string | undefined {
  339. return this.ctx.sessionProjections.stateOf(session, 'agentPreset') ?? undefined
  340. }
  341. /**
  342. * Serialize image admission and model selection for one Agent.
  343. * @param agent - live Agent that owns the serialization chain.
  344. * @param operation - asynchronous operation admitted after prior work settles.
  345. * @returns the operation result or rejection.
  346. */
  347. serializeImageAdmission<Value>(agent: Agent, operation: () => Promise<Value>): Promise<Value> {
  348. const result = (this.imageAdmissionChains.get(agent) ?? Promise.resolve()).then(operation)
  349. this.imageAdmissionChains.set(agent, result.then(() => undefined, () => undefined))
  350. return result
  351. }
  352. /**
  353. * Resolve the preset id and pre-publication Agent setup for a create or resume.
  354. * @param presetId - requested preset or the configured default when omitted.
  355. * @returns the resolved preset identity and Agent setup callback.
  356. */
  357. async composeAgent(presetId: string | undefined): Promise<{
  358. readonly agentPreset?: string
  359. readonly setup: AgentSetup
  360. }> {
  361. const presets = this.ctx.get('agentPresets')
  362. if (presets === undefined) {
  363. return { setup: (_agentCtx, agent) => { this.installSelection(agent) } }
  364. }
  365. const resolvedId = (await presets.resolve(presetId)).id
  366. return {
  367. agentPreset: resolvedId,
  368. setup: async (agentCtx, agent) => {
  369. this.installSelection(agent)
  370. await presets.mount(agentCtx, resolvedId)
  371. },
  372. }
  373. }
  374. private liveAgent(sessionId: SessionId): ApiSessionAgentResult | undefined {
  375. const agent = this.ctx.agents.get(sessionId)
  376. if (agent === undefined) return undefined
  377. return hasApiSessionSubagentOwner(this.ctx, agent.session, agent)
  378. ? { error: apiSessionSubagentOwnershipError(sessionId) }
  379. : { agent }
  380. }
  381. private async resume(sessionId: SessionId, supplied?: SessionObservation): Promise<Agent> {
  382. if (supplied !== undefined) return this.resumeObserved(sessionId, supplied)
  383. try {
  384. using observation = await this.ctx.sessionQuery.observeSession(sessionId)
  385. return await this.resumeObserved(sessionId, observation)
  386. } catch (error: unknown) {
  387. if (error instanceof SessionQueryError
  388. && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
  389. throw new ApiSessionNotFound(`session "${sessionId}" not found`)
  390. }
  391. throw error
  392. }
  393. }
  394. private async resumeObserved(
  395. sessionId: SessionId,
  396. observation: SessionObservation,
  397. ): Promise<Agent> {
  398. if (observation.header.id !== sessionId || observation.header.cwd === undefined) {
  399. throw new ApiSessionNotFound(`session "${sessionId}" not found`)
  400. }
  401. if (hasApiSessionSubagentOwner(this.ctx, { header: observation.header }, undefined)) {
  402. throw new ApiSessionSubagentOwnership(sessionId)
  403. }
  404. const composition = await this.composeAgent(this.presetForObservation(observation))
  405. const published = this.ctx.sessions.get(sessionId)
  406. const live = this.ctx.agents.get(sessionId)
  407. if (published !== undefined && hasApiSessionSubagentOwner(this.ctx, published, live)) {
  408. throw new ApiSessionSubagentOwnership(sessionId)
  409. }
  410. return (await this.ctx.agents.resume({
  411. resumeSessionId: sessionId,
  412. agentOptions: this.agentOptions(),
  413. setup: composition.setup,
  414. })).agent
  415. }
  416. private async createOrAdopt(
  417. sessionId: SessionId,
  418. cwd: string,
  419. checkPersistedIdentity: boolean,
  420. presetId: string | undefined,
  421. ): Promise<Agent> {
  422. const attached = this.ctx.sessions.get(sessionId)
  423. const live = this.ctx.agents.get(sessionId)
  424. if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, live)) {
  425. throw new ApiSessionSubagentOwnership(sessionId)
  426. }
  427. if (live !== undefined) return live
  428. if (checkPersistedIdentity) {
  429. try {
  430. using observation = await this.ctx.sessionQuery.observeSession(sessionId)
  431. if (hasApiSessionSubagentOwner(this.ctx, { header: observation.header }, undefined)) {
  432. throw new ApiSessionSubagentOwnership(sessionId)
  433. }
  434. if (observation.header.cwd !== cwd) {
  435. throw new ApiSessionCwdConflict(sessionId, cwd, observation.header.cwd)
  436. }
  437. const storedPreset = this.presetForObservation(observation)
  438. this.assertPresetUnchanged(sessionId, presetId, storedPreset)
  439. const composition = await this.composeAgent(storedPreset)
  440. return (await this.ctx.agents.resume({
  441. resumeSessionId: sessionId,
  442. agentOptions: this.agentOptions(),
  443. setup: composition.setup,
  444. })).agent
  445. } catch (error: unknown) {
  446. if (!(error instanceof SessionQueryError)
  447. || error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error
  448. }
  449. }
  450. try {
  451. await mkdir(cwd, { recursive: true })
  452. } catch (error: unknown) {
  453. throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error })
  454. }
  455. const composition = await this.composeAgent(presetId)
  456. return (await this.ctx.agents.create({
  457. sessionId,
  458. agentOptions: this.agentOptions(),
  459. meta: {
  460. cwd,
  461. ...(composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset }),
  462. },
  463. setup: composition.setup,
  464. })).agent
  465. }
  466. private agentOptions(): AgentOptions {
  467. const { provider, model } = this.ctx.agentDefaultModel.currentSelection()
  468. return { provider, model }
  469. }
  470. private installSelection(agent: Agent): void {
  471. this.selectionFor(agent)
  472. }
  473. /**
  474. * Read the current Agent preset from an all-projections observation.
  475. * @param observation - exact Session observation carrying its projection snapshot.
  476. * @returns the current preset, or undefined when the capability is absent.
  477. */
  478. presetForObservation(observation: SessionObservation): string | undefined {
  479. if (observation.projections === undefined) {
  480. throw new Error('api-session: Agent activation requires a projected Session observation')
  481. }
  482. return observation.projections.values.agentPreset ?? undefined
  483. }
  484. private assertPresetUnchanged(
  485. sessionId: SessionId,
  486. requested: string | undefined,
  487. existing: string | undefined,
  488. ): void {
  489. if (requested === undefined || requested === existing) return
  490. throw new ApiSessionPresetConflict(sessionId, requested, existing)
  491. }
  492. }
  493. function agentModelSelection(selection: ModelSelection): AgentModelSelection {
  494. return {
  495. provider: selection.provider,
  496. model: selection.model,
  497. ...(selection.reasoningEffort === undefined
  498. ? {}
  499. : { reasoningEffort: ReasoningEffortId(selection.reasoningEffort) }),
  500. }
  501. }