feat(web): the agent can see the web, and cite what it saw - #45
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 theFacttype/groundingalready 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.tsalready 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 answers200with 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 lettingfetchfollow 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;verifyAnswerchecks the binding mechanically. Web content and database rows now flow through one verifier instead of two.Notes for review
FACT_KINDSgainsweb_resultandweb_page.publishedis declared precisely because engines omit it: a renderedpublished: <not recorded>is what stops a model putting a year on an undated page.Verification
pnpm run verifygreen: 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