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

17.3 KB · 515 lines · Go Raw History
  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}