orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1// Recovering a tool call the model wrote as prose.
2//
3// llama.cpp parses the tool call syntax its template defines and hands back a
4// structured tool_calls array. When a model emits a malformed version of that
5// syntax, the parser does not match and the whole thing arrives as ordinary
6// content, which then renders to the user as markup. Ornith does this
7// occasionally, in this shape and with no closing tags:
8//
9// <tool_call> <function=web_fetch> <parameter=url> https://example.com </tool_call>
10//
11// Two things have to happen. The markup must never reach the page, and the
12// call it describes should still run, because the turn is otherwise wasted.
13package main
14
15import (
16 "encoding/json"
17 "regexp"
18 "strings"
19)
20
21var (
22 // The block a leaked call sits in. The closing tag is optional because the
23 // model often drops it, in which case everything to the end is the call.
24 callBlock = regexp.MustCompile(`(?is)<tool_call>(.*?)(?:</tool_call>|$)`)
25 // The two shapes seen in the wild: an attribute style name, and a JSON
26 // object with a name field.
27 fnName = regexp.MustCompile(`(?is)<function\s*=\s*([a-z_][a-z0-9_]*)`)
28 fnParam = regexp.MustCompile(`(?is)<parameter\s*=\s*([a-z_][a-z0-9_]*)\s*>([^<]*)`)
29 jsonName = regexp.MustCompile(`(?is)"name"\s*:\s*"([a-z_][a-z0-9_]*)"`)
30 strayOpen = regexp.MustCompile(`(?is)</?(tool_call|function|parameter)[^>]*>`)
31)
32
33// salvageCalls pulls any tool calls out of prose and returns them alongside the
34// content with the markup removed. Both halves matter: an unparsed call is a
35// wasted turn, and leaving the markup in is markup on the page.
36func salvageCalls(content string, known func(string) bool) (string, []ToolCall) {
37 if !strings.Contains(content, "<tool_call") && !strings.Contains(content, "<function=") {
38 return content, nil
39 }
40 var calls []ToolCall
41 blocks := callBlock.FindAllStringSubmatchIndex(content, -1)
42 if blocks == nil {
43 // A bare <function=...> with no wrapper around it.
44 if tc, ok := parseCall(content, known); ok {
45 calls = append(calls, tc)
46 }
47 return cleanup(content), calls
48 }
49 var kept strings.Builder
50 last := 0
51 for _, m := range blocks {
52 kept.WriteString(content[last:m[0]])
53 last = m[1]
54 if tc, ok := parseCall(content[m[2]:m[3]], known); ok {
55 calls = append(calls, tc)
56 }
57 }
58 kept.WriteString(content[last:])
59 return cleanup(kept.String()), calls
60}
61
62func parseCall(s string, known func(string) bool) (ToolCall, bool) {
63 name := ""
64 if m := fnName.FindStringSubmatch(s); m != nil {
65 name = m[1]
66 } else if m := jsonName.FindStringSubmatch(s); m != nil {
67 name = m[1]
68 }
69 if name == "" || !known(name) {
70 return ToolCall{}, false
71 }
72 args := map[string]any{}
73 for _, m := range fnParam.FindAllStringSubmatch(s, -1) {
74 args[m[1]] = strings.TrimSpace(m[2])
75 }
76 if len(args) == 0 {
77 // The JSON shape keeps its arguments in an object rather than in tags.
78 if i := strings.Index(s, "{"); i >= 0 {
79 var probe struct {
80 Arguments json.RawMessage `json:"arguments"`
81 }
82 if json.Unmarshal([]byte(s[i:]), &probe) == nil && len(probe.Arguments) > 0 {
83 _ = json.Unmarshal(probe.Arguments, &args)
84 }
85 }
86 }
87 if len(args) == 0 {
88 return ToolCall{}, false
89 }
90 raw, err := json.Marshal(args)
91 if err != nil {
92 return ToolCall{}, false
93 }
94 var tc ToolCall
95 tc.Type = "function"
96 tc.ID = "salvaged_" + name
97 tc.Function.Name = name
98 tc.Function.Arguments = string(raw)
99 return tc, true
100}
101
102// cleanup takes out any tag fragments left behind and tidies the whitespace,
103// so a partially leaked call does not show as stray angle brackets.
104func cleanup(s string) string {
105 s = strayOpen.ReplaceAllString(s, "")
106 s = regexp.MustCompile(`\n{3,}`).ReplaceAllString(s, "\n\n")
107 return strings.TrimSpace(s)
108}