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// Package tools is what the model can do besides talk. Every backend here is
2// keyless and every one is reachable from a scratch image over HTTPS, which is
3// the whole selection criterion: a tool that needs an account is a tool that
4// stops working when a key expires and nobody notices.
5//
6// A tool answers with a Go value that becomes JSON. It never returns prose,
7// because the model writes the prose and the tool supplies the facts.
8package tools
9
10import (
11 "context"
12 "encoding/json"
13 "fmt"
14 "io"
15 "net/http"
16 "sort"
17 "strings"
18 "sync"
19 "time"
20)
21
22// UserAgent is a browser string because several of these endpoints answer 403
23// to anything that looks automated. It is not a disguise, it is the price of
24// entry for reading a public page.
25// Chrome on Windows because it is the commonest thing an edge sees, and current
26// because a two year old version on X11 Linux is a combination almost nothing
27// real sends. Chrome itself zeroes the last two version fields.
28const UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"
29
30// Chrome sends these on every request, so a Chrome user agent arriving without
31// them contradicts itself, which is cheaper to score than the agent string is.
32const (
33 clientHintUA = `"Google Chrome";v="149", "Chromium";v="149", "Not?A_Brand";v="24"`
34 clientHintMobile = "?0"
35 clientHintPlatform = `"Windows"`
36)
37
38// Result is what a tool hands back. Content is JSON encoded for the model.
39type Result struct {
40 Name string `json:"name"`
41 Args json.RawMessage `json:"args,omitempty"`
42 Content any `json:"content"`
43 Err string `json:"error,omitempty"`
44 Elapsed time.Duration `json:"-"`
45}
46
47// Tool is one capability. Schema is the JSON Schema the model is handed, and
48// Run is given the arguments it chose.
49type Tool struct {
50 Name string
51 Description string
52 Schema map[string]any
53 Run func(ctx context.Context, d *Deps, args map[string]any) (any, error)
54}
55
56// Deps is what every tool shares. Passing a clock rather than calling
57// time.Now means a test can sit on a fixed day, which matters for anything
58// that decides what "this weekend" is.
59type Deps struct {
60 // HTTP reaches anything, and is what the orchard tools use to read this
61 // machine's own services by container name.
62 HTTP *http.Client
63 // Public refuses to connect to this machine or its network, and is what
64 // every tool that takes a url from the model goes through.
65 Public *http.Client
66 Now func() time.Time
67 Guard *Guard
68 Budgets *Budgets
69
70 // The session of whoever is chatting, forwarded by the orchard tools so
71 // each site does its own check. It is per turn rather than per process,
72 // so it is set on a copy and never on the shared Deps.
73 Session string
74
75 // Where a tool hangs a chart or a forecast for the page to draw. Per turn
76 // and on the copy, for the same reason the session is.
77 Widgets *Sink
78
79 // Memory is the site's own fact store, so the remember tool can write to
80 // the database this process owns rather than to a service over http.
81 Memory Memory
82 // History is the site's own conversation store, for the same reason.
83 History History
84 // Whether this turn is incognito, which a tool that reaches a service with
85 // a model behind it has to pass along. Per turn and on the copy too.
86 Incognito bool
87}
88
89// WithSession returns a copy carrying one turn's session and its own widget
90// sink. A copy because Deps is shared across every turn, and writing either of
91// those onto it would hand one person's session, and one turn's charts, to the
92// next request.
93func (d *Deps) WithSession(session string) *Deps {
94 c := *d
95 c.Session = session
96 c.Widgets = NewSink()
97 return &c
98}
99
100func NewDeps() *Deps {
101 return &Deps{
102 HTTP: &http.Client{Timeout: 25 * time.Second},
103 Public: publicClient(25 * time.Second),
104 Now: time.Now,
105 // One host that starts refusing keeps refusing for a while, and asking
106 // it again in the meantime is what keeps a rate limit alive rather than
107 // letting it expire. See the DuckDuckGo and ESPN bans this was written
108 // after.
109 Guard: NewGuard(10 * time.Minute),
110 // What we allow ourselves, as opposed to what a host has told us. A gap
111 // bounds the rate and this bounds the total, and only the second one
112 // stops a long turn spending a day's searches on one question.
113 Budgets: NewBudgets(),
114 }
115}
116
117// Guard is a per host circuit breaker. Anything that answers with a refusal
118// puts its host in the penalty box, and every call to that host fails locally
119// until the box empties.
120// Store is however the caller persists the penalty box. It is an interface so
121// this package stays a leaf and does not import the database.
122type PenaltyStore interface {
123 SavePenalty(host string, till time.Time, trips int)
124 ClearPenalty(host string)
125}
126
127type Guard struct {
128 mu sync.Mutex
129 till map[string]time.Time
130 last map[string]time.Time
131 // How many times in a row a host has refused. Asking again the moment a
132 // ten minute box empties is what keeps a rate limit alive, so each repeat
133 // doubles the wait instead of poking the same endpoint six times an hour.
134 trips map[string]int
135 cool time.Duration
136 store PenaltyStore
137}
138
139func NewGuard(cool time.Duration) *Guard {
140 return &Guard{
141 till: map[string]time.Time{}, last: map[string]time.Time{},
142 trips: map[string]int{}, cool: cool,
143 }
144}
145
146// Wait blocks until this host may be called again. It holds the lock across the
147// sleep on purpose, so two turns asking the same host queue rather than both
148// deciding the gap has passed.
149func (g *Guard) Wait(host string) {
150 g.mu.Lock()
151 defer g.mu.Unlock()
152 gap := budgetFor(host).gap
153 if t, seen := g.last[host]; seen {
154 if d := gap - time.Since(t); d > 0 {
155 time.Sleep(d)
156 }
157 }
158 g.last[host] = time.Now()
159}
160
161func (g *Guard) Blocked(host string) (bool, time.Duration) {
162 g.mu.Lock()
163 defer g.mu.Unlock()
164 t, ok := g.till[host]
165 if !ok || time.Now().After(t) {
166 return false, 0
167 }
168 return true, time.Until(t)
169}
170
171// How long a host that refused is left completely alone.
172//
173// Long and flat rather than short and escalating. A ten minute box means asking
174// again six times an hour, and every one of those is a request to an endpoint
175// that has already said no, which is how a soft limit turns into a hard one.
176// There is no signal that a ban has lifted other than a call, so the only safe
177// policy is to wait out a period long enough that the question is settled and
178// let a person's next real question be the one that finds out.
179const refusalCool = 6 * time.Hour
180
181// A page that timed out or redirected somewhere that failed is not a host
182// refusing us, and boxing it for six hours reads as a ban that never happened.
183// On 2026-09-08 that put developer.android.com, mirrors.wikimedia.org and
184// hacker-news.firebaseio.com out of reach for the afternoon over one slow
185// request each. Long enough to stop a turn hammering the same dead address,
186// short enough that the next question can try again.
187const stumbleCool = 5 * time.Minute
188
189// Trip is for a host that said no in as many words, a 202 or a 429. That is the
190// case the long box was written for.
191func (g *Guard) Trip(host string) { g.box(host, refusalCool) }
192
193// Stumble is for a request that failed without the host refusing anything: a
194// timeout, a dead name, a redirect to somewhere that would not answer.
195func (g *Guard) Stumble(host string) { g.box(host, stumbleCool) }
196
197func (g *Guard) box(host string, cool time.Duration) {
198 g.mu.Lock()
199 g.trips[host]++
200 till := time.Now().Add(cool)
201 // A stumble never shortens a refusal already in force.
202 if old, ok := g.till[host]; ok && old.After(till) {
203 till = old
204 }
205 g.till[host] = till
206 trips, store := g.trips[host], g.store
207 g.mu.Unlock()
208 if store != nil {
209 store.SavePenalty(host, till, trips)
210 }
211}
212
213// Restore puts back the boxes that outlived the last process and takes the
214// store to write future ones to.
215func (g *Guard) Restore(store PenaltyStore, saved map[string][2]int64) {
216 g.mu.Lock()
217 defer g.mu.Unlock()
218 g.store = store
219 for host, v := range saved {
220 g.till[host] = time.UnixMilli(v[0])
221 g.trips[host] = int(v[1])
222 }
223}
224
225// Cleared on a call that worked, so one bad afternoon does not leave a host on
226// a four hour backoff for the rest of the process.
227func (g *Guard) OK(host string) {
228 g.mu.Lock()
229 _, had := g.trips[host]
230 delete(g.trips, host)
231 store := g.store
232 g.mu.Unlock()
233 if had && store != nil {
234 store.ClearPenalty(host)
235 }
236}
237
238// Down is every host currently in the penalty box, so the page can say search
239// is unavailable rather than letting each turn discover it again.
240func (g *Guard) Down() map[string]time.Duration {
241 g.mu.Lock()
242 defer g.mu.Unlock()
243 out := map[string]time.Duration{}
244 now := time.Now()
245 for host, till := range g.till {
246 if till.After(now) {
247 out[host] = time.Until(till).Round(time.Second)
248 }
249 }
250 return out
251}
252
253// SearchHost is the one whose loss the page reports, since a turn without it
254// cannot look anything up and every other tool is narrower.
255const SearchHost = "html.duckduckgo.com"
256
257// get fetches a URL through the breaker and returns the body.
258func get(ctx context.Context, d *Deps, url string, accept string) ([]byte, error) {
259 return getWith(ctx, d, url, accept, nil)
260}
261
262// getWith is get plus per host headers, which exists because pollen.com refuses
263// a request that arrives without a Referer naming the page its own front end
264// would have been on.
265func getWith(ctx context.Context, d *Deps, url string, accept string, extra map[string]string) ([]byte, error) {
266 host := hostOf(url)
267 if blocked, left := d.Guard.Blocked(host); blocked {
268 return nil, fmt.Errorf("%s refused us and is being left alone for another %s, "+
269 "so nothing can be looked up there until then", host, round(left))
270 }
271 // The spend ceiling, checked before the gap so a spent pool costs no wait.
272 if err := d.Budgets.Take(host); err != nil {
273 return nil, err
274 }
275 d.Guard.Wait(host)
276 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
277 if err != nil {
278 return nil, err
279 }
280 req.Header.Set("User-Agent", UserAgent)
281 req.Header.Set("Accept-Language", "en-US,en;q=0.9")
282 req.Header.Set("Sec-Ch-Ua", clientHintUA)
283 req.Header.Set("Sec-Ch-Ua-Mobile", clientHintMobile)
284 req.Header.Set("Sec-Ch-Ua-Platform", clientHintPlatform)
285 if accept != "" {
286 req.Header.Set("Accept", accept)
287 }
288 for k, v := range extra {
289 req.Header.Set(k, v)
290 }
291 client := d.Public
292 if client == nil {
293 client = d.HTTP
294 }
295 resp, err := client.Do(req)
296 if err != nil {
297 // A refusal by the fence is not the host rate limiting us, so it must
298 // not trip the breaker and lock out a host that never answered.
299 if strings.Contains(err.Error(), "refusing") {
300 return nil, fmt.Errorf("%s is not a public address", host)
301 }
302 d.Guard.Stumble(host)
303 return nil, fmt.Errorf("%s did not answer, so try a different source: %s", host, transportReason(err))
304 }
305 defer resp.Body.Close()
306 body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
307 if err != nil {
308 return nil, err
309 }
310 // 202 is how both DuckDuckGo and ESPN say no. It reads as an empty result
311 // rather than a refusal, which is worse than an error, so it is turned into
312 // one here. This and a 429 are the only two the long box is for.
313 if resp.StatusCode == http.StatusAccepted || resp.StatusCode == http.StatusTooManyRequests {
314 d.Guard.Trip(host)
315 return nil, fmt.Errorf("%s is rate limiting us (status %d)", host, resp.StatusCode)
316 }
317 // An empty 200 from a page is a page with nothing on it, not a ban.
318 if resp.StatusCode == 200 && len(strings.TrimSpace(string(body))) == 0 {
319 d.Guard.Stumble(host)
320 return nil, fmt.Errorf("%s returned an empty page, so try a different source", host)
321 }
322 if resp.StatusCode >= 400 {
323 return nil, fmt.Errorf("%s answered %d. %s", host, resp.StatusCode, statusAdvice(resp.StatusCode))
324 }
325 d.Guard.OK(host)
326 return body, nil
327}
328
329func getJSON(ctx context.Context, d *Deps, url string, into any) error {
330 return getJSONHeaders(ctx, d, url, nil, into)
331}
332
333func getJSONHeaders(ctx context.Context, d *Deps, url string, extra map[string]string, into any) error {
334 b, err := getWith(ctx, d, url, "application/json", extra)
335 if err != nil {
336 return err
337 }
338 return json.Unmarshal(b, into)
339}
340
341// statusAdvice tells the model what to do next, since a bare status code reads
342// as "the tool is broken" and the answer that follows says nothing could be
343// found. A wall and a wrong address need opposite responses.
344func statusAdvice(code int) string {
345 switch code {
346 case http.StatusForbidden, http.StatusUnauthorized:
347 return "That site blocks automated readers, and asking again will not change it. Use a different source."
348 case http.StatusNotFound:
349 return "There is no page at that address. Do not guess another one, search for the page and fetch the url the search returns."
350 case http.StatusPaymentRequired, http.StatusGone:
351 return "That page is not readable without paying or is gone. Use a different source."
352 }
353 if code >= 500 {
354 return "That site is having trouble of its own. Use a different source."
355 }
356 return "Use a different source."
357}
358
359// transportReason keeps the useful half of a transport error. The full text is
360// the whole request line, and when a fetch follows a redirect the address in it
361// is wherever it ended up, which reads as the wrong host having failed.
362func transportReason(err error) string {
363 s := err.Error()
364 switch {
365 case strings.Contains(s, "context deadline exceeded"), strings.Contains(s, "Client.Timeout"):
366 return "it took too long to answer"
367 case strings.Contains(s, "no such host"):
368 return "that name does not resolve"
369 case strings.Contains(s, "connection refused"):
370 return "nothing is listening there"
371 case strings.Contains(s, "certificate"):
372 return "its certificate did not check out"
373 }
374 // A redirect chain names the address it ended on, which is not the one that
375 // was asked for, so the bare url is dropped rather than reported as a host.
376 if i := strings.Index(s, ": "); i > 0 && strings.HasPrefix(s, "Get \"") {
377 return strings.TrimSpace(s[i+2:])
378 }
379 return s
380}
381
382func hostOf(url string) string {
383 s := strings.TrimPrefix(strings.TrimPrefix(url, "https://"), "http://")
384 if i := strings.IndexAny(s, "/?"); i >= 0 {
385 s = s[:i]
386 }
387 return s
388}
389
390// Registry is the set of tools the model is offered.
391type Registry struct {
392 byName map[string]Tool
393 order []string
394}
395
396func (r *Registry) Add(t Tool) {
397 if r.byName == nil {
398 r.byName = map[string]Tool{}
399 }
400 r.byName[t.Name] = t
401 r.order = append(r.order, t.Name)
402 sort.Strings(r.order)
403}
404
405func (r *Registry) Get(name string) (Tool, bool) { t, ok := r.byName[name]; return t, ok }
406func (r *Registry) Names() []string { return append([]string(nil), r.order...) }
407
408// Schemas renders the registry as the OpenAI tools array.
409// Without drops one schema from an offer. Taking a tool off the table is how
410// this harness stops something it cannot afford twice, the same way the repeat
411// ledger does, because a rule in the prompt is a request and this is not.
412func Without(schemas []map[string]any, name string) []map[string]any {
413 out := make([]map[string]any, 0, len(schemas))
414 for _, s := range schemas {
415 if fn, ok := s["function"].(map[string]any); ok {
416 if n, _ := fn["name"].(string); n == name {
417 continue
418 }
419 }
420 out = append(out, s)
421 }
422 return out
423}
424
425func (r *Registry) Schemas() []map[string]any {
426 out := make([]map[string]any, 0, len(r.order))
427 for _, n := range r.order {
428 t := r.byName[n]
429 out = append(out, map[string]any{
430 "type": "function",
431 "function": map[string]any{
432 "name": t.Name,
433 "description": t.Description,
434 "parameters": t.Schema,
435 },
436 })
437 }
438 return out
439}
440
441// Call runs one tool. A tool that fails returns its error as content rather
442// than blowing up the turn, because "the search engine is refusing us" is
443// something the model should say out loud rather than something that should
444// end the conversation.
445func (r *Registry) Call(ctx context.Context, d *Deps, name string, raw json.RawMessage) Result {
446 start := time.Now()
447 res := Result{Name: name, Args: raw}
448 t, ok := r.Get(name)
449 if !ok {
450 res.Err = fmt.Sprintf("no tool named %q", name)
451 res.Content = map[string]any{"error": res.Err}
452 res.Elapsed = time.Since(start)
453 return res
454 }
455 var args map[string]any
456 if len(raw) > 0 {
457 if err := json.Unmarshal(raw, &args); err != nil {
458 args = map[string]any{}
459 }
460 }
461 out, err := t.Run(ctx, d, args)
462 if err != nil {
463 res.Err = err.Error()
464 res.Content = map[string]any{"error": err.Error()}
465 } else {
466 res.Content = out
467 }
468 res.Elapsed = time.Since(start)
469 return res
470}
471
472// helpers for reading loosely typed arguments off a model
473func argStr(a map[string]any, k string) string {
474 if v, ok := a[k]; ok {
475 if s, ok := v.(string); ok {
476 return strings.TrimSpace(s)
477 }
478 return strings.TrimSpace(fmt.Sprint(v))
479 }
480 return ""
481}
482
483func argNum(a map[string]any, k string, def float64) float64 {
484 v, ok := a[k]
485 if !ok {
486 return def
487 }
488 switch n := v.(type) {
489 case float64:
490 return n
491 case int:
492 return float64(n)
493 case string:
494 var f float64
495 if _, err := fmt.Sscanf(n, "%g", &f); err == nil {
496 return f
497 }
498 }
499 return def
500}
501
502func obj(props map[string]any, required ...string) map[string]any {
503 m := map[string]any{"type": "object", "properties": props}
504 if len(required) > 0 {
505 m["required"] = required
506 }
507 return m
508}
509
510func str(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} }
511func num(desc string) map[string]any { return map[string]any{"type": "number", "description": desc} }
512func integer(desc string) map[string]any {
513 return map[string]any{"type": "integer", "description": desc}
514}