Skip to content

feat(web): the agent can see the web, and cite what it saw - #45

Merged
catomean merged 2 commits into
mainfrom
feat/web-search-and-read
Sep 11, 2026
Merged

feat(web): the agent can see the web, and cite what it saw#45
catomean merged 2 commits into
mainfrom
feat/web-search-and-read

Conversation

@catomean

Copy link
Copy Markdown
Collaborator

Adds @bitbaum/ai-kit/web — search over a provider chain, a page reader that is safe to point at an agent-chosen URL, and a mapping of both onto the Fact type /grounding already verifies.

Why here and not in each app

Every AI product in the fleet is about to need web access, and there are four separate ways to get it wrong. Building it per app means making all four decisions per app — which is the duplication this package exists to end.

Which backend. The same fallback-chain problem chain.ts already solved for models: a single pinned backend is a scheduled outage. searxng (self-hosted, no key, no third party told what your users search for) → brave (an independent index, so the fallback is not the same index asked twice) → tavily. Only the ones the env configures.

Whether it answered. The result is three-valued — found / nothing / could_not_look. "Found nothing" and "could not look" are different answers, and collapsing them into [] is how an expired API key becomes a confident sentence about what does not exist on the internet. A backend that answers 200 with zero results is walked past rather than believed: that is precisely what a metasearch instance does when its upstream engines refuse it, and stopping there would report an outage as a finding.

Fetching a page the model chose, which is a genuinely different security problem from fetching one a user typed. There is no human to blame for the address, and any page the agent reads can hand it a link to the metadata service. So: http(s) only, ports 80/443 only, no credentials, every resolved address checked rather than the first, and redirect: "manual" with the whole check re-run on each hop. Validating the input URL and then letting fetch follow the 302 is not a check — and it passes every test that only feeds it URLs.

Citations. Search without citation binding makes hallucination worse, not better: the invented sentence is now surrounded by real ones. Results become Facts carrying an id and a url; page text becomes evidence labelled with the id that licenses it; verifyAnswer checks the binding mechanically. Web content and database rows now flow through one verifier instead of two.

Notes for review

  • FACT_KINDS gains web_result and web_page. published is declared precisely because engines omit it: a rendered published: <not recorded> is what stops a model putting a year on an undated page.
  • Nothing in the module throws. The caller is an agent loop, and an unreadable page is a fact to reason about rather than an exception to unwind through.
  • It deliberately does not summarise, re-rank with an LLM, crawl, render JavaScript, or cache.
  • ESM-only, like the root export. No new runtime dependencies.

Verification

pnpm run verify green: 162 tests, 39 of them new. They are written against the contract rather than the implementation — this repo has already shipped a regression test that asserted buggy behaviour and went green because of it. The reader's title extraction had a real bug (stripping an inline tag left double spaces); the test caught it and the implementation was fixed rather than the expectation.

🤖 Generated with Claude Code

https://claude.ai/code/session_014nayuDvAPd5BYkSfsVPHHA

catomean and others added 2 commits September 7, 2026 17:51
The comment added a few hours ago says GITHUB_TOKEN cannot merge a PR that
touches .github/workflows. That is contradicted by this fleet's own history:
github-actions[bot] merged datacat#253 and ai-kit#42 today, and both change a
workflow file. I asserted it from the reusable workflow's docstring without
checking, then found the counter-evidence while looking for something else.

Two stalled PRs were offered as proof and neither survives either. ai-kit#11
opened 2026-08-29 and this repo's sweep was not added until 2026-09-04, so for
most of that PR's life nothing was sweeping at all. aoz-housing#122 is still
unexplained — I merged it before diagnosing it, which destroyed the evidence.

The THROUGHPUT reason is unaffected and is why the line stays. A dispatch made
with GITHUB_TOKEN triggers no workflows — the same rule the deploy reconciler
exists for — so after a merge the re-armed CI run's completion fires no
workflow_run and nothing wakes the sweep for the next PR. It waits for the
cron, which GitHub throttles: fleetcrown's sweeps land 50-65 minutes apart
against a */10 schedule. A PAT-created dispatch does emit workflow_run.

The wrong claim is recorded in the file rather than quietly deleted, so the
next reader does not re-derive it from the same docstring I did.

No behaviour change: an undefined secret still resolves to empty and the
reusable workflow still falls back to github.token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvjGNAS9CMfEGNW26tUR4P
Adds `@bitbaum/ai-kit/web`: search over a provider chain, a page reader
safe to point at an agent-chosen URL, and a mapping of both onto the
`Fact` type the grounding harness already verifies.

Every AI product in this fleet is about to need this, and each one is a
distinct way to be wrong:

- WHICH backend. Same fallback-chain problem `chain.ts` solved for
  models, for the same reason — a single pinned backend is a scheduled
  outage. searxng (self-hosted, no key, nobody told what users search
  for) -> brave (an independent index, so the fallback is not the same
  index asked twice) -> tavily. Only the ones the env configures.

- WHETHER it answered. The result is three-valued: found / nothing /
  could_not_look. "Found nothing" and "could not look" are different
  answers and collapsing them into [] is how an expired API key becomes
  a confident sentence about what does not exist on the internet. A
  backend answering 200 with zero results is walked past rather than
  believed — that is what a metasearch instance does when its upstreams
  refuse it, and stopping there would report an outage as a fact.

- FETCHING a page the MODEL chose, which is a different security problem
  from fetching one a user typed. There is no human to blame for the
  address, and any page the agent reads can hand it a link to the
  metadata service. So: http(s) only, ports 80/443, no credentials,
  every resolved address checked (not the first — a hostname with one
  public and one private record is rebinding with the work done), and
  `redirect: "manual"` with the full check re-run on every hop.
  Validating the first URL and then letting fetch follow the 302 is not
  a check, and it passes every test that only feeds it URLs.

- CITATIONS. Search without citation binding makes hallucination worse,
  not better: the invented sentence is now surrounded by real ones. So
  results become Facts with an id and a url, page text becomes evidence
  labelled with the id that licenses it, and `verifyAnswer` checks the
  binding mechanically. Web content and database rows now go through one
  verifier instead of two.

`published` and the rest are declared in FACT_KINDS precisely because
engines omit them: a rendered `published: <not recorded>` is what stops
a model putting a year on an undated page.

39 new tests, written against the contract rather than the
implementation — the repo has already shipped a regression test that
asserted the buggy behaviour and went green. The reader's title path had
a real bug (tag-stripping left double spaces); the test found it and the
implementation was fixed, not the test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014nayuDvAPd5BYkSfsVPHHA
@catomean
catomean merged commit ac5027d into main Sep 11, 2026
1 check passed
@catomean
catomean deleted the feat/web-search-and-read branch September 11, 2026 11:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant