parse-assistant-message.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. import {
  2. AssistantMessageContent,
  3. TextContent,
  4. ToolUse,
  5. ToolParamName,
  6. toolParamNames,
  7. toolUseNames,
  8. ToolUseName,
  9. } from "."
  10. export function parseAssistantMessage(assistantMessage: string) {
  11. let contentBlocks: AssistantMessageContent[] = []
  12. let currentTextContent: TextContent | undefined = undefined
  13. let currentTextContentStartIndex = 0
  14. let currentToolUse: ToolUse | undefined = undefined
  15. let currentToolUseStartIndex = 0
  16. let currentParamName: ToolParamName | undefined = undefined
  17. let currentParamValueStartIndex = 0
  18. let accumulator = ""
  19. for (let i = 0; i < assistantMessage.length; i++) {
  20. const char = assistantMessage[i]
  21. accumulator += char
  22. // there should not be a param without a tool use
  23. if (currentToolUse && currentParamName) {
  24. const currentParamValue = accumulator.slice(currentParamValueStartIndex)
  25. const paramClosingTag = `</${currentParamName}>`
  26. if (currentParamValue.endsWith(paramClosingTag)) {
  27. // end of param value
  28. currentToolUse.params[currentParamName] = currentParamValue.slice(0, -paramClosingTag.length).trim()
  29. currentParamName = undefined
  30. continue
  31. } else {
  32. // partial param value is accumulating
  33. continue
  34. }
  35. }
  36. // no currentParamName
  37. if (currentToolUse) {
  38. const currentToolValue = accumulator.slice(currentToolUseStartIndex)
  39. const toolUseClosingTag = `</${currentToolUse.name}>`
  40. if (currentToolValue.endsWith(toolUseClosingTag)) {
  41. // end of a tool use
  42. currentToolUse.partial = false
  43. contentBlocks.push(currentToolUse)
  44. currentToolUse = undefined
  45. continue
  46. } else {
  47. const possibleParamOpeningTags = toolParamNames.map((name) => `<${name}>`)
  48. for (const paramOpeningTag of possibleParamOpeningTags) {
  49. if (accumulator.endsWith(paramOpeningTag)) {
  50. // start of a new parameter
  51. currentParamName = paramOpeningTag.slice(1, -1) as ToolParamName
  52. currentParamValueStartIndex = accumulator.length
  53. break
  54. }
  55. }
  56. // there's no current param, and not starting a new param
  57. // special case for write_to_file where file contents could contain the closing tag, in which case the param would have closed and we end up with the rest of the file contents here. To work around this, we get the string between the starting content tag and the LAST content tag.
  58. const contentParamName: ToolParamName = "content"
  59. if (currentToolUse.name === "write_to_file" && accumulator.endsWith(`</${contentParamName}>`)) {
  60. const toolContent = accumulator.slice(currentToolUseStartIndex)
  61. const contentStartTag = `<${contentParamName}>`
  62. const contentEndTag = `</${contentParamName}>`
  63. const contentStartIndex = toolContent.indexOf(contentStartTag) + contentStartTag.length
  64. const contentEndIndex = toolContent.lastIndexOf(contentEndTag)
  65. if (contentStartIndex !== -1 && contentEndIndex !== -1 && contentEndIndex > contentStartIndex) {
  66. currentToolUse.params[contentParamName] = toolContent
  67. .slice(contentStartIndex, contentEndIndex)
  68. .trim()
  69. }
  70. }
  71. // partial tool value is accumulating
  72. continue
  73. }
  74. }
  75. // no currentToolUse
  76. let didStartToolUse = false
  77. const possibleToolUseOpeningTags = toolUseNames.map((name) => `<${name}>`)
  78. for (const toolUseOpeningTag of possibleToolUseOpeningTags) {
  79. if (accumulator.endsWith(toolUseOpeningTag)) {
  80. // start of a new tool use
  81. currentToolUse = {
  82. type: "tool_use",
  83. name: toolUseOpeningTag.slice(1, -1) as ToolUseName,
  84. params: {},
  85. partial: true,
  86. }
  87. currentToolUseStartIndex = accumulator.length
  88. // this also indicates the end of the current text content
  89. if (currentTextContent) {
  90. currentTextContent.partial = false
  91. // remove the partially accumulated tool use tag from the end of text (<tool)
  92. currentTextContent.content = currentTextContent.content
  93. .slice(0, -toolUseOpeningTag.slice(0, -1).length)
  94. .trim()
  95. contentBlocks.push(currentTextContent)
  96. currentTextContent = undefined
  97. }
  98. didStartToolUse = true
  99. break
  100. }
  101. }
  102. if (!didStartToolUse) {
  103. // no tool use, so it must be text either at the beginning or between tools
  104. if (currentTextContent === undefined) {
  105. currentTextContentStartIndex = i
  106. }
  107. currentTextContent = {
  108. type: "text",
  109. content: accumulator.slice(currentTextContentStartIndex).trim(),
  110. partial: true,
  111. }
  112. }
  113. }
  114. if (currentToolUse) {
  115. // stream did not complete tool call, add it as partial
  116. if (currentParamName) {
  117. // tool call has a parameter that was not completed
  118. currentToolUse.params[currentParamName] = accumulator.slice(currentParamValueStartIndex).trim()
  119. }
  120. contentBlocks.push(currentToolUse)
  121. }
  122. // Note: it doesnt matter if check for currentToolUse or currentTextContent, only one of them will be defined since only one can be partial at a time
  123. if (currentTextContent) {
  124. // stream did not complete text content, add it as partial
  125. contentBlocks.push(currentTextContent)
  126. }
  127. return contentBlocks
  128. }