tool-call-parser.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. import type { ToolCallDelta } from "./types"
  2. import type { ToolCall } from "../llm-providers"
  3. export function accumulateToolCalls(deltas: ToolCallDelta[]): ToolCall[] {
  4. const groups = new Map<number, { id: string; name: string; argsChunks: string[] }>()
  5. for (const delta of deltas) {
  6. const group = groups.get(delta.index) || { id: "", name: "", argsChunks: [] }
  7. if (delta.id) group.id = delta.id
  8. if (delta.name) group.name = delta.name
  9. if (delta.arguments) group.argsChunks.push(delta.arguments)
  10. groups.set(delta.index, group)
  11. }
  12. return Array.from(groups.values()).map((g) => {
  13. const argsStr = g.argsChunks.join("")
  14. return {
  15. id: g.id,
  16. type: "function" as const,
  17. function: {
  18. name: g.name,
  19. arguments: argsStr,
  20. },
  21. }
  22. })
  23. }
  24. function extractJsonCandidates(text: string): string[] {
  25. const trimmed = text.trim()
  26. if (!trimmed) return []
  27. const candidates: string[] = []
  28. const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)
  29. if (fenced?.[1]?.trim()) {
  30. candidates.push(fenced[1].trim())
  31. }
  32. const firstObj = trimmed.indexOf("{")
  33. const lastObj = trimmed.lastIndexOf("}")
  34. if (firstObj >= 0 && lastObj > firstObj) {
  35. candidates.push(trimmed.slice(firstObj, lastObj + 1))
  36. }
  37. const firstArr = trimmed.indexOf("[")
  38. const lastArr = trimmed.lastIndexOf("]")
  39. if (firstArr >= 0 && lastArr > firstArr) {
  40. candidates.push(trimmed.slice(firstArr, lastArr + 1))
  41. }
  42. if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
  43. candidates.unshift(trimmed)
  44. }
  45. return [...new Set(candidates)]
  46. }
  47. function stringifyArguments(value: unknown): string {
  48. if (typeof value === "string") {
  49. const trimmed = value.trim()
  50. if (!trimmed) return "{}"
  51. try {
  52. JSON.parse(trimmed)
  53. return trimmed
  54. } catch {
  55. return JSON.stringify(value)
  56. }
  57. }
  58. if (value && typeof value === "object") {
  59. return JSON.stringify(value)
  60. }
  61. return "{}"
  62. }
  63. function toolCallFromObject(
  64. raw: Record<string, unknown>,
  65. allowedToolNames: ReadonlySet<string>,
  66. index: number,
  67. ): ToolCall | null {
  68. const nestedFunction =
  69. raw.function && typeof raw.function === "object"
  70. ? (raw.function as Record<string, unknown>)
  71. : null
  72. const nameCandidate = [
  73. raw.name,
  74. raw.tool,
  75. raw.tool_name,
  76. nestedFunction?.name,
  77. ].find((value): value is string => typeof value === "string" && value.trim().length > 0)
  78. if (!nameCandidate) return null
  79. const name = nameCandidate.trim()
  80. if (!allowedToolNames.has(name)) return null
  81. const args =
  82. raw.arguments ??
  83. raw.parameters ??
  84. raw.input ??
  85. nestedFunction?.arguments ??
  86. nestedFunction?.parameters ??
  87. {}
  88. const id =
  89. (typeof raw.id === "string" && raw.id.trim()) ||
  90. (typeof nestedFunction?.id === "string" && nestedFunction.id.trim()) ||
  91. `text_call_${index}_${Date.now()}`
  92. return {
  93. id,
  94. type: "function",
  95. function: {
  96. name,
  97. arguments: stringifyArguments(args),
  98. },
  99. }
  100. }
  101. function collectToolCallsFromParsed(
  102. parsed: unknown,
  103. allowedToolNames: ReadonlySet<string>,
  104. ): ToolCall[] {
  105. if (Array.isArray(parsed)) {
  106. return parsed.flatMap((item, index) => {
  107. if (!item || typeof item !== "object") return []
  108. const call = toolCallFromObject(item as Record<string, unknown>, allowedToolNames, index)
  109. return call ? [call] : []
  110. })
  111. }
  112. if (!parsed || typeof parsed !== "object") return []
  113. const obj = parsed as Record<string, unknown>
  114. if (Array.isArray(obj.tool_calls)) {
  115. return collectToolCallsFromParsed(obj.tool_calls, allowedToolNames)
  116. }
  117. if (Array.isArray(obj.tools)) {
  118. return collectToolCallsFromParsed(obj.tools, allowedToolNames)
  119. }
  120. const single = toolCallFromObject(obj, allowedToolNames, 0)
  121. return single ? [single] : []
  122. }
  123. /**
  124. * cursor-api-proxy 等桥接层只能把 tools schema 注入 prompt,无法返回原生
  125. * tool_calls delta。从模型文本里解析 JSON 工具调用。
  126. */
  127. export function parseTextToolCalls(
  128. text: string,
  129. allowedToolNames: ReadonlySet<string>,
  130. ): { toolCalls: ToolCall[]; residualText: string } {
  131. if (!text.trim() || allowedToolNames.size === 0) {
  132. return { toolCalls: [], residualText: text }
  133. }
  134. for (const candidate of extractJsonCandidates(text)) {
  135. let parsed: unknown
  136. try {
  137. parsed = JSON.parse(candidate)
  138. } catch {
  139. continue
  140. }
  141. const toolCalls = collectToolCallsFromParsed(parsed, allowedToolNames)
  142. if (toolCalls.length === 0) continue
  143. const residualText = text.includes(candidate)
  144. ? text.replace(candidate, "").replace(/```(?:json)?\s*```/gi, "").trim()
  145. : ""
  146. return { toolCalls, residualText }
  147. }
  148. return { toolCalls: [], residualText: text }
  149. }