tool-calls.ts 10 KB

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