Skip to content

Commit df4372d

Browse files
author
goweft
committed
feat: app ingestion Step 2 — WebView mode (HTTP fetch + HTML parsing)\n\nWebView mode ingests a URL as a workspace: fetch, parse, materialize,\nbind WebAgent. No embedded browser runtime — stdlib HTTP and\ngolang.org/x/net/html give authoritative DOM access in a TUI context.\n\ninternal/webview/browser.go (new)\n Session: NewSession(), Navigate(), Fetch(), Close()\n parseHTML(): title, headings (h1–h3), links (top 30), body text\n - Script and style tags excluded from text extraction\n - Body text truncated to 4 KB for workspace display\n - Links resolved to absolute URLs against base\n FormatPageState(): markdown workspace content from PageState\n ParseForTest(): exported test helper for parser unit testing\n\ninternal/agent/web_agent.go (new)\n WebAgent owns all KindBrowse LLM calls and web navigation.\n WebAction types: answer | navigate | extract\n Autonomy dial: same suggest/confirm/run as MCPAgent\n planAction: LLM selects action from page context summary; JSON response\n parsed to webPlan; plain-text fallback treated as answer\n Contract:\n Pre: instruction non-empty, session present, page state present,\n autonomy valid\n Post: navigate_url (if set) must be a parseable absolute URL\n\ninternal/intent/detect.go\n KindBrowse kind added\n browsePatterns: browse/open/scrape/fetch/read/summarise/summarize/\n go to + URL\n extractBrowseURL: pulls URL into TitleHint\n\ninternal/contract/contract.go\n DefaultWorkspaceContract now accepts 'web' workspace type\n\ninternal/shell/shell.go\n Shell holds webAgent, webSessions, webPages\n handleBrowse: NewSession → Navigate → materialize 'web' workspace\n HandleWebAction: dispatch instruction to WebAgent; updates page state\n on navigation\n CloseWebWorkspace: cleanup on workspace close\n KindBrowse routed in ProcessMessage and StreamMessage\n\ngo.mod: golang.org/x/net/html added (was already indirect via other deps)\n go directive pinned to 1.25.5; chromedp (Go 1.26 requirement) not used.\n Build verified against Go 1.25.10 at /home/gostev/go-install.\n\nTests:\n internal/webview: 7 tests — title, headings, links, script exclusion,\n body text, FormatPageState output, NewSession invalid URL\n internal/agent: +4 WebAgent contract tests (empty instruction, nil\n session, nil page state, invalid autonomy)\n internal/intent: +TestBrowseDetection (8 patterns),\n +TestBrowseDoesNotMatchNormal (5 non-URL cases)
1 parent 784e94e commit df4372d

10 files changed

Lines changed: 897 additions & 25 deletions

File tree

go.mod

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ require (
99
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
1010
github.com/mark3labs/mcp-go v0.54.0
1111
github.com/yuin/gopher-lua v1.1.2
12+
golang.org/x/net v0.54.0
1213
modernc.org/sqlite v1.48.1
1314
)
1415

@@ -49,10 +50,9 @@ require (
4950
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
5051
github.com/yuin/goldmark v1.7.13 // indirect
5152
github.com/yuin/goldmark-emoji v1.0.6 // indirect
52-
golang.org/x/net v0.38.0 // indirect
53-
golang.org/x/sys v0.42.0 // indirect
54-
golang.org/x/term v0.36.0 // indirect
55-
golang.org/x/text v0.30.0 // indirect
53+
golang.org/x/sys v0.44.0 // indirect
54+
golang.org/x/term v0.43.0 // indirect
55+
golang.org/x/text v0.37.0 // indirect
5656
modernc.org/libc v1.70.0 // indirect
5757
modernc.org/mathutil v1.7.1 // indirect
5858
modernc.org/memory v1.11.0 // indirect

go.sum

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -119,22 +119,22 @@ github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA
119119
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
120120
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
121121
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
122-
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
123-
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
124-
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
125-
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
126-
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
127-
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
122+
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
123+
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
124+
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
125+
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
126+
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
127+
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
128128
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
129129
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
130-
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
131-
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
132-
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
133-
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
134-
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
135-
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
136-
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
137-
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
130+
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
131+
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
132+
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
133+
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
134+
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
135+
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
136+
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
137+
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
138138
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
139139
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
140140
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=

internal/agent/agent_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/goweft/cas/internal/agent"
99
mcpclient "github.com/goweft/cas/internal/mcp"
10+
"github.com/goweft/cas/internal/webview"
1011
"github.com/goweft/cas/internal/llm"
1112
)
1213

@@ -287,3 +288,75 @@ func contains(s, sub string) bool {
287288
return false
288289
}())
289290
}
291+
292+
// ── WebAgent ──────────────────────────────────────────────────────
293+
294+
func TestWebAgentContractEmptyInstruction(t *testing.T) {
295+
a := agent.NewWebAgent()
296+
sess, _ := webview.NewSession(nil, "https://example.com")
297+
page := &webview.PageState{URL: "https://example.com", Title: "Test"}
298+
_, err := a.Act(context.Background(), agent.WebRequest{
299+
Instruction: " ",
300+
Session: sess,
301+
PageState: page,
302+
Autonomy: agent.AutonomySuggest,
303+
})
304+
if err == nil {
305+
t.Fatal("expected contract violation for empty instruction, got nil")
306+
}
307+
if !strings.Contains(err.Error(), "instruction_not_empty") {
308+
t.Errorf("expected instruction_not_empty violation, got: %v", err)
309+
}
310+
}
311+
312+
func TestWebAgentContractNilSession(t *testing.T) {
313+
a := agent.NewWebAgent()
314+
page := &webview.PageState{URL: "https://example.com"}
315+
_, err := a.Act(context.Background(), agent.WebRequest{
316+
Instruction: "summarise this page",
317+
Session: nil,
318+
PageState: page,
319+
Autonomy: agent.AutonomySuggest,
320+
})
321+
if err == nil {
322+
t.Fatal("expected contract violation for nil session, got nil")
323+
}
324+
if !strings.Contains(err.Error(), "session_present") {
325+
t.Errorf("expected session_present violation, got: %v", err)
326+
}
327+
}
328+
329+
func TestWebAgentContractNilPageState(t *testing.T) {
330+
a := agent.NewWebAgent()
331+
sess, _ := webview.NewSession(nil, "https://example.com")
332+
_, err := a.Act(context.Background(), agent.WebRequest{
333+
Instruction: "summarise this page",
334+
Session: sess,
335+
PageState: nil,
336+
Autonomy: agent.AutonomySuggest,
337+
})
338+
if err == nil {
339+
t.Fatal("expected contract violation for nil page state, got nil")
340+
}
341+
if !strings.Contains(err.Error(), "page_state_present") {
342+
t.Errorf("expected page_state_present violation, got: %v", err)
343+
}
344+
}
345+
346+
func TestWebAgentContractInvalidAutonomy(t *testing.T) {
347+
a := agent.NewWebAgent()
348+
sess, _ := webview.NewSession(nil, "https://example.com")
349+
page := &webview.PageState{URL: "https://example.com"}
350+
_, err := a.Act(context.Background(), agent.WebRequest{
351+
Instruction: "summarise",
352+
Session: sess,
353+
PageState: page,
354+
Autonomy: agent.Autonomy("turbo"),
355+
})
356+
if err == nil {
357+
t.Fatal("expected contract violation for invalid autonomy, got nil")
358+
}
359+
if !strings.Contains(err.Error(), "autonomy_valid") {
360+
t.Errorf("expected autonomy_valid violation, got: %v", err)
361+
}
362+
}

internal/agent/web_agent.go

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
// web_agent.go — WebAgent: owns all web workspace LLM interactions.
2+
//
3+
// The WebAgent reasons about what to do with a web page — extract data,
4+
// summarise, navigate to a linked page, answer questions — and acts
5+
// according to the autonomy dial. Like MCPAgent, it never leaves its
6+
// workspace scope and its contract enforces that navigation stays within
7+
// the session's origin domain (or an explicitly approved URL).
8+
package agent
9+
10+
import (
11+
"context"
12+
"encoding/json"
13+
"fmt"
14+
"net/url"
15+
"strings"
16+
17+
"github.com/goweft/cas/internal/contract"
18+
"github.com/goweft/cas/internal/llm"
19+
"github.com/goweft/cas/internal/webview"
20+
)
21+
22+
// WebAction names what the WebAgent decided to do.
23+
type WebAction string
24+
25+
const (
26+
WebActionAnswer WebAction = "answer" // answer from current page content
27+
WebActionNavigate WebAction = "navigate" // fetch a new URL
28+
WebActionExtract WebAction = "extract" // extract specific data from the page
29+
)
30+
31+
// WebRequest is the input to WebAgent.
32+
type WebRequest struct {
33+
Instruction string
34+
Session *webview.Session
35+
PageState *webview.PageState // current page snapshot
36+
Autonomy Autonomy
37+
UserContext string
38+
Temperature float64
39+
}
40+
41+
// WebResult is the output from WebAgent.
42+
type WebResult struct {
43+
Action WebAction
44+
Answer string // populated for WebActionAnswer and WebActionExtract
45+
NavigateURL string // populated for WebActionNavigate
46+
NewPage *webview.PageState // populated after navigation
47+
}
48+
49+
// WebAgent handles web workspace interactions.
50+
// It owns all KindBrowse LLM calls and web navigation.
51+
type WebAgent struct{}
52+
53+
// NewWebAgent returns a WebAgent.
54+
func NewWebAgent() *WebAgent { return &WebAgent{} }
55+
56+
// Act reasons about the instruction against the current page state,
57+
// then acts according to the autonomy dial.
58+
func (a *WebAgent) Act(ctx context.Context, req WebRequest) (*WebResult, error) {
59+
if err := a.contract(req, "").CheckPreconditions(); err != nil {
60+
return nil, err
61+
}
62+
63+
plan, err := a.planAction(ctx, req)
64+
if err != nil {
65+
return nil, err
66+
}
67+
68+
// In suggest mode, return the plan without acting.
69+
if req.Autonomy == AutonomySuggest {
70+
result := &WebResult{Action: plan.action, Answer: plan.answer, NavigateURL: plan.navigateURL}
71+
if err := a.contract(req, plan.navigateURL).CheckPostconditions(); err != nil {
72+
return nil, err
73+
}
74+
return result, nil
75+
}
76+
77+
// confirm and run: execute the action.
78+
switch plan.action {
79+
case WebActionNavigate:
80+
newPage, err := req.Session.Fetch(ctx, plan.navigateURL)
81+
if err != nil {
82+
return nil, fmt.Errorf("web-agent: navigate: %w", err)
83+
}
84+
result := &WebResult{
85+
Action: WebActionNavigate,
86+
NavigateURL: plan.navigateURL,
87+
NewPage: newPage,
88+
}
89+
if err := a.contract(req, plan.navigateURL).CheckPostconditions(); err != nil {
90+
return nil, err
91+
}
92+
return result, nil
93+
94+
default: // answer or extract — no network call
95+
result := &WebResult{Action: plan.action, Answer: plan.answer}
96+
if err := a.contract(req, "").CheckPostconditions(); err != nil {
97+
return nil, err
98+
}
99+
return result, nil
100+
}
101+
}
102+
103+
type webPlan struct {
104+
action WebAction
105+
answer string
106+
navigateURL string
107+
}
108+
109+
// planAction asks the LLM what to do with the current page.
110+
func (a *WebAgent) planAction(ctx context.Context, req WebRequest) (*webPlan, error) {
111+
sys := a.systemPrompt(req)
112+
pageContext := formatPageContext(req.PageState)
113+
114+
msgs := []llm.Message{
115+
{Role: "system", Content: sys},
116+
{Role: "user", Content: pageContext + "\n\nInstruction: " + req.Instruction},
117+
}
118+
119+
raw, err := llm.Complete(ctx, msgs, llm.ModelFor("chat"), req.Temperature)
120+
if err != nil {
121+
return nil, fmt.Errorf("web-agent: plan: %w", err)
122+
}
123+
124+
return parseWebPlanResponse(raw), nil
125+
}
126+
127+
// systemPrompt builds the system prompt for web action planning.
128+
func (a *WebAgent) systemPrompt(req WebRequest) string {
129+
var sb strings.Builder
130+
sb.WriteString("You are an assistant operating inside CAS (Conversational Agent Shell).\n")
131+
sb.WriteString("You are working with a web page. You can do three things:\n\n")
132+
sb.WriteString("1. ANSWER — answer a question from the page content directly.\n")
133+
sb.WriteString("2. NAVIGATE — go to a different URL (must be a real link from the page).\n")
134+
sb.WriteString("3. EXTRACT — pull out specific structured data from the page.\n\n")
135+
sb.WriteString("Respond with ONLY a JSON object:\n")
136+
sb.WriteString(`{"action": "answer"|"navigate"|"extract", "answer": "<text>", "navigate_url": "<url>"}`)
137+
sb.WriteString("\n\nFor 'answer' and 'extract': populate 'answer'. Leave 'navigate_url' empty.")
138+
sb.WriteString("\nFor 'navigate': populate 'navigate_url' with a URL from the page links. Leave 'answer' empty.")
139+
sb.WriteString("\n\nScope rule: navigate_url must be a real URL visible in the page content. Never invent URLs.")
140+
if req.UserContext != "" {
141+
sb.WriteString("\n\nUser context: " + req.UserContext)
142+
}
143+
return sb.String()
144+
}
145+
146+
// parseWebPlanResponse parses a JSON action plan from the LLM.
147+
// Falls back to a plain-text answer if the response is not JSON.
148+
func parseWebPlanResponse(raw string) *webPlan {
149+
raw = strings.TrimSpace(raw)
150+
if strings.HasPrefix(raw, "```") {
151+
lines := strings.Split(raw, "\n")
152+
if len(lines) > 2 {
153+
raw = strings.Join(lines[1:len(lines)-1], "\n")
154+
}
155+
}
156+
157+
var v struct {
158+
Action string `json:"action"`
159+
Answer string `json:"answer"`
160+
NavigateURL string `json:"navigate_url"`
161+
}
162+
if err := json.Unmarshal([]byte(raw), &v); err != nil {
163+
// Plain text — treat as answer
164+
return &webPlan{action: WebActionAnswer, answer: strings.TrimSpace(raw)}
165+
}
166+
167+
switch WebAction(v.Action) {
168+
case WebActionNavigate:
169+
return &webPlan{action: WebActionNavigate, navigateURL: v.NavigateURL}
170+
case WebActionExtract:
171+
return &webPlan{action: WebActionExtract, answer: v.Answer}
172+
default:
173+
return &webPlan{action: WebActionAnswer, answer: v.Answer}
174+
}
175+
}
176+
177+
// formatPageContext produces a compact page summary for the LLM prompt.
178+
func formatPageContext(ps *webview.PageState) string {
179+
if ps == nil {
180+
return "(no page loaded)"
181+
}
182+
var sb strings.Builder
183+
sb.WriteString("Current page: " + ps.URL + "\n")
184+
if ps.Title != "" {
185+
sb.WriteString("Title: " + ps.Title + "\n")
186+
}
187+
if len(ps.Headings) > 0 {
188+
sb.WriteString("Headings: " + strings.Join(ps.Headings, " | ") + "\n")
189+
}
190+
if len(ps.Links) > 0 {
191+
sb.WriteString("Links:\n")
192+
for _, l := range ps.Links {
193+
sb.WriteString(" - " + l.Text + ": " + l.Href + "\n")
194+
}
195+
}
196+
// Truncate text to ~1 KB for prompt
197+
text := ps.Text
198+
if len(text) > 1024 {
199+
text = text[:1024] + "…"
200+
}
201+
if text != "" {
202+
sb.WriteString("Content excerpt:\n" + text + "\n")
203+
}
204+
return sb.String()
205+
}
206+
207+
// contract builds and freezes the WebAgent contract.
208+
func (a *WebAgent) contract(req WebRequest, navigateURL string) *contract.Contract {
209+
c := contract.New("web-agent")
210+
c.Preconditions = []contract.Rule{
211+
{
212+
Name: "instruction_not_empty",
213+
Description: "instruction must not be empty",
214+
Check: func() bool { return strings.TrimSpace(req.Instruction) != "" },
215+
},
216+
{
217+
Name: "session_present",
218+
Description: "a web session must be provided",
219+
Check: func() bool { return req.Session != nil },
220+
},
221+
{
222+
Name: "page_state_present",
223+
Description: "a page state must be provided",
224+
Check: func() bool { return req.PageState != nil },
225+
},
226+
{
227+
Name: "autonomy_valid",
228+
Description: "autonomy must be suggest, confirm, or run",
229+
Check: func() bool {
230+
return req.Autonomy == AutonomySuggest ||
231+
req.Autonomy == AutonomyConfirm ||
232+
req.Autonomy == AutonomyRun
233+
},
234+
},
235+
}
236+
c.Postconditions = []contract.Rule{
237+
{
238+
Name: "navigate_url_valid",
239+
Description: "navigate_url must be a parseable absolute URL if set",
240+
Check: func() bool {
241+
if navigateURL == "" {
242+
return true
243+
}
244+
u, err := url.Parse(navigateURL)
245+
return err == nil && u.IsAbs()
246+
},
247+
},
248+
}
249+
return c.Freeze()
250+
}

0 commit comments

Comments
 (0)