package openai import ( "encoding/json" "fmt" "sort" "strings" ) var ( textToolCallOpeningHint = "= len(content) || content[callOpen] != '(' { start = blockStart + len(textMustacheToolCallOpenHint) continue } foundAny = true argsStart := callOpen + 1 argsEnd, blockEnd, ok := findMustacheToolCallEnd(content, argsStart) if !ok { out = append(out, candidateMatch{ start: blockStart, end: len(content), name: name, validationErr: fmt.Errorf("malformed tool call: unclosed mustache tool call %q", name), }) break } if _, ok := names[name]; !ok { out = append(out, candidateMatch{ start: blockStart, end: blockEnd, name: name, validationErr: fmt.Errorf("tool %q is not in request tools", name), }) start = blockEnd continue } spec := specs[name] args, ok := parseMustacheToolCallArguments(content[argsStart:argsEnd]) if !ok { out = append(out, candidateMatch{ start: blockStart, end: blockEnd, name: name, validationErr: fmt.Errorf("failed to parse arguments for tool %q", name), }) start = blockEnd continue } args = normalizeTextToolCallArguments(name, args, spec) encoded, err := json.Marshal(args) if err != nil { out = append(out, candidateMatch{ start: blockStart, end: blockEnd, name: name, validationErr: fmt.Errorf("failed to marshal arguments for tool %q: %w", name, err), }) start = blockEnd continue } out = append(out, candidateMatch{ start: blockStart, end: blockEnd, name: name, arguments: string(encoded), }) start = blockEnd } return out, foundAny } func synthesizeToolCallsFromTextResult(content string, tools []any, runID string) textToolSynthesisResult { specs := requestedToolSpecs(tools) names := requestedToolNames(tools) xmlMatches, xmlFound := collectXMLCandidateMatches(content, specs, names) mustacheMatches, mustacheFound := collectMustacheCandidateMatches(content, specs, names) candidateFound := xmlFound || mustacheFound var matches []candidateMatch matches = append(matches, xmlMatches...) matches = append(matches, mustacheMatches...) sort.SliceStable(matches, func(i, j int) bool { if matches[i].start == matches[j].start { return matches[i].end > matches[j].end } return matches[i].start < matches[j].start }) var cleaned strings.Builder cursor := 0 toolCalls := make([]any, 0) var firstErr error for _, match := range matches { if match.start < cursor { continue } if match.validationErr != nil { if firstErr == nil { firstErr = match.validationErr } continue } cleaned.WriteString(content[cursor:match.start]) cursor = match.end toolCalls = append(toolCalls, map[string]any{ "id": textToolCallID(runID, len(toolCalls)+1), "type": "function", "function": map[string]any{ "name": match.name, "arguments": match.arguments, }, }) } if firstErr != nil { return textToolSynthesisResult{ cleaned: content, toolCalls: nil, candidateFound: candidateFound, validationErr: firstErr, } } if len(toolCalls) == 0 { return textToolSynthesisResult{ cleaned: content, toolCalls: nil, candidateFound: candidateFound, validationErr: nil, } } cleaned.WriteString(content[cursor:]) return textToolSynthesisResult{ cleaned: strings.TrimSpace(cleaned.String()), toolCalls: toolCalls, candidateFound: candidateFound, validationErr: nil, } } func synthesizeToolCallsFromText(content string, tools []any, runID string) (string, []any) { res := synthesizeToolCallsFromTextResult(content, tools, runID) if res.validationErr != nil { return content, nil } return res.cleaned, res.toolCalls } func textToolCallID(runID string, ordinal int) string { normalized := normalizeToolCallIDPart(runID) if normalized == "" { return fmt.Sprintf("call_iop_%d", ordinal) } return fmt.Sprintf("call_iop_%s_%d", normalized, ordinal) } func normalizeToolCallIDPart(s string) string { var b strings.Builder for _, r := range s { switch { case r >= 'a' && r <= 'z': b.WriteRune(r) case r >= 'A' && r <= 'Z': b.WriteRune(r) case r >= '0' && r <= '9': b.WriteRune(r) case r == '_' || r == '-': b.WriteRune(r) default: b.WriteByte('_') } } return strings.Trim(b.String(), "_-") } func requestedToolNames(tools []any) map[string]struct{} { names := make(map[string]struct{}, len(tools)) for _, tool := range tools { m, ok := tool.(map[string]any) if !ok { continue } if name, ok := m["name"].(string); ok && strings.TrimSpace(name) != "" { names[strings.TrimSpace(name)] = struct{}{} } fn, ok := m["function"].(map[string]any) if !ok { continue } if name, ok := fn["name"].(string); ok && strings.TrimSpace(name) != "" { names[strings.TrimSpace(name)] = struct{}{} } } return names } func requestedToolSpecs(tools []any) map[string]textToolSpec { specs := make(map[string]textToolSpec, len(tools)) for _, tool := range tools { m, ok := tool.(map[string]any) if !ok { continue } if name, ok := m["name"].(string); ok && strings.TrimSpace(name) != "" { name = strings.TrimSpace(name) specs[name] = textToolSpec{name: name, parameters: mapValue(m["parameters"])} } fn, ok := m["function"].(map[string]any) if !ok { continue } if name, ok := fn["name"].(string); ok && strings.TrimSpace(name) != "" { name = strings.TrimSpace(name) specs[name] = textToolSpec{name: name, parameters: mapValue(fn["parameters"])} } } return specs } func mapValue(value any) map[string]any { m, _ := value.(map[string]any) return m } func parseTextToolParameterValue(raw string) any { trimmed := strings.TrimSpace(raw) if trimmed == "" { return "" } var decoded any if err := json.Unmarshal([]byte(trimmed), &decoded); err == nil { return decoded } return trimmed }