repos
/ orchard main

orchard

mirror

Every 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

3.6 KB · 109 lines · Go Raw History
  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}