Skip to content

Commit aae7b01

Browse files
feat(research): page reads go through codeNERD's browser, not net/http
Steve 2026-09-29: use the browser capabilities native to codeNERD to scrape and research. web_fetch, web_search and context7_fetch now read through one browser page read (isolated tab, load + DOM stability, rendered HTML/text, tab always closed); research.browser_headless (default true) keeps reads in a separate headless Chrome. A missing Chrome is an error naming browser.launch. The kernel web_search action's Target becomes the query. The Context7 key is no longer sent to raw GitHub. getBrowserManager no longer builds a hidden nil-kernel manager. Root review fix: the nine browser tools that dereferenced it would now have panicked unbound; each refuses at entry with requireBoundBrowser (TestBrowserToolsRefuseWhenUnbound) and evidence recording is nil-safe. Implemented by a Grok lane (R2); verified in an isolated tree (HEAD b069d3e + this change): build ok; research (real Chrome), config, core, core/defaults(+policy), browser ok. Live DuckDuckGo not exercised. Spec: Docs/architecture/tools/13-RESEARCH-READS-THROUGH-BROWSER.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016kLAKtMKJieinizH6Zjx2b
1 parent b069d3e commit aae7b01

27 files changed

Lines changed: 1430 additions & 376 deletions
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
doc-class: shipped
3+
subsystem: tools
4+
implementation-status: shipped
5+
last-verified: 2026-09-29
6+
verified-against: b069d3e4
7+
supersedes: []
8+
---
9+
10+
# 13 — Research reads pages through codeNERD's browser
11+
12+
> Written to `Docs/journeys/09-architecture-doc-standard.md` beside this package's July-2026
13+
> files, which are not rewritten yet. Question 2 (what runs) for one capability, written after
14+
> the code (work in flight on 2026-09-29).
15+
16+
## What runs
17+
18+
Steve, 2026-09-29: "instead of fetch for web page pulling or whatever we are using, use the
19+
browser capabilities native to codenerd to scrape data and do research." No research tool reads
20+
a web page with `net/http` any more.
21+
22+
- **One page read.** `readBrowserPage` (`internal/tools/research/browser_read.go:205`) opens an
23+
isolated tab, navigates, waits for load and then for DOM stability (the same stable-wait the
24+
`browser_wait` tool uses), reads the rendered HTML and text, and always closes the tab. A page
25+
that never goes quiet is an error with no body, never a blank-frame extract. A non-2xx document
26+
response is reported as `HTTP <code>`.
27+
- **Headless by default.** `research.browser_headless` (`internal/config/research.go:52`, default
28+
true at `internal/config/research.go:69`) runs research reads in a separate headless Chrome
29+
(`researchHeadlessManager`, `internal/tools/research/browser_read.go:139`), so a read never pops
30+
a window even when the interactive browser (`browser.headless`) is visible. A missing Chrome is
31+
an error naming `browser.launch`.
32+
- **The three tools.** `web_fetch` (`internal/tools/research/web_fetch.go:56`) reads through the
33+
browser, then keeps its HTML-to-markdown conversion and paging. `web_search`
34+
(`internal/tools/research/web_search.go:47`) drives the DuckDuckGo HTML results page in the
35+
browser and parses the live DOM. `context7_fetch` (`internal/tools/research/context7.go:43`)
36+
reads each documentation URL through the browser; the Context7 key is no longer sent to raw
37+
GitHub, where it was never a credential.
38+
- **No hidden second browser.** `getBrowserManager` (`internal/tools/research/browser.go`) returns
39+
the manager boot bound with `SetBrowserRuntime`, or nil. Every browser tool refuses at its entry
40+
with `requireBoundBrowser` when nothing is bound (`TestBrowserToolsRefuseWhenUnbound`).
41+
- **Kernel action.** A kernel `web_search` action's `Target` becomes the query
42+
(`internal/core/virtual_store_actions.go`; `TestHandleModularTool_WebSearchTargetIsQuery`).
43+
44+
Search discovery on Meta is `grounded_web_search`
45+
(`Docs/architecture/perception/13-META-SEARCH-GROUNDING.md`); the browser then reads the pages
46+
it returns.
47+
48+
## Evidence
49+
50+
`internal/tools/research/browser_read_test.go` drives real Chrome against local servers: static
51+
HTML, a page that inserts its content by script after load, `text/plain` markdown, tab count back
52+
to baseline after each read, and no `net/http` use in the research tools. It ran (Chrome found at
53+
the standard Windows path) on 2026-09-29.
54+
55+
## Not verified / open
56+
57+
- The live DuckDuckGo site was not opened; headless Chrome may receive a challenge page there.
58+
The parser is proven on a local document with the same markup.
59+
- A custom `browser.launch` does not reach the headless research browser until the factory
60+
passes it (`internal/system/factory.go`); auto-detected Chrome is used.

‎internal/config/research.go‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,16 @@ import (
1313
//
1414
// Why its own section instead of llm_timeouts: those bound LLM calls (one
1515
// HTTP call to a model vendor, a slot wait, an articulation call). These
16-
// bound researcher HTTP fetches and browser evidence paging — different
16+
// bound research browser page reads and browser evidence paging — different
1717
// vendors, different latency profiles, different owners. Every value here
1818
// bounds one request or one paging window, never a run: a windowed result
1919
// names the remainder and the offset that reaches it.
2020
type ResearchConfig struct {
21-
// WebFetchTimeout bounds one web_fetch HTTP round trip.
21+
// WebFetchTimeout bounds one web_fetch browser page read.
2222
WebFetchTimeout string `json:"web_fetch_timeout,omitempty"`
23-
// Context7Timeout bounds one context7_fetch HTTP round trip.
23+
// Context7Timeout bounds one context7_fetch browser page read.
2424
Context7Timeout string `json:"context7_timeout,omitempty"`
25-
// WebSearchTimeout bounds one web_search HTTP round trip.
25+
// WebSearchTimeout bounds one web_search browser page read.
2626
WebSearchTimeout string `json:"web_search_timeout,omitempty"`
2727
// BrowserExtractTimeout bounds one browser_extract DOM read.
2828
BrowserExtractTimeout string `json:"browser_extract_timeout,omitempty"`
@@ -43,6 +43,13 @@ type ResearchConfig struct {
4343
// view when max_items is absent: compact stays the cheap rung of the
4444
// summary/compact/full ladder.
4545
BrowserReasonCompactItems int `json:"browser_reason_compact_items,omitempty"`
46+
47+
// BrowserHeadless is whether research page reads (web_fetch, web_search,
48+
// context7_fetch) launch their own headless Chrome. Default true: a read
49+
// does not pop a window when browser.headless is false. False uses the
50+
// boot-bound browser manager, which may be headed. A pointer so an absent
51+
// key stays the default rather than false.
52+
BrowserHeadless *bool `json:"browser_headless,omitempty"`
4653
}
4754

4855
// DefaultResearchConfig is the research section with every field written
@@ -59,6 +66,7 @@ func DefaultResearchConfig() ResearchConfig {
5966
BrowserExtractMaxCharsCap: 32000,
6067
BrowserReasonItems: 20,
6168
BrowserReasonCompactItems: 10,
69+
BrowserHeadless: boolPtr(true),
6270
}
6371
}
6472

@@ -98,6 +106,11 @@ func (c ResearchConfig) WithDefaults() ResearchConfig {
98106
if c.BrowserReasonCompactItems == 0 {
99107
c.BrowserReasonCompactItems = d.BrowserReasonCompactItems
100108
}
109+
if c.BrowserHeadless == nil {
110+
// Copy the bool. Aliasing d.BrowserHeadless would let a caller mutate
111+
// the process default through the returned struct.
112+
c.BrowserHeadless = boolPtr(*d.BrowserHeadless)
113+
}
101114
return c
102115
}
103116

@@ -114,6 +127,7 @@ type ResearchPolicy struct {
114127
BrowserExtractMaxCharsCap int
115128
BrowserReasonItems int
116129
BrowserReasonCompactItems int
130+
BrowserHeadless bool
117131
}
118132

119133
// Resolve defaults, checks and parses the section. Every problem Check
@@ -131,6 +145,10 @@ func (c ResearchConfig) Resolve() (ResearchPolicy, error) {
131145
v, _ := time.ParseDuration(s) // Check parsed every one of these
132146
return v
133147
}
148+
headless := true
149+
if c.BrowserHeadless != nil {
150+
headless = *c.BrowserHeadless
151+
}
134152
return ResearchPolicy{
135153
WebFetchTimeout: d(c.WebFetchTimeout),
136154
Context7Timeout: d(c.Context7Timeout),
@@ -140,6 +158,7 @@ func (c ResearchConfig) Resolve() (ResearchPolicy, error) {
140158
BrowserExtractMaxCharsCap: c.BrowserExtractMaxCharsCap,
141159
BrowserReasonItems: c.BrowserReasonItems,
142160
BrowserReasonCompactItems: c.BrowserReasonCompactItems,
161+
BrowserHeadless: headless,
143162
}, nil
144163
}
145164

‎internal/config/research_test.go‎

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,28 @@ func TestDefaultResearchConfig(t *testing.T) {
2121
got.BrowserReasonItems != 20 || got.BrowserReasonCompactItems != 10 {
2222
t.Errorf("default windows = %+v", got)
2323
}
24+
if got.BrowserHeadless == nil || !*got.BrowserHeadless {
25+
t.Errorf("default browser_headless = %v, want true", got.BrowserHeadless)
26+
}
27+
}
28+
29+
// ResearchConfig carries a *bool, so two default structs are not comparable
30+
// by address. The resolved policy is the value the tools actually read.
31+
func resolvedResearch(t *testing.T, c ResearchConfig) ResearchPolicy {
32+
t.Helper()
33+
p, err := c.Resolve()
34+
if err != nil {
35+
t.Fatalf("Resolve: %v", err)
36+
}
37+
return p
2438
}
2539

2640
func TestGetResearchConfig_Overlay(t *testing.T) {
2741
var nilCfg *UserConfig
28-
if got := nilCfg.GetResearchConfig(); got != DefaultResearchConfig() {
42+
if got := resolvedResearch(t, nilCfg.GetResearchConfig()); got != resolvedResearch(t, DefaultResearchConfig()) {
2943
t.Errorf("nil config = %+v, want defaults", got)
3044
}
31-
if got := (&UserConfig{}).GetResearchConfig(); got != DefaultResearchConfig() {
45+
if got := resolvedResearch(t, (&UserConfig{}).GetResearchConfig()); got != resolvedResearch(t, DefaultResearchConfig()) {
3246
t.Errorf("absent section = %+v, want defaults", got)
3347
}
3448
cfg := &UserConfig{Research: &ResearchConfig{
@@ -48,6 +62,18 @@ func TestGetResearchConfig_Overlay(t *testing.T) {
4862
got.BrowserReasonItems != 33 || got.BrowserReasonCompactItems != 44 {
4963
t.Errorf("set section = %+v, want the set values", got)
5064
}
65+
if got.BrowserHeadless == nil || !*got.BrowserHeadless {
66+
t.Errorf("absent browser_headless = %v, want true", got.BrowserHeadless)
67+
}
68+
off := false
69+
explicit := (&UserConfig{Research: &ResearchConfig{BrowserHeadless: &off}}).GetResearchConfig()
70+
if explicit.BrowserHeadless == nil || *explicit.BrowserHeadless {
71+
t.Errorf("explicit false browser_headless = %v", explicit.BrowserHeadless)
72+
}
73+
resolved, err := explicit.Resolve()
74+
if err != nil || resolved.BrowserHeadless {
75+
t.Fatalf("explicit false resolved browser_headless = %v, %v", resolved.BrowserHeadless, err)
76+
}
5177
}
5278

5379
func TestResearchConfig_Resolve(t *testing.T) {
@@ -64,6 +90,9 @@ func TestResearchConfig_Resolve(t *testing.T) {
6490
if p.BrowserExtractMaxChars != 8000 || p.BrowserReasonCompactItems != 10 {
6591
t.Errorf("absent windows did not take defaults: %+v", p)
6692
}
93+
if !p.BrowserHeadless {
94+
t.Errorf("absent browser_headless = false, want true")
95+
}
6796
if _, err := (ResearchConfig{WebFetchTimeout: "bogus"}).Resolve(); err == nil {
6897
t.Error("Resolve accepted an unparseable duration")
6998
}
@@ -122,8 +151,8 @@ func TestLoadUserConfig_InstallsResearchPolicy(t *testing.T) {
122151
got := ResolvedResearchPolicy()
123152
if got.WebFetchTimeout != 61*time.Second || got.BrowserExtractMaxChars != 111 ||
124153
got.BrowserExtractMaxCharsCap != 222 || got.BrowserReasonItems != 33 ||
125-
got.BrowserReasonCompactItems != 44 {
126-
t.Errorf("installed policy = %+v, want the set values", got)
154+
got.BrowserReasonCompactItems != 44 || !got.BrowserHeadless {
155+
t.Errorf("installed policy = %+v, want the set values and default headless", got)
127156
}
128157
if _, err := LoadUserConfig(filepath.Join(dir, "missing.json")); err != nil {
129158
t.Fatalf("LoadUserConfig (missing): %v", err)
@@ -144,3 +173,17 @@ func TestLoadUserConfig_RefusesBadResearch(t *testing.T) {
144173
t.Errorf("LoadUserConfig error = %v, want research.web_fetch_timeout named", err)
145174
}
146175
}
176+
177+
func TestLoadUserConfig_ResearchBrowserHeadlessFalse(t *testing.T) {
178+
t.Cleanup(func() { SetResearchPolicy(mustResolveResearchDefaults()) })
179+
path := filepath.Join(t.TempDir(), "config.json")
180+
if err := os.WriteFile(path, []byte(`{"research":{"browser_headless":false}}`), 0o600); err != nil {
181+
t.Fatal(err)
182+
}
183+
if _, err := LoadUserConfig(path); err != nil {
184+
t.Fatalf("LoadUserConfig: %v", err)
185+
}
186+
if got := ResolvedResearchPolicy(); got.BrowserHeadless {
187+
t.Fatalf("installed browser_headless = true, want false")
188+
}
189+
}

‎internal/core/virtual_store_actions.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -822,6 +822,12 @@ func (v *VirtualStore) handleModularTool(ctx context.Context, req ActionRequest)
822822
args["topic"] = req.Target
823823
case ActionWebFetch:
824824
args["url"] = req.Target
825+
case ActionWebSearch:
826+
// Kernel actions carry the search string in Target. An explicit
827+
// target wins; an empty target still lets a payload query through.
828+
if req.Target != "" {
829+
args["query"] = req.Target
830+
}
825831
case ActionBrowserNavigate:
826832
args["url"] = req.Target
827833
if sid, ok := req.Payload["session_id"].(string); ok {
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package core
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"codenerd/internal/tools"
8+
)
9+
10+
func TestHandleModularTool_WebSearchTargetIsQuery(t *testing.T) {
11+
vs, _ := createActionsTestVS(t)
12+
var got map[string]any
13+
err := vs.modularTools.Register(&tools.Tool{
14+
Effect: tools.EffectRead,
15+
Name: "web_search",
16+
Category: tools.CategoryResearch,
17+
Execute: func(ctx context.Context, args map[string]any) (string, error) {
18+
got = args
19+
return "ok", nil
20+
},
21+
})
22+
if err != nil {
23+
t.Fatal(err)
24+
}
25+
26+
res, err := vs.handleModularTool(context.Background(), ActionRequest{
27+
Type: ActionWebSearch,
28+
Target: "rod browser",
29+
Payload: map[string]any{"max_results": 3, "query": "ignored"},
30+
})
31+
if err != nil {
32+
t.Fatal(err)
33+
}
34+
if !res.Success {
35+
t.Fatalf("handleModularTool: %+v", res)
36+
}
37+
if got["query"] != "rod browser" {
38+
t.Fatalf("query = %#v, want the action target", got["query"])
39+
}
40+
if got["max_results"] != 3 {
41+
t.Fatalf("max_results = %#v, want the payload value", got["max_results"])
42+
}
43+
44+
got = nil
45+
res, err = vs.handleModularTool(context.Background(), ActionRequest{
46+
Type: ActionWebSearch,
47+
Payload: map[string]any{"query": "from-payload"},
48+
})
49+
if err != nil || !res.Success {
50+
t.Fatalf("empty target: %v %+v", err, res)
51+
}
52+
if got["query"] != "from-payload" {
53+
t.Fatalf("empty target query = %#v, want the payload query", got["query"])
54+
}
55+
}

‎internal/tools/research/agents.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,10 @@ Do not change browser authority, manager lifetime, or other sessions from
2727
this tool. Keep each result bounded (one window); never return unbounded DOM
2828
or HTML — but never silently drop content either: anything past the window
2929
must be named with the offset that reaches it.
30+
31+
Page reads go through the browser (2026-09-29): `web_fetch`, `web_search` and
32+
`context7_fetch` call `readBrowserPage` (isolated tab, load + DOM stability,
33+
always closed), headless unless `research.browser_headless` is false. Do not
34+
add a `net/http` page read. Browser tools refuse with `requireBoundBrowser`
35+
when boot bound no manager; there is no fallback manager. Spec:
36+
`Docs/architecture/tools/13-RESEARCH-READS-THROUGH-BROWSER.md`.

0 commit comments

Comments
 (0)