1
0

agent.ts 20 KB

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