tool-calls.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. /**
  2. * Schedules one assistant step's tool calls. Exclusive calls form barriers;
  3. * parallel calls use a bounded rolling pool and are reclassified before start.
  4. * Dispatch may overlap, while policy, results, and result context remain
  5. * model-ordered. Abort or an internal scheduler failure stops replenishment
  6. * and drains started calls.
  7. *
  8. * Abort records synthetic error results for skipped calls so replay stays
  9. * valid. A terminal scheduler failure preserves already-recorded `tool/call`
  10. * events without fabricating results.
  11. * @module dsh-agent-loop/tool-calls
  12. */
  13. import type { Context } from '@deepseek-ai/cordis'
  14. import { assertNever, createToolResultMessage, type ToolCallBlock } from '@deepseek-ai/dsh-llm'
  15. import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
  16. import { TOOL_ABORTED_BEFORE_DISPATCH, TOOL_RUNTIME_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools'
  17. /** One tool call after argument parsing, ready to schedule. */
  18. interface PlannedCall {
  19. block: ToolCallBlock
  20. exec: ToolExecutionInput
  21. }
  22. /** Settled dispatch awaiting model-order finalization. */
  23. interface Slot {
  24. exec: ToolRunContext
  25. result: ToolExecutionResult
  26. needsPost: boolean
  27. }
  28. /** One scheduler group outcome, including a drained cancellation. */
  29. interface GroupOutcome {
  30. consumed: number
  31. aborted: boolean
  32. /** Whether any committed result carried {@link ToolExecutionResult.concludesTurn}. */
  33. concluded: boolean
  34. }
  35. /**
  36. * Schedule one assistant step's tool calls by their live concurrency mode.
  37. * Ordinary completion and abort commit started-call results in order. Abort
  38. * drains them, records synthetic results for unstarted calls, and returns with
  39. * the signal still aborted after accepting started-call context through the
  40. * caller-supplied acceptor (the machine stages it in its next-step inbox for the
  41. * step boundary). An internal scheduler failure stops new dispatches, drains
  42. * already-started dispatches, and rejects with the first failure without
  43. * fabricating tool results.
  44. * The committed step's AgentLoop driver boundary supplies the initiating Agent
  45. * that becomes each explicit {@link ToolExecutionInput.agent}.
  46. *
  47. * @param ctx - loop context that owns the tool registry and carries the initiating Agent.
  48. * @param turn - current turn number.
  49. * @param step - current step number.
  50. * @param toolCalls - assistant calls in model order.
  51. * @param signal - abort signal shared by the step.
  52. * @param acceptContext - accepts committed result context for the next step boundary.
  53. */
  54. export async function executeToolCalls(
  55. ctx: Context,
  56. turn: number,
  57. step: number,
  58. toolCalls: ToolCallBlock[],
  59. signal: AbortSignal,
  60. acceptContext: (context: UserMessage) => void,
  61. ): Promise<{ concluded: boolean }> {
  62. const agent = ctx.agents.requireInitiator()
  63. const { session } = agent
  64. // Inputs are distinct because tools/execute wrappers may replace `exec.signal`.
  65. const planned: PlannedCall[] = toolCalls.map(block => ({
  66. block,
  67. exec: {
  68. callId: block.id,
  69. name: block.name,
  70. arguments: parseArguments(block.arguments),
  71. agent,
  72. signal,
  73. },
  74. }))
  75. let next = 0
  76. let concluded = false
  77. while (next < planned.length) {
  78. // Commit before classifying again so registry changes affect unstarted calls.
  79. // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition
  80. const first = planned[next]!
  81. const mode = ctx.tools.executionMode(first.exec).kind
  82. const group = mode === 'parallel' ? planned.slice(next) : [first]
  83. const outcome = await runGroup(
  84. ctx, turn, step, group, mode, signal, acceptContext,
  85. )
  86. next += outcome.consumed
  87. concluded ||= outcome.concluded
  88. if (outcome.aborted) {
  89. for (const call of planned.slice(next)) appendSkippedToolCall(session, turn, step, call.block)
  90. return { concluded }
  91. }
  92. }
  93. return { concluded }
  94. }
  95. /** Parse model arguments, preserving invalid JSON as text and mapping empty input to `{}`. */
  96. function parseArguments(raw: string): unknown {
  97. try {
  98. return raw ? JSON.parse(raw) : {}
  99. } catch {
  100. return raw
  101. }
  102. }
  103. /**
  104. * Run one exclusive barrier or parallel pool. Later calls are reclassified
  105. * before start; an exclusive reclassification waits for the current pool to
  106. * drain and remains for the caller's next barrier. Results and contexts commit
  107. * in model order. Abort stops starts, drains and commits started calls, accepts
  108. * their contexts into the owning batch, records results for skipped calls, and
  109. * returns an aborted outcome. Scheduler failure drains dispatches without
  110. * committing synthetic recovery results.
  111. */
  112. async function runGroup(
  113. ctx: Context,
  114. turn: number,
  115. step: number,
  116. group: PlannedCall[],
  117. mode: ToolExecutionMode['kind'],
  118. signal: AbortSignal,
  119. acceptContext: (context: UserMessage) => void,
  120. ): Promise<GroupOutcome> {
  121. const { session } = ctx.agents.requireInitiator()
  122. const { maxParallelToolCalls } = ctx.agentLoop.config
  123. const slots: (Slot | undefined)[] = group.map(() => undefined)
  124. // Started slots retain their `tool/call` seq so the result can cite it.
  125. const callSeqs: number[] = group.map(() => -1)
  126. let nextToStart = 0
  127. let committed = 0
  128. let started = 0
  129. let aborted: boolean = signal.aborted
  130. let concluded = false
  131. let schedulerFailure: { error: unknown } | undefined
  132. const throwSchedulerFailure = (): void => {
  133. if (schedulerFailure !== undefined) throw schedulerFailure.error
  134. }
  135. // `committed` advances only across contiguous model-order slots.
  136. const commitReady = async (): Promise<void> => {
  137. while (committed < group.length) {
  138. const slot = slots[committed]
  139. if (slot === undefined) break
  140. const call = group[committed]
  141. const result = slot.needsPost
  142. ? await ctx.tools[TOOL_RUNTIME_SCHEDULER].finalize(slot.exec, slot.result)
  143. : ctx.tools[TOOL_RUNTIME_SCHEDULER].finish(slot.exec, slot.result)
  144. // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded index
  145. appendToolResult(session, turn, step, call!.block, result, callSeqs[committed]!)
  146. for (const context of result.additionalContexts ?? []) acceptContext(context)
  147. concluded ||= result.concludesTurn === true
  148. committed++
  149. }
  150. }
  151. const inFlight = new Map<number, Promise<number>>()
  152. const startCall = async (index: number): Promise<void> => {
  153. // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded index
  154. const call = group[index]!
  155. callSeqs[index] = appendToolCall(session, turn, step, call.block)
  156. started++
  157. const prepared = await ctx.tools[TOOL_RUNTIME_SCHEDULER].prepare(call.exec)
  158. throwSchedulerFailure()
  159. switch (prepared.kind) {
  160. case 'dispatch': {
  161. const promise = ctx.tools[TOOL_RUNTIME_SCHEDULER].dispatch(prepared.exec).then(
  162. (outcome) => {
  163. slots[index] = { exec: prepared.exec, result: outcome.result, needsPost: outcome.kind === 'post-result' }
  164. return index
  165. },
  166. (error: unknown) => {
  167. schedulerFailure ??= { error }
  168. return index
  169. },
  170. )
  171. inFlight.set(index, promise)
  172. break
  173. }
  174. case 'post-result':
  175. slots[index] = { exec: prepared.exec, result: prepared.result, needsPost: true }
  176. break
  177. case 'final-result':
  178. slots[index] = { exec: prepared.exec, result: prepared.result, needsPost: false }
  179. break
  180. /* v8 ignore next -- closed-union exhaustiveness guard */
  181. default:
  182. assertNever(prepared, 'tool-call scheduler prepare result')
  183. }
  184. }
  185. const fillPool = async (): Promise<void> => {
  186. while (!aborted && nextToStart < group.length && inFlight.size < maxParallelToolCalls) {
  187. // Re-read later modes after ordered commits so registry changes can create a barrier.
  188. // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition
  189. const nextCall = group[nextToStart]!
  190. if (nextToStart > 0 && mode === 'parallel'
  191. && ctx.tools.executionMode(nextCall.exec).kind !== 'parallel') break
  192. await startCall(nextToStart)
  193. nextToStart++
  194. throwSchedulerFailure()
  195. await commitReady()
  196. throwSchedulerFailure()
  197. // Abort may arrive while pre-execute awaits.
  198. if (signal.aborted) aborted = true
  199. }
  200. }
  201. // Ordered pre-execute may await; only dispatch/body overlaps. A scheduler
  202. // failure stops new dispatches and reaches the turn boundary after every
  203. // already-started dispatch settles.
  204. try {
  205. await fillPool()
  206. while (inFlight.size > 0) {
  207. const settledIndex = await Promise.race(inFlight.values())
  208. inFlight.delete(settledIndex)
  209. throwSchedulerFailure()
  210. await commitReady()
  211. throwSchedulerFailure()
  212. // Abort may arrive while a tool or ordered commit awaits.
  213. if (signal.aborted) aborted = true
  214. await fillPool()
  215. }
  216. } catch (error: unknown) {
  217. schedulerFailure ??= { error }
  218. await Promise.allSettled(inFlight.values())
  219. throw schedulerFailure.error
  220. }
  221. if (aborted) {
  222. // Started calls and accepted context settle first; every remaining model
  223. // call then receives an ordered synthetic result before the turn aborts.
  224. for (const call of group.slice(started)) appendSkippedToolCall(session, turn, step, call.block)
  225. return { consumed: group.length, aborted: true, concluded }
  226. }
  227. /* v8 ignore next -- unreachable: a non-aborted group commits every started call */
  228. if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls')
  229. return { consumed: started, aborted: false, concluded }
  230. }
  231. /** Append the durable call/result pair for a model call skipped after cancellation. */
  232. function appendSkippedToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): void {
  233. const callSeq = appendToolCall(session, turn, step, block)
  234. appendToolResult(session, turn, step, block, {
  235. content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
  236. isError: true,
  237. error: {
  238. message: 'tool call aborted before dispatch',
  239. info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
  240. },
  241. }, callSeq)
  242. }
  243. /** Append a started call and return the event seq that its result must cite. */
  244. function appendToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): number {
  245. const event = session.append('tool/call', { turn, step, callId: block.id, name: block.name, arguments: block.arguments })
  246. return event.seq
  247. }
  248. /** Append a model-ordered result linked to its call event. */
  249. function appendToolResult(
  250. session: Session,
  251. turn: number,
  252. step: number,
  253. block: ToolCallBlock,
  254. result: ToolExecutionResult,
  255. callSeq: number,
  256. ): void {
  257. const message = createToolResultMessage({
  258. callId: block.id,
  259. content: result.content,
  260. isError: result.isError,
  261. })
  262. session.append('tool/result', {
  263. turn, step,
  264. message,
  265. ...result.error?.info ? { error: result.error.info } : {},
  266. // The tool's private presentation payload (e.g. a result-time diff),
  267. // persisted so a UI bridge reproduces the card on replay.
  268. ...result.meta !== undefined ? { meta: result.meta } : {},
  269. }, { surfaceOp: 'append', sourceEventSeqs: [callSeq] })
  270. }