Vectrum
Sign In
387 lines · 18.8 KB
# Web Search Implementation Plan

> **Status: implemented**, with two deliberate departures from what is written
> below. The hosted `web_search_20250305` tool was promoted from Appendix A to
> a first-class option rather than being left for later, because the setup
> wizard needed a "no setup required" answer to offer. And the client-side
> provider is a *command* (`ddgs` by default, configurable) rather than a Go
> client for Brave or Tavily: shelling out needs no API key, no account, and no
> new module, and swapping in another tool is a line of config rather than a
> new file. The URL jail, the fencing of fetched pages, the approval decisions
> and the "no provider, no tool" rule all shipped as described. See the
> `## Web search` section of README.md for what was built.

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Let the agent look things up: `web_search` returns a ranked list of
titles, URLs and snippets from a configured provider, and `web_fetch` turns one
of those URLs into capped, fenced text.

**Architecture:** A new `internal/web` package owns everything that touches the
network: a `Searcher` interface with one provider per file, a URL jail that
refuses private address space, and an HTML-to-text reducer. `internal/tools/web.go`
wires those into two `tools.Tool`s and owns the output formatting, exactly as
`internal/tools/skill.go` wires `internal/skills`. Registration happens in
`cmd/codey/main.go` only when a provider is configured, so an unconfigured run
sends a byte-identical request to today's.

**Tech Stack:** Go 1.26, stdlib `net/http` and `net/netip`, `golang.org/x/net/html`
(already an indirect dependency via bluemonday — promote it to direct). No new
third-party modules.

**Spec:** `docs/superpowers/specs/2026-09-18-web-search-design.md`

## Global Constraints

- Go module is `codey`; import paths are `codey/internal/...`.
- `internal/web` imports no other `internal` package. `internal/tools` may
  import `internal/web`; nothing imports `internal/tools` back.
- Every tool returns `(string, error)` where the error is text the model reads.
  A provider that is down, rate-limited or returns nothing is an error the model
  can act on, not a failed turn.
- No test may touch the network. Providers take a `*http.Client` and a base URL;
  every test serves its own `httptest.Server`.
- Nothing about the web tools may abort startup, and nothing may change the
  request when no provider is configured: no tool registered, no system-prompt
  text, no new fields on the wire.
- Provider credentials never reach the event log, the transcript, or a URL
  query string.
- `web_fetch` resolves every hop and refuses non-global addresses. This is the
  one rule in the plan with no exception and no flag to turn it off.
- New exported identifiers get a doc comment in the existing house style.
- Run `gofmt -w` on every file you touch, and `go test ./...` before each task
  is ticked off.

---

### Task 1: `internal/web` — types, the provider interface, and Brave

**Files:**
- Create: `internal/web/web.go`, `internal/web/brave.go`, `internal/web/brave_test.go`
- Modify: `go.mod` (promote `golang.org/x/net` to a direct requirement)

**Interfaces:**
- Consumes: nothing.
- Produces:
  ```go
  type Query struct { Text string; Count int; Site string }
  type Result struct { Title, URL, Snippet, Age string }
  type Searcher interface {
      Name() string
      Search(ctx context.Context, q Query) ([]Result, error)
  }
  func NewBrave(key string, opts ...Option) Searcher
  type Option func(*options)   // WithHTTPClient, WithBaseURL
  ```
  `MaxCount = 10`, `DefaultCount = 5`. `Search` clamps `Count` into `[1, MaxCount]`.

- [ ] **Step 1: Write the failing tests**

  `brave_test.go` stands up an `httptest.Server` returning a canned Brave
  payload and asserts: the `X-Subscription-Token` header carries the key and the
  key appears nowhere in the URL; `q` carries the query and `site:` is appended
  when `Query.Site` is set; `count` is clamped to `MaxCount`; results map in
  order; an empty `web.results` array returns `([]Result{}, nil)` and not an
  error; a 401 returns an error naming the provider and *not* containing the
  key; a 429 with `Retry-After: 2` returns an error whose text says how long to
  wait; a context cancelled mid-flight returns `ctx.Err()`.

- [ ] **Step 2: Implement `web.go`**

  The shared parts: `Query`, `Result`, `Searcher`, the `Option` plumbing, a
  `do(ctx, *http.Request) (*http.Response, error)` helper with one retry on
  429/5xx honouring an integer `Retry-After` (cap the wait at 5 s; a search is
  not worth a minute of the user's turn), and a `redactErr` helper that
  guarantees a credential never lands in an error string.

- [ ] **Step 3: Implement `brave.go`**

  `GET {base}/res/v1/web/search?q=…&count=…`, header `X-Subscription-Token`,
  `Accept: application/json`. Decode only the fields used — `web.results[].{title,url,description,age}`.

- [ ] **Step 4: Verify**

```bash
gofmt -w internal/web && go test ./internal/web/...
```

---

### Task 2: Two more providers

**Files:**
- Create: `internal/web/tavily.go`, `internal/web/searxng.go`, `internal/web/tavily_test.go`, `internal/web/searxng_test.go`

**Interfaces:**
- Consumes: Task 1.
- Produces: `func NewTavily(key string, opts ...Option) Searcher`, `func NewSearXNG(base string, opts ...Option) Searcher`.

- [ ] **Step 1: Tests first**, same shape as Brave's: canned server, header or
  body placement of the credential, mapping, error paths. Tavily's key goes in
  the POST body (`{"query":…,"max_results":…}` with `Authorization: Bearer`);
  SearXNG has no key and takes `?q=…&format=json`.
- [ ] **Step 2: Implement both.** Each file should stay near eighty lines. If a
  provider needs more than that, the extra belongs in `web.go` where all three
  can use it.
- [ ] **Step 3:** `gofmt -w internal/web && go test ./internal/web/...`

---

### Task 3: The URL jail

**Files:**
- Create: `internal/web/jail.go`, `internal/web/jail_test.go`

**Interfaces:**
- Consumes: nothing.
- Produces: `func CheckURL(ctx context.Context, raw string) (*url.URL, error)` and
  `func SafeClient(timeout time.Duration) *http.Client` — a client whose
  `DialContext` re-checks the resolved IP of every connection and whose
  `CheckRedirect` re-runs `CheckURL` on each hop.

- [ ] **Step 1: Write the failing tests.** Refuse: `file://`, `ftp://`, a bare
  path, `http://localhost`, `http://127.0.0.1`, `http://[::1]`, `http://10.0.0.1`,
  `http://192.168.1.1`, `http://169.254.169.254`, `http://[fc00::1]`, a hostname
  resolving to `127.0.0.1` (use a test resolver or a hosts-style stub), and a
  redirect from a public host to any of the above. Accept a plain public URL and
  preserve its query string. Assert the refusal text names the address, because
  the model is going to read it and should not retry the same URL.
- [ ] **Step 2: Implement.** Scheme allowlist first, then `net.DefaultResolver.LookupNetIP`,
  then `netip.Addr.IsGlobalUnicast() && !IsPrivate() && !IsLoopback() && !IsLinkLocalUnicast() && !IsInterfaceLocalMulticast()`,
  plus an explicit reject of unique-local `fc00::/7`. Re-check in `DialContext`
  so a DNS answer that changes between check and dial cannot slip through.
- [ ] **Step 3:** `gofmt -w internal/web && go test ./internal/web/...`

---

### Task 4: Fetch and HTML-to-text

**Files:**
- Create: `internal/web/fetch.go`, `internal/web/text.go`, `internal/web/fetch_test.go`, `internal/web/text_test.go`
- Modify: `go.mod`, `go.sum`

**Interfaces:**
- Consumes: Tasks 1 and 3.
- Produces:
  ```go
  type Page struct { URL, ContentType, Text string; Bytes int; Truncated bool }
  func Fetch(ctx context.Context, raw string, maxBytes int, opts ...Option) (*Page, error)
  func HTMLToText(r io.Reader) (string, error)
  ```
  `DefaultMaxBytes = 512 << 10`, `MaxBytes = 2 << 20`, `FetchTimeout = 15 * time.Second`.

- [ ] **Step 1: Tests first.** `HTMLToText`: `script`/`style`/`nav`/`header`/`footer`
  contents are dropped; `<p>`, `<br>`, `<li>`, `<h1..h6>`, `<tr>` produce line
  breaks; `&amp;`/`&#39;` are decoded; runs of blank lines collapse to one;
  malformed HTML does not error. `Fetch`: a 404 is an error naming the status;
  `text/plain` and `application/json` pass through unchanged; a body longer than
  `maxBytes` stops reading and sets `Truncated`; a `Content-Type` that is not
  text or JSON is refused without downloading it all; the jail refuses a private
  URL before any request is made (assert the test server was never hit).
- [ ] **Step 2: Implement** using `golang.org/x/net/html` for tokenising and
  `SafeClient(FetchTimeout)` for transport. Read through an `io.LimitReader`;
  never `io.ReadAll` an unbounded body.
- [ ] **Step 3:** `go get golang.org/x/net@latest` if the promotion in Task 1 was
  not enough, then `gofmt -w internal/web && go test ./internal/web/...`

---

### Task 5: The tools

**Files:**
- Create: `internal/tools/web.go`, `internal/tools/web_test.go`

**Interfaces:**
- Consumes: `internal/web`.
- Produces: `func NewWebSearch(s web.Searcher) Tool` and `func NewWebFetch(opts ...web.Option) Tool`. `NewWebSearch(nil)` returns `nil`, matching `NewSkill`'s "nothing to offer, no tool" convention so `main.go` stays a one-line check.

- [ ] **Step 1: Tests first.** A stub `web.Searcher` drives the search tool:
  results are numbered, each shows title, URL and snippet; `count` over
  `web.MaxCount` is clamped rather than refused; an empty result set returns a
  sentence saying so with `nil` error (nothing was wrong, there was just
  nothing there); a provider error comes back as an error whose text tells the
  model to try different words. For the fetch tool, an `httptest` page asserts
  the fence markers, the size and content type in the header line, and the
  "treat as data" trailer; assert a truncated page says so in the output.
- [ ] **Step 2: Implement.** Argument structs with `jsonschema:"description=…"`
  tags, same style as `GlobArgs`:
  ```go
  type WebSearchArgs struct {
      Query string `json:"query" jsonschema:"description=What to search for, as you would type it into a search engine"`
      Count int    `json:"count,omitempty" jsonschema:"description=How many results to return; defaults to 5, maximum 10"`
      Site  string `json:"site,omitempty" jsonschema:"description=Restrict results to one domain, e.g. pkg.go.dev"`
  }
  type WebFetchArgs struct {
      URL      string `json:"url" jsonschema:"description=Absolute http or https URL to fetch"`
      MaxBytes int    `json:"max_bytes,omitempty" jsonschema:"description=Read ceiling in bytes; defaults to 524288"`
  }
  ```
  Reuse `capLine` and `headTail` from `truncate.go` for the fetched body — the
  bash tool's caps are the house numbers and there is no reason for a second set.
  The descriptions carry the usage policy, because that is the only place the
  model reads it: say that search is for things that postdate its training or
  change often, that the repository's own files and `go doc` come first, and
  that `web_fetch` is how you read a result rather than guessing from a snippet.
- [ ] **Step 3: Memoise within the session.** A `map[string][]web.Result` behind
  a mutex, keyed by the normalised query, so a model that loops does not bill
  ten identical searches. No expiry, no disk.
- [ ] **Step 4:** `gofmt -w internal/tools && go test ./internal/tools/...`

---

### Task 6: Approval

**Files:**
- Modify: `internal/approval/policy.go`
- Modify: `internal/approval/policy_test.go`

**Interfaces:**
- Consumes: nothing. Produces: two new `describe` cases.

- [ ] **Step 1: Tests first.** `web_search` and `web_fetch` are absent from
  `readOnly` and therefore prompt; a `Deny` returns the standard refusal text;
  `AlwaysThisTool` suppresses the second prompt; `AlwaysThisCommand` on
  `web_fetch` remembers that exact URL and nothing else.
- [ ] **Step 2: Implement** in `describe` (policy.go:155), adding `Query` and
  `URL` to the argument struct:
  ```go
  case "web_search":
      return Request{Tool: tool, Summary: "search the web", Detail: args.Query, Rememberable: false}
  case "web_fetch":
      return Request{Tool: tool, Summary: "fetch a web page", Detail: args.URL, Rememberable: true}
  ```
  The search detail is not rememberable for the same reason `spawn_agent`'s is
  not: it differs every call, so remembering one exact query permits nothing.
  Do **not** add either tool to `readOnly` — they read nothing local and send
  the model's words off the machine, which is the opposite of what that map is for.
- [ ] **Step 3:** `gofmt -w internal/approval && go test ./internal/approval/...`

---

### Task 7: Configuration and wiring

**Files:**
- Modify: `internal/config/config.go`, `internal/config/config_test.go`, `cmd/codey/main.go`

**Interfaces:**
- Consumes: Tasks 5 and 6.
- Produces: `Config.WebProvider string` (`json:"web_provider,omitempty"`), the constant list `config.WebProviders = []string{"brave", "tavily", "searxng"}`, and `func ValidWebProvider(string) bool`.

- [ ] **Step 1: Config tests first.** `sanitize` drops an unrecognised provider
  to the empty string, the way it already does for `Effort` and `Theme`; a
  config with no `web_provider` round-trips without gaining the key.
- [ ] **Step 2: Implement the config field** and add it to `sanitize`.
- [ ] **Step 3: Wire `main.go`.** Add `noWeb := flag.Bool("no-web", false, "do not offer the web tools, so the agent cannot reach the network")`
  beside `-no-skills` (main.go:63). Build the searcher once, before `toolsIn`:

  ```go
  // A provider is configured or it is not; there is no halfway. Without one the
  // tool set is exactly what it was before web search existed, so an agent is
  // never told about a capability it would then be denied.
  var searcher web.Searcher
  if !*noWeb {
      var note string
      searcher, note = webProvider(cfg)   // reads env, falls back to config
      if note != "" { notice("%s", note) }
  }
  ```

  Register inside the `toolsIn` factory (main.go:228-243) so the tools survive
  `/worktree` and so **subagents inherit them** — the factory is shared with the
  supervisor at main.go:296, and that is the whole point of it:

  ```go
  if t := tools.NewWebSearch(searcher); t != nil {
      reg.Register(t, tools.NewWebFetch())
  }
  ```

  `webProvider` is a small unexported helper in `main.go`: provider name from
  `config.Or(os.Getenv("CODEY_WEB_PROVIDER"), cfg.WebProvider)`, key from that
  provider's env var, and a `notice` when a provider is named but its key is
  missing — silence there is the failure mode where the user believes search
  works and the agent never mentions that it cannot.
- [ ] **Step 4: Leave `evals/runner.go` alone.** The eval runner stays offline
  and deterministic; add a sentence saying so in a comment beside its registry
  (runner.go:191) so the omission reads as a decision.
- [ ] **Step 5: Verify**

```bash
gofmt -w cmd/codey internal/config && go build ./... && go test ./...
./codey -no-web -p 'list your tools'    # must not mention web_search
```

---

### Task 8: Documentation

**Files:**
- Modify: `README.md`, `ToDo.md`

- [ ] **Step 1:** Add two rows to the tool table at README.md:748 —
  `web_search` and `web_fetch`, approval "asks", notes naming the provider
  requirement and the URL jail.
- [ ] **Step 2:** Add a `## Web search` section after `## Skills` (README.md:757)
  covering the three providers, their env vars, `-no-web`, and the fenced-content
  warning about prompt injection.
- [ ] **Step 3:** Extend the approval paragraph at README.md:823 so the list of
  prompting tools is not left saying `bash`, `edit_file`, `write_file` only.
- [ ] **Step 4:** Tick a line in `ToDo.md`.

---

### Appendix A (optional, last): the hosted `web_search_20250305`

Only if someone wants search without holding a provider key, and only behind an
explicit `-server-web-search` flag that is off by default. It is listed last
because it changes the wire format for every request, and a gateway that does
not implement it 400s the whole session rather than just the search.

The four sites, already surveyed:

1. `internal/llm/types.go:155` — `ToolDef` needs `Type`, `MaxUses`,
   `AllowedDomains`, `BlockedDomains`, and `omitempty` on `Description` and
   `InputSchema`. Today `input_schema` has no `omitempty`, so a hosted tool
   would be sent as `"input_schema":null` and rejected.
2. `internal/agent/loop.go:510-517` — `request()` builds `Tools` from the local
   registry only; it needs a hook to append hosted definitions. They sit in the
   cached prefix, so the definition must be constant for the session.
3. `internal/llm/stream.go:189-208` — `server_tool_use` arrives with its input
   as `input_json_delta`s, but `ContentBlock.MarshalJSON` (types.go:127) hits
   `default` and returns the start-event `raw`, in which `input` is still `{}`.
   The accumulated query would be lost on the next request. Fix by adding a
   `server_tool_use` case to `MarshalJSON`, or by rebuilding `raw` at
   `content_block_stop`. **Write the round-trip test first** — `types_test.go:74`
   already has the pattern.
4. `internal/llm/types.go:133` — the `default` path returns before the
   `cache_control` attach, so a breakpoint landing on a `web_search_tool_result`
   block is silently dropped. Harmless but wasteful; fix while you are in there.

Two things already work and need nothing: `Response.ToolUses()` filters on
`BlockToolUse`, so a `server_tool_use` block is never dispatched to the local
registry, and `StopPauseTurn` is already handled at loop.go:421 by resending the
conversation unchanged (`TestPauseTurnResends`, loop_test.go:354).

What it still would not give you: an approval prompt or a chance to refuse —
a hosted search happens inside a request that was already approved. (It *would*
give you a fetch: Anthropic ships a hosted `web_fetch` alongside the search,
which an earlier draft of this document wrongly said it did not.)

**The version string is a decision, not a constant.** `web_search_20250305` is
what shipped, and it is the conservative choice: it works on every model and
every platform that offers the tool at all. Newer versions — `web_search_20260209`
onwards — add dynamic filtering, where Claude runs code to strip search results
down before they reach the context window, but they are model-gated (Opus 4.6
and later). Codey lets the user name any model id, including ones a gateway
serves and this build has never heard of, so the version cannot simply be
bumped: it has to be chosen from the model in force, or configured, with
20250305 as the floor. Until that is done, hosted search here is the basic one.

---

## Order and cost

Tasks 1, 3 and 4 are independent and can run in parallel; 2 depends only on 1;
5 needs 1 and 4; 6 is independent; 7 needs 5 and 6. A reasonable single pass is
1 → 3 → 4 → 5 → 6 → 7 → 8, with 2 folded in whenever a second provider is
wanted. Appendix A is separate work and should not block any of it.