tool-calls.ts 9.8 KB

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