| |
| |
| |
| |
| |
| |
| |
| |
| |
| package toolcall |
|
|
| import ( |
| "encoding/json" |
| "regexp" |
| "strings" |
|
|
| "aurora/typings/official" |
| ) |
|
|
| const ( |
| StartTag = "<tool_call>" |
| EndTag = "</tool_call>" |
| ) |
|
|
| |
| type Parser struct { |
| buffer string |
| inside bool |
| emittedCount int |
| emittedText bool |
| } |
|
|
| |
| func NewParser() *Parser { |
| return &Parser{} |
| } |
|
|
| |
| |
| |
| func (p *Parser) Feed(chunk string) (textDelta string, toolCalls []official.ToolCall) { |
| p.buffer = normalize(p.buffer + chunk) |
| var text strings.Builder |
| for len(p.buffer) > 0 { |
| if !p.inside { |
| startIdx := strings.Index(p.buffer, StartTag) |
| if startIdx >= 0 { |
| pre := p.buffer[:startIdx] |
| if pre != "" { |
| |
| |
| if !(p.emittedCount == 0 && p.emittedText == false && looksLikeJSONJunk(pre)) { |
| text.WriteString(pre) |
| if strings.TrimSpace(pre) != "" { |
| p.emittedText = true |
| } |
| } |
| } |
| p.inside = true |
| p.buffer = p.buffer[startIdx+len(StartTag):] |
| continue |
| } |
| |
| |
| flushIndex := len(p.buffer) |
| for i := 1; i < len(StartTag); i++ { |
| if strings.HasSuffix(p.buffer, StartTag[:i]) { |
| flushIndex = len(p.buffer) - i |
| break |
| } |
| } |
| pre := p.buffer[:flushIndex] |
| if pre != "" { |
| head := strings.TrimSpace(pre) |
| |
| |
| if p.emittedCount == 0 && !p.emittedText && (strings.HasPrefix(head, "{") || strings.HasPrefix(head, "`")) { |
| break |
| } |
| text.WriteString(pre) |
| if strings.TrimSpace(pre) != "" { |
| p.emittedText = true |
| } |
| } |
| p.buffer = p.buffer[flushIndex:] |
| break |
| } |
| |
| endIdx := strings.Index(p.buffer, EndTag) |
| if endIdx < 0 { |
| break |
| } |
| raw := strings.TrimSpace(p.buffer[:endIdx]) |
| if tc := buildToolCallFromRaw(raw); tc != nil { |
| toolCalls = append(toolCalls, *tc) |
| p.emittedCount++ |
| } |
| p.inside = false |
| p.buffer = p.buffer[endIdx+len(EndTag):] |
| } |
| return text.String(), toolCalls |
| } |
|
|
| |
| |
| func (p *Parser) Flush() (textDelta string, toolCalls []official.ToolCall) { |
| remaining := p.buffer |
| p.buffer = "" |
| if remaining == "" { |
| return "", nil |
| } |
| if p.inside { |
| |
| if tc := buildToolCallFromRaw(remaining); tc != nil { |
| toolCalls = append(toolCalls, *tc) |
| p.emittedCount++ |
| return "", toolCalls |
| } |
| |
| if p.emittedCount == 0 { |
| return StartTag + remaining, nil |
| } |
| return "", nil |
| } |
| |
| if p.emittedCount == 0 { |
| if tc := buildToolCallFromRaw(remaining); tc != nil { |
| toolCalls = append(toolCalls, *tc) |
| p.emittedCount++ |
| return "", toolCalls |
| } |
| if !p.emittedText { |
| return remaining, nil |
| } |
| } |
| return "", nil |
| } |
|
|
| |
| |
| func normalize(s string) string { |
| s = toolCallsOpenRe.ReplaceAllString(s, StartTag) |
| s = toolCallsCloseRe.ReplaceAllString(s, EndTag) |
| s = toolCallAltOpenRe.ReplaceAllString(s, StartTag) |
| s = toolCallAltCloseRe.ReplaceAllString(s, EndTag) |
| return s |
| } |
|
|
| var ( |
| toolCallsOpenRe = regexp.MustCompile(`(?i)<tool_calls>`) |
| toolCallsCloseRe = regexp.MustCompile(`(?i)</tool_calls>`) |
| toolCallAltOpenRe = regexp.MustCompile(`(?i)<tool[_\s]call>`) |
| toolCallAltCloseRe = regexp.MustCompile(`(?i)</tool[_\s]call>`) |
| ) |
|
|
| func looksLikeJSONJunk(s string) bool { |
| t := strings.TrimSpace(s) |
| if t == "" { |
| return false |
| } |
| return strings.HasPrefix(t, "`") || strings.HasPrefix(t, "{") || strings.HasPrefix(t, "[") |
| } |
|
|
| |
| |
| func buildToolCallFromRaw(raw string) *official.ToolCall { |
| s := strings.TrimSpace(raw) |
| if s == "" { |
| return nil |
| } |
| |
| s = stripMarkdownFence(s) |
| idx := strings.Index(s, "{") |
| if idx < 0 { |
| return nil |
| } |
| s = s[idx:] |
| obj, ok := robustJSON(s) |
| if !ok { |
| return nil |
| } |
| return buildToolCallFromObject(obj) |
| } |
|
|
| func stripMarkdownFence(s string) string { |
| s = fenceOpenRe.ReplaceAllString(s, "") |
| s = fenceCloseRe.ReplaceAllString(strings.TrimSpace(s), "") |
| return strings.TrimSpace(s) |
| } |
|
|
| var ( |
| fenceOpenRe = regexp.MustCompile("^```[a-zA-Z]*\\s*") |
| fenceCloseRe = regexp.MustCompile("```$") |
| ) |
|
|
| |
| |
| |
| func fixLoneBackslashes(s string) string { |
| var out strings.Builder |
| out.Grow(len(s) + 8) |
| for i := 0; i < len(s); i++ { |
| c := s[i] |
| if c != '\\' { |
| out.WriteByte(c) |
| continue |
| } |
| nxt := byte(0) |
| if i+1 < len(s) { |
| nxt = s[i+1] |
| } |
| if nxt != 0 && strings.IndexByte(`"\\/bfnrtu`, nxt) >= 0 { |
| out.WriteByte('\\') |
| out.WriteByte(nxt) |
| i++ |
| continue |
| } |
| out.WriteByte('\\') |
| out.WriteByte('\\') |
| } |
| return out.String() |
| } |
|
|
| |
| |
| func robustJSON(s string) (map[string]any, bool) { |
| if s == "" { |
| return nil, false |
| } |
| repaired := fixLoneBackslashes(s) |
| if v, err := parseObject(repaired); err == nil { |
| return v, true |
| } |
| |
| if end := firstBalancedObject(repaired); end > 0 { |
| if v, err := parseObject(repaired[:end+1]); err == nil { |
| return v, true |
| } |
| } |
| return nil, false |
| } |
|
|
| func parseObject(s string) (map[string]any, error) { |
| var v map[string]any |
| if err := json.Unmarshal([]byte(s), &v); err != nil { |
| return nil, err |
| } |
| return v, nil |
| } |
|
|
| |
| |
| func firstBalancedObject(s string) int { |
| depth := 0 |
| inStr := false |
| esc := false |
| for i := 0; i < len(s); i++ { |
| c := s[i] |
| if esc { |
| esc = false |
| continue |
| } |
| if c == '\\' { |
| esc = true |
| continue |
| } |
| if c == '"' { |
| inStr = !inStr |
| continue |
| } |
| if inStr { |
| continue |
| } |
| switch c { |
| case '{': |
| depth++ |
| case '}': |
| depth-- |
| if depth == 0 { |
| return i |
| } |
| } |
| } |
| return -1 |
| } |
|
|
| |
| |
| |
| func buildToolCallFromObject(obj map[string]any) *official.ToolCall { |
| if obj == nil { |
| return nil |
| } |
| name := pickString(obj, "name", "tool", "tool_name", "function") |
| if name == "" { |
| return nil |
| } |
| args := extractArguments(obj) |
| return &official.ToolCall{ |
| Index: 0, |
| ID: generateCallID(), |
| Type: "function", |
| Function: official.ToolCallFunc{ |
| Name: name, |
| Arguments: marshalArguments(args), |
| }, |
| } |
| } |
|
|
| func pickString(obj map[string]any, keys ...string) string { |
| for _, k := range keys { |
| if v, ok := obj[k]; ok { |
| if s, ok := v.(string); ok { |
| return s |
| } |
| } |
| } |
| return "" |
| } |
|
|
| func extractArguments(obj map[string]any) any { |
| for _, k := range []string{"arguments", "parameters", "args"} { |
| if v, ok := obj[k]; ok { |
| return v |
| } |
| } |
| |
| remaining := make(map[string]any, len(obj)) |
| for k, v := range obj { |
| if k == "name" || k == "tool" || k == "tool_name" || k == "function" { |
| continue |
| } |
| remaining[k] = v |
| } |
| return remaining |
| } |
|
|
| func marshalArguments(v any) string { |
| switch t := v.(type) { |
| case nil: |
| return "{}" |
| case string: |
| s := strings.TrimSpace(t) |
| |
| if strings.HasPrefix(s, "{") { |
| if _, ok := robustJSON(s); ok { |
| return s |
| } |
| } |
| |
| b, _ := json.Marshal(map[string]string{"command": s}) |
| return string(b) |
| case map[string]any: |
| b, _ := json.Marshal(t) |
| return string(b) |
| default: |
| b, _ := json.Marshal(t) |
| return string(b) |
| } |
| } |
|
|
| |
| |
| func generateCallID() string { |
| return "call_" + newCallIDSuffix() |
| } |
|
|