e071084ebe
govulncheck / govulncheck (push) Waiting to run
Harness (E2E) / Harnesses (mock LLM) (push) Waiting to run
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Waiting to run
Lint / golangci-lint (push) Waiting to run
Run Tests / Unit Tests (push) Waiting to run
Run Tests / Etcd Integration Tests (push) Waiting to run
461 lines
12 KiB
Go
461 lines
12 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"go-micro.dev/v6/ai"
|
|
)
|
|
|
|
var fencedJSONBlock = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```")
|
|
var taggedToolCallBlock = regexp.MustCompile(`(?s)<[^<>]*(?:tool_call|tool_calls|function=)[^<>]*>(.*?)</[^<>]*>`)
|
|
var singleTaggedToolCall = regexp.MustCompile(`(?s)<(tool_call\b[^<>]*|[^<>]*function\s*=[^<>]*)>(.*?)</[^<>]*>`)
|
|
var taggedToolNameAttr = regexp.MustCompile(`(?i)(?:function|name|tool)\s*=\s*["\']?([^"\'\s>]+)`)
|
|
var openingTaggedToolCall = regexp.MustCompile(`(?i)<(tool_call\b[^<>]*|[^<>]*function\s*=[^<>]*)>`)
|
|
|
|
type textToolCall struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Tool string `json:"tool"`
|
|
Input map[string]any `json:"input"`
|
|
Arguments any `json:"arguments"`
|
|
Function *textToolCall `json:"function"`
|
|
}
|
|
|
|
// executeTextToolCalls is a compatibility fallback for providers that return a
|
|
// tool call as text JSON instead of a structured tool_calls field. It only runs
|
|
// calls whose names match the tools offered to the model, so ordinary JSON
|
|
// answers are left untouched.
|
|
func (a *agentImpl) executeTextToolCalls(ctx context.Context, reply string, tools []ai.Tool) ([]ai.ToolCall, string, bool) {
|
|
calls := parseTextToolCalls(reply, tools)
|
|
if len(calls) == 0 {
|
|
return nil, "", false
|
|
}
|
|
|
|
handler := a.toolHandler()
|
|
results := make([]string, 0, len(calls))
|
|
for i := range calls {
|
|
result := handler(ctx, calls[i])
|
|
calls[i].Result = result.Content
|
|
if result.Refused != "" {
|
|
calls[i].Error = result.Refused
|
|
}
|
|
if result.Content != "" {
|
|
results = append(results, result.Content)
|
|
}
|
|
}
|
|
return calls, strings.Join(results, "\n"), true
|
|
}
|
|
|
|
// executeAdditionalTextToolCalls runs text-encoded tool calls that accompany a
|
|
// structured tool_calls response. Some OpenAI-compatible providers can mix the
|
|
// two forms in a single assistant turn: for example, emitting a native
|
|
// conformance_echo call while rendering a follow-up guarded delegate call as
|
|
// <tool_call name="delegate">...</tool_call> text. Keep this fallback additive
|
|
// and de-duplicate calls already represented in the structured tool_calls list.
|
|
func (a *agentImpl) executeAdditionalTextToolCalls(ctx context.Context, reply string, tools []ai.Tool, existing []ai.ToolCall) ([]ai.ToolCall, string, bool) {
|
|
calls := parseTextToolCalls(reply, tools)
|
|
if len(calls) == 0 {
|
|
return nil, "", false
|
|
}
|
|
|
|
seen := map[string]bool{}
|
|
for _, call := range existing {
|
|
seen[textToolCallKey(call)] = true
|
|
}
|
|
|
|
handler := a.toolHandler()
|
|
out := make([]ai.ToolCall, 0, len(calls))
|
|
results := make([]string, 0, len(calls))
|
|
for i := range calls {
|
|
if seen[textToolCallKey(calls[i])] {
|
|
continue
|
|
}
|
|
result := handler(ctx, calls[i])
|
|
calls[i].Result = result.Content
|
|
if result.Refused != "" {
|
|
calls[i].Error = result.Refused
|
|
}
|
|
if result.Content != "" {
|
|
results = append(results, result.Content)
|
|
}
|
|
out = append(out, calls[i])
|
|
}
|
|
return out, strings.Join(results, "\n"), len(out) > 0
|
|
}
|
|
|
|
func textToolCallKey(call ai.ToolCall) string {
|
|
b, _ := json.Marshal(call.Input)
|
|
return call.Name + "\x00" + string(b)
|
|
}
|
|
|
|
func parseTextToolCalls(text string, tools []ai.Tool) []ai.ToolCall {
|
|
text = html.UnescapeString(text)
|
|
allowed := textToolNames(tools)
|
|
if len(allowed) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if calls := decodeTaggedTextToolCalls(text, allowed); len(calls) > 0 {
|
|
return calls
|
|
}
|
|
if calls := decodeFunctionTextToolCalls(text, allowed); len(calls) > 0 {
|
|
return calls
|
|
}
|
|
for _, candidate := range jsonCandidates(text) {
|
|
if calls := decodeTextToolCalls(candidate, allowed); len(calls) > 0 {
|
|
return calls
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func partialTextToolCallName(text string, tools []ai.Tool) string {
|
|
text = html.UnescapeString(text)
|
|
allowed := textToolNames(tools)
|
|
if len(allowed) == 0 {
|
|
return ""
|
|
}
|
|
openMatches := openingTaggedToolCall.FindAllStringSubmatchIndex(text, -1)
|
|
if len(openMatches) == 0 {
|
|
return ""
|
|
}
|
|
closedMatches := singleTaggedToolCall.FindAllStringSubmatchIndex(text, -1)
|
|
for _, open := range openMatches {
|
|
closed := false
|
|
for _, match := range closedMatches {
|
|
if match[0] == open[0] {
|
|
closed = true
|
|
break
|
|
}
|
|
}
|
|
if closed {
|
|
continue
|
|
}
|
|
tag := text[open[2]:open[3]]
|
|
name := taggedToolName(tag)
|
|
if canonical := allowed[name]; canonical != "" {
|
|
return canonical
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func textToolNames(tools []ai.Tool) map[string]string {
|
|
allowed := map[string]string{}
|
|
for _, tool := range tools {
|
|
addTextToolName(allowed, tool.Name, tool.Name)
|
|
if tool.OriginalName != "" {
|
|
addTextToolName(allowed, tool.OriginalName, tool.Name)
|
|
}
|
|
}
|
|
return allowed
|
|
}
|
|
|
|
func addTextToolName(allowed map[string]string, name, canonical string) {
|
|
if name == "" || canonical == "" {
|
|
return
|
|
}
|
|
allowed[name] = canonical
|
|
// Some OpenAI-compatible models describe an idempotent Add endpoint as a
|
|
// creation action and emit the otherwise-correct service tool with a Create
|
|
// suffix in text-only tool-call markup. Keep the fallback bounded by the
|
|
// offered service tool prefix so ordinary unknown tools remain ignored.
|
|
for _, suffix := range []string{"_Add", ".Add"} {
|
|
if strings.HasSuffix(name, suffix) {
|
|
allowed[strings.TrimSuffix(name, suffix)+strings.Replace(suffix, "Add", "Create", 1)] = canonical
|
|
}
|
|
}
|
|
}
|
|
|
|
func jsonCandidates(text string) []string {
|
|
trimmed := strings.TrimSpace(text)
|
|
var out []string
|
|
if trimmed != "" {
|
|
out = append(out, trimmed)
|
|
}
|
|
for _, match := range fencedJSONBlock.FindAllStringSubmatch(text, -1) {
|
|
if len(match) > 1 {
|
|
out = append(out, strings.TrimSpace(match[1]))
|
|
}
|
|
}
|
|
for _, match := range taggedToolCallBlock.FindAllStringSubmatch(text, -1) {
|
|
if len(match) > 1 {
|
|
out = append(out, strings.TrimSpace(match[1]))
|
|
}
|
|
}
|
|
if start, end := strings.IndexAny(text, "[{"), strings.LastIndexAny(text, "]}"); start >= 0 && end > start {
|
|
out = append(out, strings.TrimSpace(text[start:end+1]))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func decodeTextToolCalls(candidate string, allowed map[string]string) []ai.ToolCall {
|
|
var root any
|
|
if err := json.Unmarshal([]byte(candidate), &root); err != nil {
|
|
return nil
|
|
}
|
|
return collectTextToolCalls(root, allowed)
|
|
}
|
|
|
|
func collectTextToolCalls(v any, allowed map[string]string) []ai.ToolCall {
|
|
switch x := v.(type) {
|
|
case []any:
|
|
var out []ai.ToolCall
|
|
for _, item := range x {
|
|
out = append(out, collectTextToolCalls(item, allowed)...)
|
|
}
|
|
return out
|
|
case map[string]any:
|
|
if nested, ok := firstNestedToolCalls(x); ok {
|
|
return collectTextToolCalls(nested, allowed)
|
|
}
|
|
call := mapToTextToolCall(x)
|
|
name, input := textToolCallNameAndInput(call)
|
|
if name == "" || allowed[name] == "" || input == nil {
|
|
return nil
|
|
}
|
|
id := call.ID
|
|
if id == "" {
|
|
id = fmt.Sprintf("text-call-%s", strings.ReplaceAll(name, ".", "_"))
|
|
}
|
|
return []ai.ToolCall{{ID: id, Name: allowed[name], Input: input}}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func textToolCallNameAndInput(call textToolCall) (string, map[string]any) {
|
|
name := call.Name
|
|
if name == "" {
|
|
name = call.Tool
|
|
}
|
|
input := call.Input
|
|
if input == nil {
|
|
input = textToolArguments(call.Arguments)
|
|
}
|
|
if call.Function != nil {
|
|
fnName, fnInput := textToolCallNameAndInput(*call.Function)
|
|
if name == "" {
|
|
name = fnName
|
|
}
|
|
if input == nil {
|
|
input = fnInput
|
|
}
|
|
}
|
|
return name, input
|
|
}
|
|
|
|
func textToolArguments(raw any) map[string]any {
|
|
switch args := raw.(type) {
|
|
case map[string]any:
|
|
return args
|
|
case string:
|
|
var input map[string]any
|
|
if err := json.Unmarshal([]byte(args), &input); err == nil {
|
|
return input
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func containsNestedTextToolCall(v any) bool {
|
|
switch x := v.(type) {
|
|
case string:
|
|
text := html.UnescapeString(x)
|
|
return openingTaggedToolCall.MatchString(text) || singleTaggedToolCall.MatchString(text)
|
|
case map[string]any:
|
|
for _, item := range x {
|
|
if containsNestedTextToolCall(item) {
|
|
return true
|
|
}
|
|
}
|
|
case []any:
|
|
for _, item := range x {
|
|
if containsNestedTextToolCall(item) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func decodeTaggedTextToolCalls(text string, allowed map[string]string) []ai.ToolCall {
|
|
var out []ai.ToolCall
|
|
for _, match := range singleTaggedToolCall.FindAllStringSubmatch(text, -1) {
|
|
if len(match) < 3 {
|
|
continue
|
|
}
|
|
tag, body := match[1], strings.TrimSpace(match[2])
|
|
if calls := decodeTextToolCalls(body, allowed); len(calls) > 0 {
|
|
out = append(out, calls...)
|
|
continue
|
|
}
|
|
if calls := decodeTaggedTextToolCalls(body, allowed); len(calls) > 0 {
|
|
out = append(out, calls...)
|
|
continue
|
|
}
|
|
name := taggedToolName(tag)
|
|
if name == "" || allowed[name] == "" {
|
|
continue
|
|
}
|
|
var input map[string]any
|
|
if err := json.Unmarshal([]byte(body), &input); err != nil || input == nil {
|
|
continue
|
|
}
|
|
out = append(out, ai.ToolCall{
|
|
ID: fmt.Sprintf("text-call-%s", strings.ReplaceAll(name, ".", "_")),
|
|
Name: allowed[name],
|
|
Input: input,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func taggedToolName(tag string) string {
|
|
match := taggedToolNameAttr.FindStringSubmatch(tag)
|
|
if len(match) < 2 {
|
|
return ""
|
|
}
|
|
return strings.Trim(match[1], `"'`)
|
|
}
|
|
|
|
func decodeFunctionTextToolCalls(text string, allowed map[string]string) []ai.ToolCall {
|
|
var out []ai.ToolCall
|
|
for alias, canonical := range allowed {
|
|
for _, body := range functionCallBodies(text, alias) {
|
|
var input map[string]any
|
|
if err := json.Unmarshal([]byte(body), &input); err != nil || input == nil {
|
|
continue
|
|
}
|
|
out = append(out, ai.ToolCall{
|
|
ID: fmt.Sprintf("text-call-%s", strings.ReplaceAll(alias, ".", "_")),
|
|
Name: canonical,
|
|
Input: input,
|
|
})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func functionCallBodies(text, name string) []string {
|
|
if name == "" {
|
|
return nil
|
|
}
|
|
var bodies []string
|
|
for searchFrom := 0; searchFrom < len(text); {
|
|
idx := strings.Index(text[searchFrom:], name)
|
|
if idx < 0 {
|
|
break
|
|
}
|
|
start := searchFrom + idx
|
|
open := start + len(name)
|
|
if !isFunctionCallBoundary(text, start, open) {
|
|
searchFrom = start + len(name)
|
|
continue
|
|
}
|
|
bodyStart := open + 1
|
|
bodyEnd, ok := balancedJSONObjectEnd(text, bodyStart)
|
|
if !ok {
|
|
searchFrom = bodyStart
|
|
continue
|
|
}
|
|
bodies = append(bodies, strings.TrimSpace(text[bodyStart:bodyEnd]))
|
|
searchFrom = bodyEnd + 1
|
|
}
|
|
return bodies
|
|
}
|
|
|
|
func isFunctionCallBoundary(text string, start, open int) bool {
|
|
if open >= len(text) || text[open] != '(' {
|
|
return false
|
|
}
|
|
if start > 0 {
|
|
prev := text[start-1]
|
|
if prev == '_' || prev == '.' || prev == '-' || prev == '$' || ('0' <= prev && prev <= '9') || ('A' <= prev && prev <= 'Z') || ('a' <= prev && prev <= 'z') {
|
|
return false
|
|
}
|
|
}
|
|
for i := open + 1; i < len(text); i++ {
|
|
switch text[i] {
|
|
case ' ', '\n', '\r', '\t':
|
|
continue
|
|
case '{':
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func balancedJSONObjectEnd(text string, start int) (int, bool) {
|
|
for start < len(text) {
|
|
switch text[start] {
|
|
case ' ', '\n', '\r', '\t':
|
|
start++
|
|
case '{':
|
|
depth := 0
|
|
inString := false
|
|
escaped := false
|
|
for i := start; i < len(text); i++ {
|
|
c := text[i]
|
|
if inString {
|
|
if escaped {
|
|
escaped = false
|
|
} else if c == '\\' {
|
|
escaped = true
|
|
} else if c == '"' {
|
|
inString = false
|
|
}
|
|
continue
|
|
}
|
|
switch c {
|
|
case '"':
|
|
inString = true
|
|
case '{':
|
|
depth++
|
|
case '}':
|
|
depth--
|
|
if depth == 0 {
|
|
for j := i + 1; j < len(text); j++ {
|
|
switch text[j] {
|
|
case ' ', '\n', '\r', '\t':
|
|
continue
|
|
case ')':
|
|
return i + 1, true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return 0, false
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func firstNestedToolCalls(m map[string]any) (any, bool) {
|
|
for _, key := range []string{"tool_calls", "toolCalls", "calls"} {
|
|
if v, ok := m[key]; ok {
|
|
return v, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func mapToTextToolCall(m map[string]any) textToolCall {
|
|
b, _ := json.Marshal(m)
|
|
var call textToolCall
|
|
_ = json.Unmarshal(b, &call)
|
|
return call
|
|
}
|