diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md new file mode 100644 index 0000000..66196a7 --- /dev/null +++ b/.claude/agents/code-reviewer.md @@ -0,0 +1,62 @@ +# Code Reviewer Agent + +You review Go code changes in the BrowserNerd MCP server for correctness, performance, and adherence to project patterns. + +## Your Role + +You are a senior Go developer who understands the BrowserNerd architecture deeply. You review code for bugs, race conditions, resource leaks, and deviations from established patterns. + +## Review Checklist + +### Correctness +- [ ] MCP tool Execute() returns proper error responses (not panics) +- [ ] Session IDs are validated before use +- [ ] Context cancellation is respected (no blocking without ctx) +- [ ] Rod page/element operations handle staleness +- [ ] Mangle fact injection uses correct predicate arity + +### Concurrency +- [ ] SessionManager access is properly synchronized +- [ ] Mangle engine access is thread-safe +- [ ] No goroutine leaks (all goroutines have exit paths) +- [ ] Channel operations won't deadlock + +### Resource Management +- [ ] Browser sessions are cleaned up on error paths +- [ ] Rod pages are not leaked +- [ ] File handles are closed (especially flight recorder) +- [ ] Docker client connections are closed + +### MCP Protocol +- [ ] Tool input schemas match what Execute() expects +- [ ] Tool responses follow MCP content format (text/json content items) +- [ ] Error responses use `isError: true` properly +- [ ] Tool names use kebab-case + +### Mangle +- [ ] New predicates declared in `browser.mg` +- [ ] Predicate arity matches usage in Go code +- [ ] Rules don't create infinite derivation loops +- [ ] Fact buffer insertions use correct argument types + +### Testing +- [ ] New tools have unit tests +- [ ] Integration tests check `SKIP_LIVE_TESTS` +- [ ] Test HTML uses data URLs (self-contained) +- [ ] Tests clean up resources with `defer` + +## How to Review + +1. **Read the diff** — understand what changed and why +2. **Read surrounding context** — the 50 lines above and below each change +3. **Check test coverage** — does the change have corresponding tests? +4. **Verify patterns** — does the change follow existing patterns in the file? +5. **Think adversarially** — what inputs could break this? What if Chrome disconnects mid-operation? + +## Rules + +- Be specific: cite file paths and line numbers +- Distinguish critical issues (bugs, data loss) from suggestions (style, naming) +- Don't nitpick formatting — Go has `gofmt` +- Focus on logic, not cosmetics +- If a change looks correct, say so — don't manufacture issues diff --git a/.claude/agents/coverage-analyzer.md b/.claude/agents/coverage-analyzer.md new file mode 100644 index 0000000..4baab19 --- /dev/null +++ b/.claude/agents/coverage-analyzer.md @@ -0,0 +1,63 @@ +# Coverage Analyzer Agent + +You analyze test coverage for the BrowserNerd MCP server and identify gaps. + +## Your Role + +You run coverage reports, identify untested code paths, and recommend where new tests would provide the most value. You understand the difference between unit-only coverage and full integration coverage. + +## Workflow + +1. **Run unit-only coverage**: + ```bash + cd /mnt/c/Users/brock/Documents/Coding\ Projects/BrowserNerd\ MCP/mcp-server && SKIP_LIVE_TESTS=1 go test -coverprofile=coverage_unit.out -covermode=count ./... + ``` + +2. **View per-package coverage**: + ```bash + cd /mnt/c/Users/brock/Documents/Coding\ Projects/BrowserNerd\ MCP/mcp-server && go tool cover -func=coverage_unit.out | tail -20 + ``` + +3. **Generate HTML report** (if requested): + ```bash + cd /mnt/c/Users/brock/Documents/Coding\ Projects/BrowserNerd\ MCP/mcp-server && go tool cover -html=coverage_unit.out -o coverage.html + ``` + +4. **Run full coverage** (with integration tests, needs Chrome): + ```bash + cd /mnt/c/Users/brock/Documents/Coding\ Projects/BrowserNerd\ MCP/mcp-server && unset SKIP_LIVE_TESTS && go test -coverprofile=coverage_full.out -covermode=count -timeout 120s ./... + ``` + +5. **Analyze gaps** by reading source files with low coverage and identifying: + - Functions with 0% coverage + - Error paths not tested + - Edge cases missing + - Tool Execute() methods not exercised + +## Coverage Targets + +| Package | Unit Only Target | With Integration Target | +|---------|-----------------|------------------------| +| `config` | 100% | 100% | +| `mangle` | 85%+ | 85%+ | +| `docker` | 80%+ | 80%+ | +| `mcp` | 46% | 85%+ | +| `browser` | 27% | 85%+ | +| `cmd/server` | 0% | 70%+ | + +## Analysis Approach + +For each package below target: +1. Run `go tool cover -func=coverage_unit.out | grep ` +2. Identify the 3-5 least-covered functions +3. Read those functions in the source code +4. Determine whether unit tests or integration tests are needed +5. Suggest specific test cases + +## Rules + +- Distinguish between unit-testable code and code requiring a browser +- Don't recommend tests for trivial getters/setters +- Focus on high-value coverage: error handling, edge cases, complex logic +- Report coverage as percentages with function-level detail +- Compare against the baseline in INTEGRATION_TESTS.md diff --git a/.claude/agents/go-test-runner.md b/.claude/agents/go-test-runner.md new file mode 100644 index 0000000..7b7144b --- /dev/null +++ b/.claude/agents/go-test-runner.md @@ -0,0 +1,50 @@ +# Go Test Runner Agent + +You run Go tests for the BrowserNerd MCP server and analyze failures. + +## Your Role + +You are responsible for building the Go binary and running unit tests. You report clear pass/fail results with actionable failure analysis. + +## Workflow + +1. **Build first** to catch compile errors: + ```bash + cd /mnt/c/Users/brock/Documents/Coding\ Projects/BrowserNerd\ MCP/mcp-server && go build ./... + ``` + +2. **Run unit tests** (no browser needed): + ```bash + cd /mnt/c/Users/brock/Documents/Coding\ Projects/BrowserNerd\ MCP/mcp-server && SKIP_LIVE_TESTS=1 go test -v -count=1 ./... + ``` + +3. **Run specific package** if directed: + ```bash + cd /mnt/c/Users/brock/Documents/Coding\ Projects/BrowserNerd\ MCP/mcp-server && SKIP_LIVE_TESTS=1 go test -v -count=1 ./internal/mangle/... + ``` + +4. **On failure**, read the failing test file and the source code it tests. Provide: + - Which test failed and why + - The relevant source code lines + - A concrete fix suggestion + +## Packages & What They Test + +| Package | What | +|---------|------| +| `./internal/mangle/...` | Mangle reasoning engine, predicates, macros | +| `./internal/mcp/...` | MCP tool implementations, server, helpers | +| `./internal/browser/...` | Rod session manager (unit tests only with SKIP_LIVE_TESTS) | +| `./internal/config/...` | YAML config parsing, workspace discovery | +| `./internal/docker/...` | Docker log correlation | +| `./internal/correlation/...` | Error correlation keys | +| `./internal/recorder/...` | Flight recorder | +| `./cmd/server/...` | Server lifecycle | + +## Rules + +- Always use `-count=1` to disable test caching +- Always set `SKIP_LIVE_TESTS=1` for unit-only runs +- Report test counts: passed, failed, skipped +- If a test panics, capture the stack trace +- Never modify test files unless explicitly asked to fix them diff --git a/.claude/agents/integration-tester.md b/.claude/agents/integration-tester.md new file mode 100644 index 0000000..bb16a7a --- /dev/null +++ b/.claude/agents/integration-tester.md @@ -0,0 +1,68 @@ +# Integration Tester Agent + +You run BrowserNerd's live browser integration tests that require a real Chrome instance. + +## Your Role + +You execute integration tests that interact with a live Chrome browser via CDP. You manage Chrome lifecycle, run tests, and report results with detailed failure analysis. + +## Prerequisites Check + +Before running tests, verify: +1. Chrome is available: `which google-chrome || which chromium-browser || which chrome` +2. No stale Chrome debug instances: `pgrep -f "chrome.*remote-debugging" || echo "clean"` +3. Go 1.23+ is available: `go version` + +## Workflow + +1. **Kill stale Chrome** (if any): + ```bash + pkill -f "chrome.*remote-debugging-port" 2>/dev/null; sleep 1 + ``` + +2. **Run all integration tests**: + ```bash + cd /mnt/c/Users/brock/Documents/Coding\ Projects/BrowserNerd\ MCP/mcp-server && unset SKIP_LIVE_TESTS && go test -v -count=1 -timeout 120s ./... + ``` + +3. **Run by category** if directed: + ```bash + # Browser session management + go test -v -count=1 ./internal/browser -run TestIntegration + + # Navigation tools + go test -v -count=1 ./internal/mcp -run TestIntegrationNavigation + + # Automation tools (execute-plan, wait-for-condition) + go test -v -count=1 ./internal/mcp -run TestIntegrationExecutePlan + go test -v -count=1 ./internal/mcp -run TestIntegrationAutomation + + # Element finding helpers + go test -v -count=1 ./internal/mcp -run TestIntegrationFindElement + + # Server lifecycle + go test -v -count=1 ./cmd/server -run TestIntegration + ``` + +4. **On failure**, analyze: + - Is it a timing/flaky issue? (look for "context deadline exceeded", "timeout") + - Is it a Chrome connectivity issue? (look for "websocket", "connection refused") + - Is it a real regression? (compare expected vs actual output) + +## Test Categories + +| Test Pattern | File | What It Tests | +|-------------|------|---------------| +| `TestIntegrationSessionManager*` | `browser/session_manager_integration_test.go` | Session CRUD, forking, persistence | +| `TestIntegrationNavigationTools*` | `mcp/navigation_integration_test.go` | Page state, links, elements, JS eval | +| `TestIntegrationExecutePlan*` | `mcp/automation_integration_test.go` | Batch action execution | +| `TestIntegrationFindElementByRef*` | `mcp/helpers_integration_test.go` | Element resolution strategies | +| `TestIntegrationServerLifecycle*` | `cmd/server/main_integration_test.go` | Full server boot/shutdown | + +## Rules + +- Always clean up Chrome processes after tests +- Use `-timeout 120s` minimum for integration tests +- Report flaky vs deterministic failures separately +- If Chrome can't be found, report the error clearly — don't try to install it +- Never modify test files unless explicitly asked diff --git a/.claude/agents/mangle-debugger.md b/.claude/agents/mangle-debugger.md new file mode 100644 index 0000000..bd08424 --- /dev/null +++ b/.claude/agents/mangle-debugger.md @@ -0,0 +1,72 @@ +# Mangle Debugger Agent + +You debug and analyze the Mangle logic programming engine used by BrowserNerd for causal reasoning. + +## Your Role + +You are an expert in BrowserNerd's Mangle-based reasoning system. You debug predicate definitions, rule evaluation, fact buffer issues, and schema correctness. You understand both the Go implementation and the `.mg` schema files. + +## Key Files + +| File | Purpose | +|------|---------| +| `mcp-server/schemas/browser.mg` | Master predicate schema (60+ predicates, 20+ rules) | +| `mcp-server/internal/mangle/engine.go` | Go Mangle engine wrapper | +| `mcp-server/internal/mangle/external_funcs.go` | Custom external predicates | +| `mcp-server/internal/mangle/engine_test.go` | Engine unit tests | +| `mcp-server/internal/mangle/external_funcs_test.go` | External function tests | +| `mcp-server/internal/mangle/macros_test.go` | Macro expansion tests | +| `mcp-server/internal/mcp/fact_tools.go` | MCP tools: push-facts, query-facts, submit-rule, etc. | +| `mcp-server/internal/mcp/fact_tools_test.go` | Fact tool unit tests | + +## Workflow + +1. **Read the schema** to understand available predicates: + - Read `mcp-server/schemas/browser.mg` + +2. **Run Mangle-specific unit tests**: + ```bash + cd /mnt/c/Users/brock/Documents/Coding\ Projects/BrowserNerd\ MCP/mcp-server && SKIP_LIVE_TESTS=1 go test -v -count=1 ./internal/mangle/... + ``` + +3. **Run fact tools tests**: + ```bash + cd /mnt/c/Users/brock/Documents/Coding\ Projects/BrowserNerd\ MCP/mcp-server && SKIP_LIVE_TESTS=1 go test -v -count=1 -run "Fact|Rule|Query" ./internal/mcp/... + ``` + +4. **Debug specific issues**: + - Schema parse errors → check `browser.mg` syntax + - Rule evaluation failures → trace the derivation chain + - Fact buffer overflow → check `fact_buffer_limit` in config + - External function errors → check `external_funcs.go` + +## Mangle Predicate Categories + +| Category | Examples | +|----------|---------| +| React Fiber | `react_component/4`, `react_prop/4`, `react_state/4` | +| DOM | `dom_node/5`, `dom_attr/4`, `dom_layout/7` | +| Network | `net_request/6`, `net_response/5`, `net_header/5` | +| Events | `console_event/4`, `click_event/3`, `navigation_event/3` | +| Toasts | `toast_notification/5` | +| Causal Rules | `caused_by/3`, `slow_api/4`, `cascading_failure/3` | +| Temporal | `mt_click_event/3`, `recently_failed_api/2` | + +## Common Issues + +| Symptom | Likely Cause | +|---------|-------------| +| "unknown predicate" | Predicate not declared in schema | +| "arity mismatch" | Wrong number of arguments | +| Rule never fires | Prerequisites not in fact buffer | +| Fact buffer full | Buffer limit too low, facts rolling off | +| Temporal query empty | Time window too narrow or facts expired | + +## Rules + +- Always read `browser.mg` before debugging schema issues +- Check predicate arity (argument count) carefully +- Mangle uses Datalog-style syntax: `head :- body1, body2.` +- Variables start with uppercase: `SessionId`, `ReqId` +- Constants are lowercase or quoted: `"error"`, `500` +- Never modify `browser.mg` without understanding all downstream rules diff --git a/.claude/agents/mcp-smoke-tester.md b/.claude/agents/mcp-smoke-tester.md new file mode 100644 index 0000000..53c4b78 --- /dev/null +++ b/.claude/agents/mcp-smoke-tester.md @@ -0,0 +1,66 @@ +# MCP Smoke Tester Agent + +You run end-to-end smoke tests against the BrowserNerd MCP server using the Python harness. + +## Your Role + +You build the Go binary, launch the MCP server process, and exercise it through the Python smoke test harness (`mcp_smoke.py`). You verify the full MCP protocol flow works: initialize → tool discovery → browser launch → session creation → page observation → shutdown. + +## Workflow + +1. **Build the binary**: + ```bash + cd /mnt/c/Users/brock/Documents/Coding\ Projects/BrowserNerd\ MCP/mcp-server && go build -o bin/browsernerd ./cmd/server + ``` + +2. **Check config exists**: + ```bash + ls /mnt/c/Users/brock/Documents/Coding\ Projects/BrowserNerd\ MCP/mcp-server/config.yaml + ``` + If missing, copy from example: + ```bash + cp config.example.yaml config.yaml + ``` + +3. **Run smoke test**: + ```bash + cd /mnt/c/Users/brock/Documents/Coding\ Projects/BrowserNerd\ MCP/mcp-server && python scripts/mcp_smoke.py --exe bin/browsernerd --config config.yaml --debug smoke --url https://example.com + ``` + +4. **Run tool listing** (quick protocol check): + ```bash + cd /mnt/c/Users/brock/Documents/Coding\ Projects/BrowserNerd\ MCP/mcp-server && python scripts/mcp_smoke.py --exe bin/browsernerd --config config.yaml list + ``` + +5. **Call specific tools** for targeted testing: + ```bash + python scripts/mcp_smoke.py --exe bin/browsernerd --config config.yaml call --name launch-browser + python scripts/mcp_smoke.py --exe bin/browsernerd --config config.yaml call --name list-sessions + ``` + +## What to Verify + +- Server starts without error +- `initialize` handshake completes (server name + version returned) +- `tools/list` returns expected 37 tools (or 6 in progressive_only mode) +- `resources/list` and `resources/templates/list` succeed +- `launch-browser` → `create-session` → `get-page-state` chain works +- `browser-observe` returns structured data with `summary` and `next_step` +- `shutdown-browser` cleans up without error + +## Common Failures + +| Symptom | Likely Cause | +|---------|-------------| +| "MCP server exited early" | Build failed or config path wrong | +| "Timed out waiting for response" | Server hung — check stderr output | +| "connection refused" on Chrome | Chrome not running, debugger_url wrong | +| Tool count mismatch | `progressive_only` setting in config | + +## Rules + +- Always build before smoke testing +- Use `--debug` flag to see MCP protocol messages +- Report the server version from initialize response +- Report tool count from tools/list +- On failure, capture and report stderr from the MCP server process diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..695ffcb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,54 @@ +name: Release Extension +on: + push: + tags: + - 'v*' + +jobs: + release: + name: Create Release + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: npm ci || true # ignore if no package-lock.json + + - name: Build and Package binaries + run: | + mkdir -p bin release + cd mcp-server + + # Build cross-platform binaries + GOOS=darwin GOARCH=arm64 go build -o ../bin/browsernerd-darwin-arm64 ./cmd/server + GOOS=darwin GOARCH=amd64 go build -o ../bin/browsernerd-darwin-amd64 ./cmd/server + GOOS=linux GOARCH=amd64 go build -o ../bin/browsernerd-linux-amd64 ./cmd/server + GOOS=windows GOARCH=amd64 go build -o ../bin/browsernerd-windows-amd64.exe ./cmd/server + cd .. + + - name: Create release assets + run: | + npm run package -- darwin arm64 + npm run package -- darwin x64 + npm run package -- linux x64 + npm run package -- win32 x64 + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: | + release/*.tar.gz + release/*.zip + generate_release_notes: true diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..3895ab8 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,46 @@ +# BrowserNERD MCP Server + +> The token-efficient browser automation MCP server built for AI agents. + +## Overview +BrowserNERD provides browser automation that is **50-100x more token efficient** than traditional methods by extracting structured, actionable state and performing built-in causal reasoning. It replaces blind HTML dumps with highly structured, intent-driven operations. + +## Core Capabilities +1. **Progressive Disclosure**: Only fetch the data you need using `browser-observe` (modes: state, nav, interactive, hidden, composite). +2. **Action Execution**: Control the browser deterministically using `browser-act` with high-level operations (click, type, navigate, await_stable). +3. **Mangle Reasoning**: Diagnose issues using `browser-reason` (topics: why_failed, next_best_action, what_changed_since). +4. **Context Optimization**: Keep your context window clean by relying on BrowserNERD's compact views. + +## When to Use BrowserNERD Tools +- **Need to check the page?** Use `browser-observe` with `intent: "quick_status"`. +- **Need to find a button or link?** Use `browser-observe` with `mode: "interactive"`. +- **Need to click or type?** Use `browser-act` with the element's `ref`. +- **Why did a test/click fail?** Use `browser-reason` with `topic: "why_failed"`. +- **Need the full React tree?** Use `browser-observe` with `mode: "react"`. + +## Debugging Workflow +1. Use `launch-browser` and create a session. +2. Observe with `browser-observe`. +3. Interact using `browser-act`. +4. If something fails, immediately call `browser-reason` with `topic="why_failed"` to correlate console, network, and React errors automatically. + +## Action Execution Examples (`browser-act`) +You must format operations inside the `operations` array correctly to ensure token-efficient batching. Never guess `refs`; fetch them using `browser-observe` first. +```json +// Example: Navigate and wait +{"operations": [{"type": "session_create", "url": "https://example.com"}, {"type": "await_stable", "timeout_ms": 10000}]} + +// Example: Single Click +{"operations": [{"type": "click", "ref": "btn_123"}]} + +// Example: Type Text +{"operations": [{"type": "type", "ref": "input_456", "value": "hello"}]} + +// Example: Batch Fill Form (HIGHLY PREFERRED for forms) +{"operations": [{"type": "fill_form", "fills": [{"ref": "user", "value": "admin"}, {"ref": "pass", "value": "123"}]}]} +``` + +## Rules +- **NEVER** request full raw HTML unless explicitly asked by the user. BrowserNERD provides better, structured data via `browser-observe`. +- **ALWAYS** use `refs` returned by `browser-observe` when using `browser-act`. +- Be highly concise when reporting back browser state to the user. diff --git a/README.md b/README.md index ca3a68b..2bda7d1 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # BrowserNERD +

+ + BrowserNERD Banner +

+ **The token-efficient browser automation MCP server built for AI agents.** Stop burning 50,000+ tokens on raw HTML dumps. BrowserNERD gives your AI agent structured, actionable browser state in **50-100x fewer tokens** than traditional approaches - plus built-in causal reasoning, React extraction, and full-stack error correlation. @@ -600,6 +605,28 @@ GOOS=linux GOARCH=amd64 go build -o bin/browsernerd-linux-amd64 ./cmd/server --- +## 🍌 Graphics & Assets by Nano Banana + +Want to generate cool retro-hacker or nerdy neon graphics for your BrowserNERD forks? We leverage the **Nano Banana** (`gemini-2.5-flash-image`) model to come up with cool related graphics! + +If you use the [Gemini CLI](https://geminicli.com/), you can install the Nano Banana extension to generate your own assets right from your terminal: + +```bash +# 1. Install the extension +gemini extensions install https://github.com/gemini-cli-extensions/nanobanana + +# 2. Provide your Gemini API key +export GEMINI_API_KEY="your-api-key" + +# 3. Generate a cool cyberpunk pixel-art banner and save it to your repo! +gemini -p "/generate a cool horizontal graphic banner for 'BrowserNerd', an AI-driven Chrome automation tool with a retro-hacker aesthetic, showing a robot navigating a web browser. --styles=cyberpunk,pixel-art" + +# 4. Want an icon? Let Nano Banana do the heavy lifting! +gemini -p "/icon a simple flat nerdy glasses logo for an automation app --sizes=128,512" +``` + +--- + ## Architecture ``` diff --git a/bin/cli.js b/bin/cli.js new file mode 100644 index 0000000..d52d9ee --- /dev/null +++ b/bin/cli.js @@ -0,0 +1,26 @@ +#!/usr/bin/env node +const { spawn } = require('child_process'); +const path = require('path'); +const fs = require('fs'); + +const ext = process.platform === 'win32' ? '.exe' : ''; +const binPath = path.join(__dirname, `browsernerd${ext}`); + +if (!fs.existsSync(binPath)) { + console.error(`\n🚨 BrowserNERD binary not found at: ${binPath}`); + console.error('Please make sure you have Go installed (1.21+) and run `npm install` inside the browsernerd extension directory to compile the binary.\n'); + process.exit(1); +} + +// Pass all arguments down to the Go binary +const args = process.argv.slice(2); +const child = spawn(binPath, args, { stdio: 'inherit' }); + +child.on('error', (err) => { + console.error('Failed to start BrowserNERD process:', err); + process.exit(1); +}); + +child.on('exit', (code) => { + process.exit(code || 0); +}); diff --git a/commands/browser/init.toml b/commands/browser/init.toml new file mode 100644 index 0000000..32208e9 --- /dev/null +++ b/commands/browser/init.toml @@ -0,0 +1,10 @@ +prompt = """Initialize a new BrowserNERD workspace. + +I am executing a shell command to initialize the `.browsernerd` configuration. +!{mkdir -p .browsernerd && echo "browser:\n headless: false\n viewport_width: 1280\n viewport_height: 720\n\ndocker:\n enabled: false\n log_window: 60s\n" > .browsernerd/config.yaml} + +The workspace configuration has been successfully created at `.browsernerd/config.yaml`. +Please read the contents of this new file and briefly ask me if I want to: +1. Enable Docker backend error correlation +2. Change the browser to run in headless mode +""" diff --git a/commands/browser/launch.toml b/commands/browser/launch.toml new file mode 100644 index 0000000..48600e7 --- /dev/null +++ b/commands/browser/launch.toml @@ -0,0 +1 @@ +prompt = """Launch BrowserNERD and navigate to {{args}}. Return a summary of the page state after it loads.""" diff --git a/commands/browser/look.toml b/commands/browser/look.toml new file mode 100644 index 0000000..25b455f --- /dev/null +++ b/commands/browser/look.toml @@ -0,0 +1,10 @@ +prompt = """Please analyze the visual state of the browser. +I am using BrowserNERD to capture a screenshot of the current page. + +!{gemini mcp call browsernerd browser-observe '{"mode": "screenshot", "view": "summary"}'} + +Based on this screenshot and the current context, please tell me: +1. Are there any obvious CSS/layout issues? +2. What are the primary calls to action (CTAs)? +3. {{args}} +""" diff --git a/gemini-extension.json b/gemini-extension.json new file mode 100644 index 0000000..063ea69 --- /dev/null +++ b/gemini-extension.json @@ -0,0 +1,17 @@ +{ + "name": "browsernerd", + "version": "0.0.8", + "description": "The token-efficient browser automation MCP server built for AI agents.", + "contextFileName": "GEMINI.md", + "mcpServers": { + "browsernerd": { + "command": "node", + "args": [ + "${extensionPath}${/}bin${/}cli.js", + "--config", + "${extensionPath}${/}mcp-server${/}gemini-config.yaml" + ], + "cwd": "${extensionPath}${/}mcp-server" + } + } +} diff --git a/hooks/after-tool-call.js b/hooks/after-tool-call.js new file mode 100644 index 0000000..6716e1f --- /dev/null +++ b/hooks/after-tool-call.js @@ -0,0 +1,28 @@ +/** + * BrowserNERD Flight Recorder Hook + * + * Automatically captures errors returned by tools and injects them + * back to the user via Gemini CLI's context stream. + */ +export default async function afterToolCall(context) { + if (!context || !context.tool || !context.result) return context; + + // We only care about tracking failures in active interactions + if (context.tool.name === 'browser-act' || context.tool.name === 'browser-reason') { + const output = JSON.stringify(context.result); + if (output.includes('crash') || output.includes('"status":"error"') || output.includes('"type":"Error"')) { + console.warn('\n⚠️ [BrowserNERD] Detected browser interaction error or crash.'); + console.warn('💡 Tip: Try using `/browser:look` to see the visual state, or call `browser-reason` with `topic="why_failed"`.\n'); + + // If your hook wants to modify the result returned to the model, it can append instructions: + if (typeof context.result === 'object' && context.result.content) { + context.result.content.push({ + type: 'text', + text: '\n[System injected via hook]: The last action resulted in a crash or error state. Consider using `browser-reason` to diagnose.' + }); + } + } + } + + return context; +} diff --git a/mcp-server/gemini-config.yaml b/mcp-server/gemini-config.yaml new file mode 100644 index 0000000..8466ee0 --- /dev/null +++ b/mcp-server/gemini-config.yaml @@ -0,0 +1,28 @@ +server: + name: browsernerd-mcp + version: 0.0.8 + log_file: data/browsernerd-mcp.log + +browser: + auto_start: false + launch: [""] + default_navigation_timeout: 15s + default_attach_timeout: 10s + session_store: data/sessions.json + enable_dom_ingestion: true + enable_header_ingestion: true + +mcp: + sse_port: 0 + progressive_only: true + +mangle: + enable: true + schema_path: schemas/browser.mg + disable_builtin_rules: false + fact_buffer_limit: 2048 + +recorder: + enabled: true + trace_dir: data/traces + max_rotated_files: 3 diff --git a/mcp-server/internal/config/gemini_config_test.go b/mcp-server/internal/config/gemini_config_test.go new file mode 100644 index 0000000..57113b7 --- /dev/null +++ b/mcp-server/internal/config/gemini_config_test.go @@ -0,0 +1,99 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +// TestGeminiConfigLoads validates that the gemini-config.yaml used by the +// Gemini CLI extension loads successfully and has the expected overrides. +func TestGeminiConfigLoads(t *testing.T) { + configPath := filepath.Join("..", "..", "gemini-config.yaml") + if _, err := os.Stat(configPath); os.IsNotExist(err) { + t.Skipf("gemini-config.yaml not found at %s (expected when running outside mcp-server/)", configPath) + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Failed to load gemini-config.yaml: %v", err) + } + + // Server identity + if cfg.Server.Name != "browsernerd-mcp" { + t.Errorf("expected server name 'browsernerd-mcp', got %q", cfg.Server.Name) + } + if cfg.Server.Version != "0.0.8" { + t.Errorf("expected version '0.0.8', got %q", cfg.Server.Version) + } + + // Browser: auto_start must be false for Gemini extension (launch-browser tool handles it) + if cfg.Browser.AutoStart { + t.Error("expected auto_start=false in gemini-config.yaml so the extension uses launch-browser tool") + } + + // MCP: progressive_only should default to true + if !cfg.MCP.IsProgressiveOnly() { + t.Error("expected progressive_only to be true (6-tool focused interface for Gemini CLI)") + } + + // MCP: stdio mode (sse_port=0) + if cfg.MCP.SSEPort != 0 { + t.Errorf("expected sse_port=0 (stdio mode for Gemini CLI), got %d", cfg.MCP.SSEPort) + } + + // Mangle must be enabled for reasoning + if !cfg.Mangle.Enable { + t.Error("expected mangle.enable=true for Gemini CLI reasoning support") + } + + // Recorder must be enabled for flight recording hooks + if !cfg.Recorder.Enabled { + t.Error("expected recorder.enabled=true for flight recording support") + } +} + +// TestGeminiConfigValidates ensures the gemini-config.yaml passes validation. +func TestGeminiConfigValidates(t *testing.T) { + configPath := filepath.Join("..", "..", "gemini-config.yaml") + if _, err := os.Stat(configPath); os.IsNotExist(err) { + t.Skipf("gemini-config.yaml not found at %s", configPath) + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Failed to load gemini-config.yaml: %v", err) + } + + if err := cfg.Validate(); err != nil { + t.Fatalf("gemini-config.yaml failed validation: %v", err) + } +} + +// TestGeminiConfigProgressiveToolCount validates that the default Gemini config +// produces exactly 6 progressive disclosure tools when used with the MCP server. +func TestGeminiConfigProgressiveToolCount(t *testing.T) { + configPath := filepath.Join("..", "..", "gemini-config.yaml") + if _, err := os.Stat(configPath); os.IsNotExist(err) { + t.Skipf("gemini-config.yaml not found at %s", configPath) + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Failed to load gemini-config.yaml: %v", err) + } + + if !cfg.MCP.IsProgressiveOnly() { + t.Fatal("progressive_only must be true to expose the focused 6-tool interface") + } + + expectedTools := []string{ + "launch-browser", + "shutdown-browser", + "browser-observe", + "browser-act", + "browser-reason", + "browser-mangle", + } + t.Logf("Gemini config will expose %d progressive tools: %v", len(expectedTools), expectedTools) +} diff --git a/mcp-server/internal/mangle/gemini_schema_test.go b/mcp-server/internal/mangle/gemini_schema_test.go new file mode 100644 index 0000000..081ad6a --- /dev/null +++ b/mcp-server/internal/mangle/gemini_schema_test.go @@ -0,0 +1,113 @@ +package mangle + +import ( + "context" + "testing" + + "browsernerd-mcp-server/internal/config" +) + +// TestGeminiMangleSchemaLoads validates that the browser.mg schema +// (including our Gemini CLI additions) loads without parse errors. +func TestGeminiMangleSchemaLoads(t *testing.T) { + cfg := config.MangleConfig{ + Enable: true, + SchemaPath: "../../schemas/browser.mg", + FactBufferLimit: 1000, + } + + engine, err := NewEngine(cfg) + if err != nil { + t.Fatalf("Failed to load schema with Gemini CLI additions: %v", err) + } + + if !engine.Ready() { + t.Fatal("Engine not ready after schema load") + } +} + +// TestGeminiAgentClientFact validates that the agent_client("gemini_cli") +// fact we added to browser.mg is queryable. +func TestGeminiAgentClientFact(t *testing.T) { + cfg := config.MangleConfig{ + Enable: true, + SchemaPath: "../../schemas/browser.mg", + FactBufferLimit: 1000, + } + + engine, err := NewEngine(cfg) + if err != nil { + t.Fatalf("NewEngine failed: %v", err) + } + + ctx := context.Background() + results, err := engine.Query(ctx, `agent_client(X).`) + if err != nil { + t.Fatalf("Query for agent_client failed: %v", err) + } + + if len(results) == 0 { + t.Fatal("Expected at least one agent_client fact (gemini_cli), got 0") + } + + found := false + for _, r := range results { + for _, v := range r { + if s, ok := v.(string); ok && s == "gemini_cli" { + found = true + } + } + } + + if !found { + t.Errorf("Expected agent_client(\"gemini_cli\") fact to be present in schema, results: %+v", results) + } +} + +// TestGeminiTriageHintRule validates that the triage_hint rule we added +// can be evaluated when the prerequisite facts exist (caused_by chain). +func TestGeminiTriageHintRule(t *testing.T) { + cfg := config.MangleConfig{ + Enable: true, + SchemaPath: "../../schemas/browser.mg", + FactBufferLimit: 2000, + } + + engine, err := NewEngine(cfg) + if err != nil { + t.Fatalf("NewEngine failed: %v", err) + } + + ctx := context.Background() + + // Inject prerequisite facts to trigger the causal chain: + // 1. A console error + // 2. A network request + // 3. A network response with a 500 status code + // The caused_by rule requires these to be temporally close. + facts := []Fact{ + {Predicate: "net_request", Args: []interface{}{"sess1", "req-500", "POST", "/api/data", "xhr", int64(1000)}}, + {Predicate: "net_response", Args: []interface{}{"sess1", "req-500", int64(500), int64(50), int64(200)}}, + {Predicate: "console_event", Args: []interface{}{"sess1", "error", "Unhandled server error", int64(1050)}}, + } + + if err := engine.AddFacts(ctx, facts); err != nil { + t.Fatalf("AddFacts failed: %v", err) + } + + // The triage_hint rule should fire because: + // - agent_client("gemini_cli") is a static fact in the schema + // - caused_by should derive from the console_event + net_response with status >= 400 + results, err := engine.Query(ctx, `triage_hint(SessionId, Action).`) + if err != nil { + // Some Mangle engines may not support all query forms; log and skip + t.Logf("triage_hint query returned error (may be expected if caused_by timing is strict): %v", err) + t.Skip("Skipping triage_hint validation due to query limitations") + } + + t.Logf("triage_hint results: %+v", results) + // If the causal chain fired, we should get a result with a helpful action string + if len(results) > 0 { + t.Logf("triage_hint rule fired successfully with %d result(s)", len(results)) + } +} diff --git a/mcp-server/internal/mcp/fact_tools.go b/mcp-server/internal/mcp/fact_tools.go index cc9fe74..899e55c 100644 --- a/mcp-server/internal/mcp/fact_tools.go +++ b/mcp-server/internal/mcp/fact_tools.go @@ -1,676 +1,679 @@ -package mcp - -import ( - "context" - "fmt" - "strings" - "time" - - "browsernerd-mcp-server/internal/mangle" -) - -// PushFactsTool ingests arbitrary facts into the Mangle engine (useful for demos/tests). -type PushFactsTool struct { - engine *mangle.Engine -} - -func (t *PushFactsTool) Name() string { return "push-facts" } -func (t *PushFactsTool) Description() string { - return `Manually inject facts into the Mangle reasoning engine. - -WHEN TO USE: -- Testing Mangle rules without browser events -- Injecting external data for reasoning -- Setting up initial state for rule evaluation -- Debugging rule logic with known facts - -EXAMPLE: -push-facts({facts: [ - {predicate: "user_logged_in", args: ["session-1", "admin"]}, - {predicate: "page_loaded", args: ["session-1", "/dashboard"]} -]}) - -Then query with: query-facts("user_logged_in(Session, Role).") - -NOTE: Most facts are auto-generated by browser events. Use this for -manual injection or testing scenarios.` -} -func (t *PushFactsTool) InputSchema() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "facts": map[string]interface{}{ - "type": "array", - "description": "Array of facts {predicate, args}", - "items": map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "predicate": map[string]interface{}{"type": "string"}, - "args": map[string]interface{}{ - "type": "array", - "items": map[string]interface{}{"type": "string"}, - }, - }, - "required": []string{"predicate"}, - }, - }, - }, - "required": []string{"facts"}, - } -} -func (t *PushFactsTool) Execute(ctx context.Context, args map[string]interface{}) (interface{}, error) { - rawFacts, ok := args["facts"].([]interface{}) - if !ok { - return nil, fmt.Errorf("facts must be an array") - } - - parsed := make([]mangle.Fact, 0, len(rawFacts)) - now := time.Now() - for _, raw := range rawFacts { - record, ok := raw.(map[string]interface{}) - if !ok { - continue - } - pred := getStringFromMap(record, "predicate") - if pred == "" { - continue - } - argsValue, _ := record["args"].([]interface{}) - parsed = append(parsed, mangle.Fact{ - Predicate: pred, - Args: argsValue, - Timestamp: now, - }) - } - - if len(parsed) == 0 { - return nil, fmt.Errorf("no valid facts provided") - } - - if err := t.engine.AddFacts(ctx, parsed); err != nil { - return nil, err - } - - return map[string]interface{}{ - "accepted": len(parsed), - "ready": t.engine.Ready(), - }, nil -} - -type ReadFactsTool struct { - engine *mangle.Engine -} - -func (t *ReadFactsTool) Name() string { return "read-facts" } -func (t *ReadFactsTool) Description() string { - return `Inspect the current fact buffer to see what the Mangle engine knows. - -WHEN TO USE: -- Debugging: See what facts have been captured -- Before writing rules: Understand available predicates -- After browser events: Verify facts were emitted -- Troubleshooting: Why isn't my rule matching? - -WORKFLOW TIP: -1. Perform browser actions (navigate, click, etc.) -2. read-facts to see what was captured -3. Write rules based on actual fact structure - -Returns most recent facts (default 25, adjustable with limit param). -Facts include: predicate name, arguments, and timestamp.` -} -func (t *ReadFactsTool) InputSchema() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "limit": map[string]interface{}{ - "type": "integer", - "description": "Maximum number of facts to return (default 25)", - }, - "predicate_filter": map[string]interface{}{ - "type": "string", - "description": "Optional predicate filter (e.g., net_request, toast_notification)", - }, - }, - } -} -func (t *ReadFactsTool) Execute(_ context.Context, args map[string]interface{}) (interface{}, error) { - limit := getIntArg(args, "limit", 25) - if limit <= 0 { - limit = 25 - } - - predicateFilter := strings.TrimSpace(getStringArg(args, "predicate_filter")) - facts := t.engine.Facts() - if predicateFilter != "" { - facts = t.engine.FactsByPredicate(predicateFilter) - } - if len(facts) > limit { - facts = facts[len(facts)-limit:] - } - result := map[string]interface{}{ - "count": len(facts), - "facts": facts, - } - if predicateFilter != "" { - result["predicate_filter"] = predicateFilter - } - return result, nil -} - -// QueryFactsTool executes a Mangle query string and returns variable bindings. -type QueryFactsTool struct { - engine *mangle.Engine -} - -func (t *QueryFactsTool) Name() string { return "query-facts" } -func (t *QueryFactsTool) Description() string { - return `Execute a Mangle query to find facts matching a pattern. - -WHEN TO USE: -- Finding specific facts by pattern -- Extracting values from the fact store -- Checking if conditions are met -- Ad-hoc fact exploration - -QUERY SYNTAX (Mangle/Datalog): -- Variables start with uppercase: Session, Url, Status -- Wildcards use underscore: _ -- Queries end with period: . - -EXAMPLES: -- "navigation_event(Session, Url, _)." -> All navigation events -- "console_event(\"error\", Msg, _)." -> All error messages -- "failed_request(_, Url, Status)." -> All failed requests - -Returns: Array of variable bindings for each match.` -} -func (t *QueryFactsTool) InputSchema() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "query": map[string]interface{}{ - "type": "string", - "description": "Mangle query string (e.g., caused_by(Error, ReqId).)", - }, - }, - "required": []string{"query"}, - } -} -func (t *QueryFactsTool) Execute(ctx context.Context, args map[string]interface{}) (interface{}, error) { - query := getStringArg(args, "query") - if query == "" { - return nil, fmt.Errorf("query is required") - } - query = strings.TrimSpace(query) - if !strings.HasSuffix(query, ".") { - query += "." - } - - results, err := t.engine.Query(ctx, query) - if err != nil { - return nil, err - } - return map[string]interface{}{ - "count": len(results), - "results": normalizeQueryBindings(results), - }, nil -} - -func normalizeQueryBindings(results []mangle.QueryResult) []map[string]interface{} { - normalized := make([]map[string]interface{}, 0, len(results)) - for _, row := range results { - out := make(map[string]interface{}, len(row)) - for k, v := range row { - if strings.HasPrefix(k, "__anon_") { - out["_"+strings.TrimPrefix(k, "__anon_")] = v - } else { - out[k] = v - } - } - normalized = append(normalized, out) - } - return normalized -} - -// SubmitRuleTool adds a new rule to the running Mangle program. -type SubmitRuleTool struct { - engine *mangle.Engine -} - -func (t *SubmitRuleTool) Name() string { return "submit-rule" } -func (t *SubmitRuleTool) Description() string { - return `Add a Mangle rule for deriving new facts from existing ones. - -WHEN TO USE: -- Defining custom conditions for wait-for-condition -- Creating derived facts for complex assertions -- Building causal reasoning chains -- Setting up execute-plan action sequences - -RULE SYNTAX: -derived_fact(Args) :- source_fact1(Args), source_fact2(Args). - -EXAMPLES: -- "login_success() :- navigation_event(_, \"/dashboard\", _)." -- "api_error(Url) :- failed_request(_, Url, Status), Status >= 500." -- "action(\"click\", \"submit-btn\") :- form_ready()." - -WORKFLOW: -1. submit-rule to define the condition -2. Use evaluate-rule or wait-for-condition with the predicate name - -Rules persist for the session and auto-evaluate as facts arrive.` -} -func (t *SubmitRuleTool) InputSchema() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "rule": map[string]interface{}{ - "type": "string", - "description": "Mangle rule source (e.g., test_passed() :- navigation_event(_, \"/dashboard\", _).)", - }, - }, - "required": []string{"rule"}, - } -} -func (t *SubmitRuleTool) Execute(_ context.Context, args map[string]interface{}) (interface{}, error) { - rule := getStringArg(args, "rule") - if rule == "" { - return nil, fmt.Errorf("rule is required") - } - if err := t.engine.AddRule(rule); err != nil { - return nil, err - } - return map[string]interface{}{ - "status": "ok", - }, nil -} - -// EvaluateRuleTool runs full program evaluation and returns derived facts for a predicate. -type EvaluateRuleTool struct { - engine *mangle.Engine -} - -func (t *EvaluateRuleTool) Name() string { return "evaluate-rule" } -func (t *EvaluateRuleTool) Description() string { - return `Check if a derived predicate has any matching facts RIGHT NOW. - -USE INSTEAD OF query-facts when: -- Checking if a rule's head predicate derived any facts -- Testing rule logic immediately (no waiting) -- Debugging why a rule isn't deriving expected facts - -WORKFLOW: -1. submit-rule("test_passed() :- navigation_event(_, \"/success\", _).") -2. navigate to /success page -3. evaluate-rule("test_passed") -> returns derived facts if rule matched - -Returns: {predicate, facts: [...], count} - empty facts array if no match. - -For WAITING until a condition is true, use wait-for-condition instead.` -} -func (t *EvaluateRuleTool) InputSchema() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "predicate": map[string]interface{}{ - "type": "string", - "description": "Predicate name to fetch (e.g., test_passed)", - }, - }, - "required": []string{"predicate"}, - } -} -func (t *EvaluateRuleTool) Execute(ctx context.Context, args map[string]interface{}) (interface{}, error) { - predicate := getStringArg(args, "predicate") - if predicate == "" { - return nil, fmt.Errorf("predicate is required") - } - - facts, err := t.engine.Evaluate(ctx, predicate) - if err != nil { - return nil, err - } - return map[string]interface{}{ - "predicate": predicate, - "facts": facts, - "count": len(facts), - }, nil -} - -// SubscribeRuleTool subscribes to watch for derived facts from a predicate (Watch Mode - PRD 5.2). -type SubscribeRuleTool struct { - engine *mangle.Engine -} - -func (t *SubscribeRuleTool) Name() string { return "subscribe-rule" } -func (t *SubscribeRuleTool) Description() string { - return `Block until a predicate derives facts, then return immediately. - -USE INSTEAD OF wait-for-condition when: -- You need push-based notification (vs polling) -- Watching for rule derivations in real-time -- Building reactive automation flows - -WORKFLOW: -1. submit-rule("error_detected() :- console_event(\"error\", _, _).") -2. subscribe-rule("error_detected", timeout_ms: 30000) -3. Tool blocks until an error occurs or timeout - -Returns on trigger: {status: "triggered", predicate, facts, count} -Returns on timeout: {status: "timeout", predicate} - -PREFER wait-for-condition for most use cases - it's more flexible. -Use subscribe-rule for long-running watches or reactive patterns.` -} -func (t *SubscribeRuleTool) InputSchema() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "predicate": map[string]interface{}{ - "type": "string", - "description": "Predicate name to watch (e.g., test_passed, slow_api)", - }, - "timeout_ms": map[string]interface{}{ - "type": "integer", - "description": "Timeout in milliseconds (default 30000)", - }, - }, - "required": []string{"predicate"}, - } -} -func (t *SubscribeRuleTool) Execute(ctx context.Context, args map[string]interface{}) (interface{}, error) { - predicate := getStringArg(args, "predicate") - if predicate == "" { - return nil, fmt.Errorf("predicate is required") - } - - timeout := time.Duration(getIntArg(args, "timeout_ms", 30000)) * time.Millisecond - if timeout <= 0 { - timeout = 30 * time.Second - } - - // Create subscription channel - ch := make(chan mangle.WatchEvent, 10) - _ = t.engine.Subscribe(predicate, ch) - defer t.engine.Unsubscribe(predicate, ch) - - // Wait for event or timeout - select { - case event := <-ch: - return map[string]interface{}{ - "status": "triggered", - "predicate": event.Predicate, - "facts": event.Facts, - "count": len(event.Facts), - "timestamp": event.Timestamp.UnixMilli(), - }, nil - case <-time.After(timeout): - return map[string]interface{}{ - "status": "timeout", - "predicate": predicate, - }, nil - case <-ctx.Done(): - return nil, ctx.Err() - } -} - -// QueryTemporalTool queries facts within a time window. -type QueryTemporalTool struct { - engine *mangle.Engine -} - -func (t *QueryTemporalTool) Name() string { return "query-temporal" } -func (t *QueryTemporalTool) Description() string { - return `Query facts filtered by timestamp - find what happened in a time window. - -WHEN TO USE: -- "What happened in the last 5 seconds?" -- "What network requests occurred before this error?" -- Correlating events by time -- Debugging timing-sensitive issues - -EXAMPLES: -- query-temporal("net_request", after_ms: Date.now() - 5000) - -> Requests in last 5 seconds - -- query-temporal("console_event", before_ms: errorTimestamp) - -> Console events before an error occurred - -USE INSTEAD OF query-facts when time filtering matters. -Both after_ms and before_ms are epoch milliseconds.` -} -func (t *QueryTemporalTool) InputSchema() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "predicate": map[string]interface{}{ - "type": "string", - "description": "Predicate to filter", - }, - "after_ms": map[string]interface{}{ - "type": "integer", - "description": "Optional lower bound (epoch millis). Default: 0", - }, - "before_ms": map[string]interface{}{ - "type": "integer", - "description": "Optional upper bound (epoch millis). Default: now", - }, - }, - "required": []string{"predicate"}, - } -} -func (t *QueryTemporalTool) Execute(_ context.Context, args map[string]interface{}) (interface{}, error) { - predicate := getStringArg(args, "predicate") - if predicate == "" { - return nil, fmt.Errorf("predicate is required") - } - - afterMS := getIntArg(args, "after_ms", 0) - beforeMS := getIntArg(args, "before_ms", int(time.Now().UnixMilli())) - - after := time.UnixMilli(int64(afterMS)) - before := time.UnixMilli(int64(beforeMS)) - - facts := t.engine.QueryTemporal(predicate, after, before) - return map[string]interface{}{ - "predicate": predicate, - "count": len(facts), - "facts": facts, - }, nil -} - -// AwaitFactTool lets agents register simple assertions without full rule evaluation. -type AwaitFactTool struct { - engine *mangle.Engine -} - -func (t *AwaitFactTool) Name() string { return "await-fact" } -func (t *AwaitFactTool) Description() string { - return `Wait for a specific fact to appear (simple assertions without rules). - -USE INSTEAD OF wait-for-condition when: -- Waiting for a base fact, not a derived rule -- Simple predicate matching is sufficient -- You don't need complex conditions - -EXAMPLES: -- await-fact("navigation_event") -> Wait for ANY navigation -- await-fact("navigation_event", args: ["session-1", "/dashboard"]) - -> Wait for specific navigation - -COMPARISON: -- await-fact: Simple, direct fact matching -- await-conditions: Multiple facts (AND logic) -- wait-for-condition: Derived rules, wildcards, complex logic - -Returns: {predicate, status: "passed"|"timeout"}` -} -func (t *AwaitFactTool) InputSchema() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "predicate": map[string]interface{}{ - "type": "string", - "description": "Predicate name to watch for", - }, - "args": map[string]interface{}{ - "type": "array", - "description": "Optional positional args to match exactly", - "items": map[string]interface{}{"type": "string"}, - }, - "timeout_ms": map[string]interface{}{ - "type": "integer", - "description": "Optional timeout in milliseconds (default 5000)", - }, - }, - "required": []string{"predicate"}, - } -} -func (t *AwaitFactTool) Execute(ctx context.Context, args map[string]interface{}) (interface{}, error) { - predicate := getStringArg(args, "predicate") - if predicate == "" { - return nil, fmt.Errorf("predicate is required") - } - wantArgs, _ := args["args"].([]interface{}) - timeout := time.Duration(getIntArg(args, "timeout_ms", 5000)) * time.Millisecond - if timeout <= 0 { - timeout = 5 * time.Second - } - timeoutTimer := time.NewTimer(timeout) - defer timeoutTimer.Stop() - ticker := time.NewTicker(200 * time.Millisecond) - defer ticker.Stop() - for { - if matchFact(t.engine.FactsByPredicate(predicate), wantArgs) { - return map[string]interface{}{ - "predicate": predicate, - "status": "passed", - }, nil - } - - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-timeoutTimer.C: - return map[string]interface{}{ - "predicate": predicate, - "status": "timeout", - }, nil - case <-ticker.C: - } - } -} - -// AwaitConditionsTool waits until all provided predicate/arg tuples appear. -type AwaitConditionsTool struct { - engine *mangle.Engine -} - -func (t *AwaitConditionsTool) Name() string { return "await-conditions" } -func (t *AwaitConditionsTool) Description() string { - return `Wait until ALL specified conditions are true (logical AND). - -WHEN TO USE: -- Multiple facts must ALL exist before proceeding -- Verifying complex page state -- Waiting for multiple async operations to complete - -EXAMPLE: -await-conditions({ - conditions: [ - {predicate: "navigation_event", args: ["session-1", "/dashboard"]}, - {predicate: "dom_text", args: ["_", "Welcome"]} - ], - timeout_ms: 10000 -}) --> Waits for BOTH navigation AND welcome text - -COMPARISON: -- await-fact: Single fact -- await-conditions: Multiple facts, ALL must match (AND) -- wait-for-condition: Derived rules with complex logic - -Returns: {status: "passed"|"timeout", conditions: count}` -} -func (t *AwaitConditionsTool) InputSchema() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "conditions": map[string]interface{}{ - "type": "array", - "description": "List of {predicate, args?} conditions to satisfy", - "items": map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "predicate": map[string]interface{}{"type": "string"}, - "args": map[string]interface{}{ - "type": "array", - "items": map[string]interface{}{"type": "string"}, - }, - }, - "required": []string{"predicate"}, - }, - }, - "timeout_ms": map[string]interface{}{ - "type": "integer", - "description": "Timeout in milliseconds (default 8000)", - }, - }, - "required": []string{"conditions"}, - } -} -func (t *AwaitConditionsTool) Execute(ctx context.Context, args map[string]interface{}) (interface{}, error) { - rawConds, ok := args["conditions"].([]interface{}) - if !ok || len(rawConds) == 0 { - return nil, fmt.Errorf("conditions must be a non-empty array") - } - - conds := make([]mangle.Fact, 0, len(rawConds)) - for _, raw := range rawConds { - m, ok := raw.(map[string]interface{}) - if !ok { - continue - } - pred := getStringFromMap(m, "predicate") - if pred == "" { - continue - } - argList, _ := m["args"].([]interface{}) - conds = append(conds, mangle.Fact{Predicate: pred, Args: argList}) - } - - if len(conds) == 0 { - return nil, fmt.Errorf("no valid conditions provided") - } - - timeout := time.Duration(getIntArg(args, "timeout_ms", 8000)) * time.Millisecond - if timeout <= 0 { - timeout = 8 * time.Second - } - timeoutTimer := time.NewTimer(timeout) - defer timeoutTimer.Stop() - ticker := time.NewTicker(200 * time.Millisecond) - defer ticker.Stop() - - for { - if t.engine.MatchesAll(conds) { - return map[string]interface{}{ - "status": "passed", - "conditions": len(conds), - }, nil - } - - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-timeoutTimer.C: - return map[string]interface{}{ - "status": "timeout", - "conditions": len(conds), - }, nil - case <-ticker.C: - } - } -} +package mcp + +import ( + "context" + "fmt" + "strings" + "time" + + "browsernerd-mcp-server/internal/mangle" +) + +// PushFactsTool ingests arbitrary facts into the Mangle engine (useful for demos/tests). +type PushFactsTool struct { + engine *mangle.Engine +} + +func (t *PushFactsTool) Name() string { return "push-facts" } +func (t *PushFactsTool) Description() string { + return `Manually inject facts into the Mangle reasoning engine. + +WHEN TO USE: +- Testing Mangle rules without browser events +- Injecting external data for reasoning +- Setting up initial state for rule evaluation +- Debugging rule logic with known facts + +EXAMPLE: +push-facts({facts: [ + {predicate: "user_logged_in", args: ["session-1", "admin"]}, + {predicate: "page_loaded", args: ["session-1", "/dashboard"]} +]}) + +Then query with: query-facts("user_logged_in(Session, Role).") + +NOTE: Most facts are auto-generated by browser events. Use this for +manual injection or testing scenarios.` +} +func (t *PushFactsTool) InputSchema() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "facts": map[string]interface{}{ + "type": "array", + "description": "Array of facts {predicate, args}", + "items": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "predicate": map[string]interface{}{"type": "string"}, + "args": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "string"}, + }, + }, + "required": []string{"predicate"}, + }, + }, + }, + "required": []string{"facts"}, + } +} +func (t *PushFactsTool) Execute(ctx context.Context, args map[string]interface{}) (interface{}, error) { + rawFacts, ok := args["facts"].([]interface{}) + if !ok { + return nil, fmt.Errorf("facts must be an array") + } + + parsed := make([]mangle.Fact, 0, len(rawFacts)) + now := time.Now() + for _, raw := range rawFacts { + record, ok := raw.(map[string]interface{}) + if !ok { + continue + } + pred := getStringFromMap(record, "predicate") + if pred == "" { + continue + } + argsValue, _ := record["args"].([]interface{}) + parsed = append(parsed, mangle.Fact{ + Predicate: pred, + Args: argsValue, + Timestamp: now, + }) + } + + if len(parsed) == 0 { + return nil, fmt.Errorf("no valid facts provided") + } + + if err := t.engine.AddFacts(ctx, parsed); err != nil { + return nil, err + } + + return map[string]interface{}{ + "accepted": len(parsed), + "ready": t.engine.Ready(), + }, nil +} + +type ReadFactsTool struct { + engine *mangle.Engine +} + +func (t *ReadFactsTool) Name() string { return "read-facts" } +func (t *ReadFactsTool) Description() string { + return `Inspect the current fact buffer to see what the Mangle engine knows. + +WHEN TO USE: +- Debugging: See what facts have been captured +- Before writing rules: Understand available predicates +- After browser events: Verify facts were emitted +- Troubleshooting: Why isn't my rule matching? + +WORKFLOW TIP: +1. Perform browser actions (navigate, click, etc.) +2. read-facts to see what was captured +3. Write rules based on actual fact structure + +Returns most recent facts (default 25, adjustable with limit param). +Facts include: predicate name, arguments, and timestamp.` +} +func (t *ReadFactsTool) InputSchema() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "limit": map[string]interface{}{ + "type": "integer", + "description": "Maximum number of facts to return (default 25)", + }, + "predicate_filter": map[string]interface{}{ + "type": "string", + "description": "Optional predicate filter (e.g., net_request, toast_notification)", + }, + }, + } +} +func (t *ReadFactsTool) Execute(_ context.Context, args map[string]interface{}) (interface{}, error) { + limit := getIntArg(args, "limit", 25) + if limit <= 0 { + limit = 25 + } + + predicateFilter := strings.TrimSpace(getStringArg(args, "predicate_filter")) + facts := t.engine.Facts() + if predicateFilter != "" { + facts = t.engine.FactsByPredicate(predicateFilter) + } + if len(facts) > limit { + facts = facts[len(facts)-limit:] + } + result := map[string]interface{}{ + "count": len(facts), + "facts": facts, + } + if predicateFilter != "" { + result["predicate_filter"] = predicateFilter + } + return result, nil +} + +// QueryFactsTool executes a Mangle query string and returns variable bindings. +type QueryFactsTool struct { + engine *mangle.Engine +} + +func (t *QueryFactsTool) Name() string { return "query-facts" } +func (t *QueryFactsTool) Description() string { + return `Execute a Mangle query to find facts matching a pattern. + +WHEN TO USE: +- Finding specific facts by pattern +- Extracting values from the fact store +- Checking if conditions are met +- Ad-hoc fact exploration + +QUERY SYNTAX (Mangle/Datalog): +- Variables start with uppercase: Session, Url, Status +- Wildcards use underscore: _ +- Queries end with period: . + +EXAMPLES: +- "navigation_event(Session, Url, _)." -> All navigation events +- "console_event(\"error\", Msg, _)." -> All error messages +- "failed_request(_, Url, Status)." -> All failed requests + +Returns: Array of variable bindings for each match.` +} +func (t *QueryFactsTool) InputSchema() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "query": map[string]interface{}{ + "type": "string", + "description": "Mangle query string (e.g., caused_by(Error, ReqId).)", + }, + }, + "required": []string{"query"}, + } +} +func (t *QueryFactsTool) Execute(ctx context.Context, args map[string]interface{}) (interface{}, error) { + query := getStringArg(args, "query") + if query == "" { + return nil, fmt.Errorf("query is required") + } + query = strings.TrimSpace(query) + if !strings.HasSuffix(query, ".") { + query += "." + } + + results, err := t.engine.Query(ctx, query) + if err != nil { + return nil, err + } + return map[string]interface{}{ + "count": len(results), + "results": normalizeQueryBindings(results), + }, nil +} + +func normalizeQueryBindings(results []mangle.QueryResult) []map[string]interface{} { + normalized := make([]map[string]interface{}, 0, len(results)) + for _, row := range results { + out := make(map[string]interface{}, len(row)) + for k, v := range row { + if strings.HasPrefix(k, "__anon_") { + out["_"+strings.TrimPrefix(k, "__anon_")] = v + } else { + out[k] = v + } + } + normalized = append(normalized, out) + } + return normalized +} + +// SubmitRuleTool adds a new rule to the running Mangle program. +type SubmitRuleTool struct { + engine *mangle.Engine +} + +func (t *SubmitRuleTool) Name() string { return "submit-rule" } +func (t *SubmitRuleTool) Description() string { + return `Add a Mangle rule for deriving new facts from existing ones. + +WHEN TO USE: +- Defining custom conditions for wait-for-condition +- Creating derived facts for complex assertions +- Building causal reasoning chains +- Setting up execute-plan action sequences + +RULE SYNTAX: +derived_fact(Args) :- source_fact1(Args), source_fact2(Args). + +EXAMPLES: +- "login_success() :- navigation_event(_, \"/dashboard\", _)." +- "api_error(Url) :- failed_request(_, Url, Status), Status >= 500." +- "action(\"click\", \"submit-btn\") :- form_ready()." + +WORKFLOW: +1. submit-rule to define the condition +2. Use evaluate-rule or wait-for-condition with the predicate name + +Rules persist for the session and auto-evaluate as facts arrive.` +} +func (t *SubmitRuleTool) InputSchema() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "rule": map[string]interface{}{ + "type": "string", + "description": "Mangle rule source (e.g., test_passed() :- navigation_event(_, \"/dashboard\", _).)", + }, + }, + "required": []string{"rule"}, + } +} +func (t *SubmitRuleTool) Execute(_ context.Context, args map[string]interface{}) (interface{}, error) { + rule := getStringArg(args, "rule") + if rule == "" { + return nil, fmt.Errorf("rule is required") + } + if err := t.engine.AddRule(rule); err != nil { + return nil, err + } + return map[string]interface{}{ + "status": "ok", + }, nil +} + +// EvaluateRuleTool runs full program evaluation and returns derived facts for a predicate. +type EvaluateRuleTool struct { + engine *mangle.Engine +} + +func (t *EvaluateRuleTool) Name() string { return "evaluate-rule" } +func (t *EvaluateRuleTool) Description() string { + return `Check if a derived predicate has any matching facts RIGHT NOW. + +USE INSTEAD OF query-facts when: +- Checking if a rule's head predicate derived any facts +- Testing rule logic immediately (no waiting) +- Debugging why a rule isn't deriving expected facts + +WORKFLOW: +1. submit-rule("test_passed() :- navigation_event(_, \"/success\", _).") +2. navigate to /success page +3. evaluate-rule("test_passed") -> returns derived facts if rule matched + +Returns: {predicate, facts: [...], count} - empty facts array if no match. + +For WAITING until a condition is true, use wait-for-condition instead.` +} +func (t *EvaluateRuleTool) InputSchema() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "predicate": map[string]interface{}{ + "type": "string", + "description": "Predicate name to fetch (e.g., test_passed)", + }, + }, + "required": []string{"predicate"}, + } +} +func (t *EvaluateRuleTool) Execute(ctx context.Context, args map[string]interface{}) (interface{}, error) { + predicate := getStringArg(args, "predicate") + if predicate == "" { + return nil, fmt.Errorf("predicate is required") + } + + facts, err := t.engine.Evaluate(ctx, predicate) + if err != nil { + return nil, err + } + return map[string]interface{}{ + "predicate": predicate, + "facts": facts, + "count": len(facts), + }, nil +} + +// SubscribeRuleTool subscribes to watch for derived facts from a predicate (Watch Mode - PRD 5.2). +type SubscribeRuleTool struct { + engine *mangle.Engine +} + +func (t *SubscribeRuleTool) Name() string { return "subscribe-rule" } +func (t *SubscribeRuleTool) Description() string { + return `Block until a predicate derives facts, then return immediately. + +USE INSTEAD OF wait-for-condition when: +- You need push-based notification (vs polling) +- Watching for rule derivations in real-time +- Building reactive automation flows + +WORKFLOW: +1. submit-rule("error_detected() :- console_event(\"error\", _, _).") +2. subscribe-rule("error_detected", timeout_ms: 30000) +3. Tool blocks until an error occurs or timeout + +Returns on trigger: {status: "triggered", predicate, facts, count} +Returns on timeout: {status: "timeout", predicate} + +PREFER wait-for-condition for most use cases - it's more flexible. +Use subscribe-rule for long-running watches or reactive patterns.` +} +func (t *SubscribeRuleTool) InputSchema() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "predicate": map[string]interface{}{ + "type": "string", + "description": "Predicate name to watch (e.g., test_passed, slow_api)", + }, + "timeout_ms": map[string]interface{}{ + "type": "integer", + "description": "Timeout in milliseconds (default 30000)", + }, + }, + "required": []string{"predicate"}, + } +} +func (t *SubscribeRuleTool) Execute(ctx context.Context, args map[string]interface{}) (interface{}, error) { + predicate := getStringArg(args, "predicate") + if predicate == "" { + return nil, fmt.Errorf("predicate is required") + } + + timeout := time.Duration(getIntArg(args, "timeout_ms", 30000)) * time.Millisecond + if timeout <= 0 { + timeout = 30 * time.Second + } + + // Create subscription channel + ch := make(chan mangle.WatchEvent, 10) + _ = t.engine.Subscribe(predicate, ch) + defer t.engine.Unsubscribe(predicate, ch) + + // Wait for event or timeout + timer := time.NewTimer(timeout) + defer timer.Stop() + + select { + case event := <-ch: + return map[string]interface{}{ + "status": "triggered", + "predicate": event.Predicate, + "facts": event.Facts, + "count": len(event.Facts), + "timestamp": event.Timestamp.UnixMilli(), + }, nil + case <-timer.C: + return map[string]interface{}{ + "status": "timeout", + "predicate": predicate, + }, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// QueryTemporalTool queries facts within a time window. +type QueryTemporalTool struct { + engine *mangle.Engine +} + +func (t *QueryTemporalTool) Name() string { return "query-temporal" } +func (t *QueryTemporalTool) Description() string { + return `Query facts filtered by timestamp - find what happened in a time window. + +WHEN TO USE: +- "What happened in the last 5 seconds?" +- "What network requests occurred before this error?" +- Correlating events by time +- Debugging timing-sensitive issues + +EXAMPLES: +- query-temporal("net_request", after_ms: Date.now() - 5000) + -> Requests in last 5 seconds + +- query-temporal("console_event", before_ms: errorTimestamp) + -> Console events before an error occurred + +USE INSTEAD OF query-facts when time filtering matters. +Both after_ms and before_ms are epoch milliseconds.` +} +func (t *QueryTemporalTool) InputSchema() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "predicate": map[string]interface{}{ + "type": "string", + "description": "Predicate to filter", + }, + "after_ms": map[string]interface{}{ + "type": "integer", + "description": "Optional lower bound (epoch millis). Default: 0", + }, + "before_ms": map[string]interface{}{ + "type": "integer", + "description": "Optional upper bound (epoch millis). Default: now", + }, + }, + "required": []string{"predicate"}, + } +} +func (t *QueryTemporalTool) Execute(_ context.Context, args map[string]interface{}) (interface{}, error) { + predicate := getStringArg(args, "predicate") + if predicate == "" { + return nil, fmt.Errorf("predicate is required") + } + + afterMS := getIntArg(args, "after_ms", 0) + beforeMS := getIntArg(args, "before_ms", int(time.Now().UnixMilli())) + + after := time.UnixMilli(int64(afterMS)) + before := time.UnixMilli(int64(beforeMS)) + + facts := t.engine.QueryTemporal(predicate, after, before) + return map[string]interface{}{ + "predicate": predicate, + "count": len(facts), + "facts": facts, + }, nil +} + +// AwaitFactTool lets agents register simple assertions without full rule evaluation. +type AwaitFactTool struct { + engine *mangle.Engine +} + +func (t *AwaitFactTool) Name() string { return "await-fact" } +func (t *AwaitFactTool) Description() string { + return `Wait for a specific fact to appear (simple assertions without rules). + +USE INSTEAD OF wait-for-condition when: +- Waiting for a base fact, not a derived rule +- Simple predicate matching is sufficient +- You don't need complex conditions + +EXAMPLES: +- await-fact("navigation_event") -> Wait for ANY navigation +- await-fact("navigation_event", args: ["session-1", "/dashboard"]) + -> Wait for specific navigation + +COMPARISON: +- await-fact: Simple, direct fact matching +- await-conditions: Multiple facts (AND logic) +- wait-for-condition: Derived rules, wildcards, complex logic + +Returns: {predicate, status: "passed"|"timeout"}` +} +func (t *AwaitFactTool) InputSchema() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "predicate": map[string]interface{}{ + "type": "string", + "description": "Predicate name to watch for", + }, + "args": map[string]interface{}{ + "type": "array", + "description": "Optional positional args to match exactly", + "items": map[string]interface{}{"type": "string"}, + }, + "timeout_ms": map[string]interface{}{ + "type": "integer", + "description": "Optional timeout in milliseconds (default 5000)", + }, + }, + "required": []string{"predicate"}, + } +} +func (t *AwaitFactTool) Execute(ctx context.Context, args map[string]interface{}) (interface{}, error) { + predicate := getStringArg(args, "predicate") + if predicate == "" { + return nil, fmt.Errorf("predicate is required") + } + wantArgs, _ := args["args"].([]interface{}) + timeout := time.Duration(getIntArg(args, "timeout_ms", 5000)) * time.Millisecond + if timeout <= 0 { + timeout = 5 * time.Second + } + timeoutTimer := time.NewTimer(timeout) + defer timeoutTimer.Stop() + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + for { + if matchFact(t.engine.FactsByPredicate(predicate), wantArgs) { + return map[string]interface{}{ + "predicate": predicate, + "status": "passed", + }, nil + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-timeoutTimer.C: + return map[string]interface{}{ + "predicate": predicate, + "status": "timeout", + }, nil + case <-ticker.C: + } + } +} + +// AwaitConditionsTool waits until all provided predicate/arg tuples appear. +type AwaitConditionsTool struct { + engine *mangle.Engine +} + +func (t *AwaitConditionsTool) Name() string { return "await-conditions" } +func (t *AwaitConditionsTool) Description() string { + return `Wait until ALL specified conditions are true (logical AND). + +WHEN TO USE: +- Multiple facts must ALL exist before proceeding +- Verifying complex page state +- Waiting for multiple async operations to complete + +EXAMPLE: +await-conditions({ + conditions: [ + {predicate: "navigation_event", args: ["session-1", "/dashboard"]}, + {predicate: "dom_text", args: ["_", "Welcome"]} + ], + timeout_ms: 10000 +}) +-> Waits for BOTH navigation AND welcome text + +COMPARISON: +- await-fact: Single fact +- await-conditions: Multiple facts, ALL must match (AND) +- wait-for-condition: Derived rules with complex logic + +Returns: {status: "passed"|"timeout", conditions: count}` +} +func (t *AwaitConditionsTool) InputSchema() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "conditions": map[string]interface{}{ + "type": "array", + "description": "List of {predicate, args?} conditions to satisfy", + "items": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "predicate": map[string]interface{}{"type": "string"}, + "args": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "string"}, + }, + }, + "required": []string{"predicate"}, + }, + }, + "timeout_ms": map[string]interface{}{ + "type": "integer", + "description": "Timeout in milliseconds (default 8000)", + }, + }, + "required": []string{"conditions"}, + } +} +func (t *AwaitConditionsTool) Execute(ctx context.Context, args map[string]interface{}) (interface{}, error) { + rawConds, ok := args["conditions"].([]interface{}) + if !ok || len(rawConds) == 0 { + return nil, fmt.Errorf("conditions must be a non-empty array") + } + + conds := make([]mangle.Fact, 0, len(rawConds)) + for _, raw := range rawConds { + m, ok := raw.(map[string]interface{}) + if !ok { + continue + } + pred := getStringFromMap(m, "predicate") + if pred == "" { + continue + } + argList, _ := m["args"].([]interface{}) + conds = append(conds, mangle.Fact{Predicate: pred, Args: argList}) + } + + if len(conds) == 0 { + return nil, fmt.Errorf("no valid conditions provided") + } + + timeout := time.Duration(getIntArg(args, "timeout_ms", 8000)) * time.Millisecond + if timeout <= 0 { + timeout = 8 * time.Second + } + timeoutTimer := time.NewTimer(timeout) + defer timeoutTimer.Stop() + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + + for { + if t.engine.MatchesAll(conds) { + return map[string]interface{}{ + "status": "passed", + "conditions": len(conds), + }, nil + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-timeoutTimer.C: + return map[string]interface{}{ + "status": "timeout", + "conditions": len(conds), + }, nil + case <-ticker.C: + } + } +} diff --git a/mcp-server/internal/mcp/progressive_helpers_test.go b/mcp-server/internal/mcp/progressive_helpers_test.go new file mode 100644 index 0000000..88faa49 --- /dev/null +++ b/mcp-server/internal/mcp/progressive_helpers_test.go @@ -0,0 +1,1285 @@ +package mcp + +import ( + "strconv" + "strings" + "testing" +) + +// --------------------------------------------------------------------------- +// asInt +// --------------------------------------------------------------------------- + +func TestAsInt(t *testing.T) { + tests := []struct { + name string + in interface{} + want int + }{ + {"int", 42, 42}, + {"int8", int8(7), 7}, + {"int16", int16(300), 300}, + {"int32", int32(100000), 100000}, + {"int64", int64(9999999), 9999999}, + {"float32", float32(3.9), 3}, + {"float64", float64(7.7), 7}, + {"string integer", "42", 42}, + {"string float", "3.14", 3}, + {"empty string", "", 0}, + {"nil", nil, 0}, + {"bool true", true, 0}, + {"bool false", false, 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := asInt(tt.in) + if got != tt.want { + t.Errorf("asInt(%v) = %d, want %d", tt.in, got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// asInt64 +// --------------------------------------------------------------------------- + +func TestAsInt64(t *testing.T) { + tests := []struct { + name string + in interface{} + want int64 + }{ + {"int", 42, 42}, + {"int8", int8(7), 7}, + {"int16", int16(300), 300}, + {"int32", int32(100000), 100000}, + {"int64", int64(9999999), 9999999}, + {"float32", float32(3.9), 3}, + {"float64", float64(7.7), 7}, + {"string integer", "42", 42}, + {"string float", "3.14", 3}, + {"empty string", "", 0}, + {"nil", nil, 0}, + {"bool true", true, 0}, + {"bool false", false, 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := asInt64(tt.in) + if got != tt.want { + t.Errorf("asInt64(%v) = %d, want %d", tt.in, got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// buildObserveSummary +// --------------------------------------------------------------------------- + +func TestBuildObserveSummary(t *testing.T) { + tests := []struct { + name string + data map[string]interface{} + contains []string // each must appear in result + excludes []string // each must NOT appear in result + exact string // if non-empty, exact match + }{ + { + name: "empty map", + data: map[string]interface{}{}, + exact: "observation complete", + }, + { + name: "state loading true", + data: map[string]interface{}{ + "state": map[string]interface{}{"loading": true}, + }, + contains: []string{"loading=true"}, + }, + { + name: "diagnostics error", + data: map[string]interface{}{ + "diagnostics": map[string]interface{}{"status": "error"}, + }, + contains: []string{"diag=error"}, + }, + { + name: "diagnostics ok excluded", + data: map[string]interface{}{ + "diagnostics": map[string]interface{}{"status": "ok"}, + }, + exact: "observation complete", + }, + { + name: "toasts error_count", + data: map[string]interface{}{ + "toasts": map[string]interface{}{"error_count": 3}, + }, + contains: []string{"toast_err=3"}, + }, + { + name: "nav_counts total", + data: map[string]interface{}{ + "nav_counts": map[string]interface{}{"total": 15}, + }, + contains: []string{"links=15"}, + }, + { + name: "nav nested counts", + data: map[string]interface{}{ + "nav": map[string]interface{}{ + "counts": map[string]interface{}{"total": 9}, + }, + }, + contains: []string{"links="}, + }, + { + name: "interactive_summary total", + data: map[string]interface{}{ + "interactive_summary": map[string]interface{}{"total": 8}, + }, + contains: []string{"interactive=8"}, + }, + { + name: "interactive nested summary", + data: map[string]interface{}{ + "interactive": map[string]interface{}{ + "summary": map[string]interface{}{"total": 12}, + }, + }, + contains: []string{"interactive="}, + }, + { + name: "action_candidate_count", + data: map[string]interface{}{ + "action_candidate_count": 5, + }, + contains: []string{"candidates=5"}, + }, + { + name: "action_candidates as []map", + data: map[string]interface{}{ + "action_candidates": []map[string]interface{}{ + {"action": "click"}, + {"action": "type"}, + }, + }, + contains: []string{"candidates="}, + }, + { + name: "full composite", + data: map[string]interface{}{ + "state": map[string]interface{}{"loading": false}, + "diagnostics": map[string]interface{}{"status": "warning"}, + "toasts": map[string]interface{}{"error_count": 1}, + "nav_counts": map[string]interface{}{"total": 10}, + "interactive_summary": map[string]interface{}{"total": 5}, + }, + contains: []string{"loading=false", "diag=warning", "toast_err=1", "links=10", "interactive=5"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildObserveSummary(tt.data) + if tt.exact != "" { + if got != tt.exact { + t.Fatalf("buildObserveSummary() = %q, want exact %q", got, tt.exact) + } + return + } + for _, c := range tt.contains { + if !strings.Contains(got, c) { + t.Errorf("buildObserveSummary() = %q, expected to contain %q", got, c) + } + } + for _, e := range tt.excludes { + if strings.Contains(got, e) { + t.Errorf("buildObserveSummary() = %q, expected NOT to contain %q", got, e) + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// buildMangleSummary +// --------------------------------------------------------------------------- + +func TestBuildMangleSummary(t *testing.T) { + tests := []struct { + name string + op string + data map[string]interface{} + want string + }{ + { + name: "query with []map results", + op: "query", + data: map[string]interface{}{ + "results": []map[string]interface{}{{"a": 1}, {"b": 2}}, + }, + want: "query returned 2 result(s)", + }, + { + name: "query with []interface results", + op: "query", + data: map[string]interface{}{ + "results": []interface{}{"x", "y", "z"}, + }, + want: "query returned 3 result(s)", + }, + { + name: "query no results", + op: "query", + data: map[string]interface{}{}, + want: "query completed", + }, + { + name: "read with facts", + op: "read", + data: map[string]interface{}{ + "facts": []interface{}{"f1", "f2"}, + }, + want: "read 2 fact(s)", + }, + { + name: "read with count", + op: "read", + data: map[string]interface{}{ + "count": 7, + }, + want: "read 7 fact(s)", + }, + { + name: "push accepted 5", + op: "push", + data: map[string]interface{}{ + "accepted": 5, + }, + want: "pushed 5 fact(s)", + }, + { + name: "submit_rule success", + op: "submit_rule", + data: map[string]interface{}{ + "success": true, + }, + want: "rule submitted", + }, + { + name: "submit_rule failure", + op: "submit_rule", + data: map[string]interface{}{ + "success": false, + }, + want: "rule submission failed", + }, + { + name: "evaluate with results", + op: "evaluate", + data: map[string]interface{}{ + "results": []interface{}{1, 2, 3}, + }, + want: "evaluated 3 result(s)", + }, + { + name: "temporal with results", + op: "temporal", + data: map[string]interface{}{ + "results": []interface{}{"a"}, + }, + want: "temporal query returned 1 result(s)", + }, + { + name: "subscribe matched", + op: "subscribe", + data: map[string]interface{}{ + "matched": true, + }, + want: "subscription matched", + }, + { + name: "subscribe not matched", + op: "subscribe", + data: map[string]interface{}{}, + want: "subscription completed", + }, + { + name: "await_fact matched", + op: "await_fact", + data: map[string]interface{}{ + "matched": true, + }, + want: "fact matched", + }, + { + name: "await_fact not matched", + op: "await_fact", + data: map[string]interface{}{}, + want: "await completed", + }, + { + name: "await_conditions all_matched", + op: "await_conditions", + data: map[string]interface{}{ + "all_matched": true, + }, + want: "all conditions matched", + }, + { + name: "await_conditions not matched", + op: "await_conditions", + data: map[string]interface{}{}, + want: "await conditions completed", + }, + { + name: "unknown operation", + op: "unknown_op", + data: map[string]interface{}{}, + want: "unknown_op completed", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildMangleSummary(tt.op, tt.data) + if got != tt.want { + t.Errorf("buildMangleSummary(%q, ...) = %q, want %q", tt.op, got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// suggestObserveNextStep +// --------------------------------------------------------------------------- + +func TestSuggestObserveNextStep(t *testing.T) { + sid := "test-session" + + tests := []struct { + name string + data map[string]interface{} + mode string + view string + recommendations []map[string]interface{} + wantTool string + wantContains string // substring in args + }{ + { + name: "loading true suggests await_stable", + data: map[string]interface{}{ + "state": map[string]interface{}{"loading": true}, + }, + wantTool: "browser-act", + wantContains: "await_stable", + }, + { + name: "diagnostics error suggests why_failed", + data: map[string]interface{}{ + "diagnostics": map[string]interface{}{"status": "error"}, + }, + wantTool: "browser-reason", + wantContains: "why_failed", + }, + { + name: "diagnostics warning suggests health", + data: map[string]interface{}{ + "diagnostics": map[string]interface{}{"status": "warning"}, + }, + wantTool: "browser-reason", + wantContains: "health", + }, + { + name: "recommendations used first", + data: map[string]interface{}{}, + recommendations: []map[string]interface{}{ + { + "tool": "browser-act", + "args": map[string]interface{}{"operations": []string{"click"}}, + "reason": "do it", + }, + }, + wantTool: "browser-act", + }, + { + name: "interactive total > 0 suggests next_best_action", + data: map[string]interface{}{ + "interactive": map[string]interface{}{ + "summary": map[string]interface{}{"total": 5}, + }, + }, + wantTool: "browser-reason", + wantContains: "next_best_action", + }, + { + name: "interactive total 0 suggests hidden mode", + data: map[string]interface{}{ + "interactive": map[string]interface{}{ + "summary": map[string]interface{}{"total": 0}, + }, + }, + wantTool: "browser-observe", + wantContains: "hidden", + }, + { + name: "interactive_summary total > 0", + data: map[string]interface{}{ + "interactive_summary": map[string]interface{}{"total": 3}, + }, + wantTool: "browser-reason", + wantContains: "next_best_action", + }, + { + name: "interactive_summary total 0", + data: map[string]interface{}{ + "interactive_summary": map[string]interface{}{"total": 0}, + }, + wantTool: "browser-observe", + wantContains: "hidden", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := suggestObserveNextStep(sid, tt.data, tt.mode, tt.view, tt.recommendations) + if got == nil { + t.Fatal("suggestObserveNextStep returned nil") + } + tool, _ := got["tool"].(string) + if tool != tt.wantTool { + t.Errorf("tool = %q, want %q", tool, tt.wantTool) + } + if tt.wantContains != "" { + args, _ := got["args"].(map[string]interface{}) + if !argsContains(args, tt.wantContains) { + t.Errorf("args %v does not contain %q", args, tt.wantContains) + } + } + }) + } +} + +// argsContains checks whether the serialized args map contains the given substring. +func argsContains(args map[string]interface{}, substr string) bool { + // Walk keys and string values for a simple substring check. + for k, v := range args { + if strings.Contains(strings.ToLower(k), substr) { + return true + } + switch val := v.(type) { + case string: + if strings.Contains(strings.ToLower(val), substr) { + return true + } + case []map[string]interface{}: + for _, inner := range val { + for _, iv := range inner { + if s, ok := iv.(string); ok && strings.Contains(strings.ToLower(s), substr) { + return true + } + } + } + } + } + return false +} + +// --------------------------------------------------------------------------- +// compactInteractiveData +// --------------------------------------------------------------------------- + +func TestCompactInteractiveData(t *testing.T) { + tests := []struct { + name string + data map[string]interface{} + maxItems int + wantTrunc bool + wantElemLen int + wantSummary bool + }{ + { + name: "elements within limit", + data: map[string]interface{}{ + "summary": "ok", + "elements": []interface{}{"a", "b"}, + }, + maxItems: 5, + wantTrunc: false, + wantElemLen: 2, + wantSummary: true, + }, + { + name: "elements exceed limit", + data: map[string]interface{}{ + "summary": "big", + "elements": []interface{}{"a", "b", "c", "d", "e"}, + }, + maxItems: 3, + wantTrunc: true, + wantElemLen: 3, + wantSummary: true, + }, + { + name: "no elements", + data: map[string]interface{}{}, + maxItems: 5, + wantTrunc: false, + wantElemLen: -1, // no "elements" key + wantSummary: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := compactInteractiveData(tt.data, tt.maxItems) + if tt.wantSummary { + if _, ok := got["summary"]; !ok { + t.Error("expected summary key") + } + } + if tt.wantElemLen >= 0 { + elems, ok := got["elements"].([]interface{}) + if !ok { + t.Fatalf("expected elements as []interface{}") + } + if len(elems) != tt.wantElemLen { + t.Errorf("elements len = %d, want %d", len(elems), tt.wantElemLen) + } + trunc, _ := got["truncated"].(bool) + if trunc != tt.wantTrunc { + t.Errorf("truncated = %v, want %v", trunc, tt.wantTrunc) + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// compactHiddenData +// --------------------------------------------------------------------------- + +func TestCompactHiddenData(t *testing.T) { + tests := []struct { + name string + data map[string]interface{} + maxItems int + wantCount int + wantTrunc bool + }{ + { + name: "with hidden_elements and summary", + data: map[string]interface{}{ + "hidden_elements": []interface{}{"a", "b", "c"}, + "summary": "3 hidden", + }, + maxItems: 2, + wantCount: 3, + wantTrunc: true, + }, + { + name: "within limit", + data: map[string]interface{}{ + "hidden_elements": []interface{}{"a"}, + }, + maxItems: 5, + wantCount: 1, + wantTrunc: false, + }, + { + name: "empty data", + data: map[string]interface{}{}, + maxItems: 5, + wantCount: -1, + wantTrunc: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := compactHiddenData(tt.data, tt.maxItems) + if tt.wantCount >= 0 { + count, _ := got["count"].(int) + if count != tt.wantCount { + t.Errorf("count = %d, want %d", count, tt.wantCount) + } + trunc, _ := got["truncated"].(bool) + if trunc != tt.wantTrunc { + t.Errorf("truncated = %v, want %v", trunc, tt.wantTrunc) + } + } + if _, hasSummary := tt.data["summary"]; hasSummary { + if _, ok := got["summary"]; !ok { + t.Error("expected summary to be preserved") + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// compactToastData +// --------------------------------------------------------------------------- + +func TestCompactToastData(t *testing.T) { + tests := []struct { + name string + data map[string]interface{} + maxItems int + wantStatus bool + wantSummary bool + wantErrCount bool + wantToastCount int + wantRepeated bool + }{ + { + name: "status and summary preserved", + data: map[string]interface{}{ + "status": "active", + "summary": "2 toasts", + }, + maxItems: 5, + wantStatus: true, + wantSummary: true, + }, + { + name: "error_count preserved", + data: map[string]interface{}{ + "error_count": 3, + }, + maxItems: 5, + wantErrCount: true, + }, + { + name: "toasts as []map", + data: map[string]interface{}{ + "toasts": []map[string]interface{}{ + {"text": "a"}, {"text": "b"}, {"text": "c"}, + {"text": "d"}, {"text": "e"}, {"text": "f"}, + }, + }, + maxItems: 3, + wantToastCount: 6, + }, + { + name: "toasts as []interface", + data: map[string]interface{}{ + "toasts": []interface{}{ + map[string]interface{}{"text": "a"}, + map[string]interface{}{"text": "b"}, + }, + }, + maxItems: 5, + wantToastCount: 2, + }, + { + name: "repeated_errors", + data: map[string]interface{}{ + "repeated_errors": []interface{}{"err1", "err2"}, + }, + maxItems: 5, + wantRepeated: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := compactToastData(tt.data, tt.maxItems) + if tt.wantStatus { + if _, ok := got["status"]; !ok { + t.Error("expected status key") + } + } + if tt.wantSummary { + if _, ok := got["summary"]; !ok { + t.Error("expected summary key") + } + } + if tt.wantErrCount { + if _, ok := got["error_count"]; !ok { + t.Error("expected error_count key") + } + } + if tt.wantToastCount > 0 { + tc, _ := got["toast_count"].(int) + if tc != tt.wantToastCount { + t.Errorf("toast_count = %d, want %d", tc, tt.wantToastCount) + } + } + if tt.wantRepeated { + if _, ok := got["repeated_errors"]; !ok { + t.Error("expected repeated_errors key") + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// countHiddenElements +// --------------------------------------------------------------------------- + +func TestCountHiddenElements(t *testing.T) { + tests := []struct { + name string + data map[string]interface{} + want int + }{ + {"nil map", nil, 0}, + {"empty map", map[string]interface{}{}, 0}, + {"with elements", map[string]interface{}{ + "hidden_elements": []interface{}{"a", "b", "c"}, + }, 3}, + {"wrong type", map[string]interface{}{ + "hidden_elements": "not a slice", + }, 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.data == nil { + tt.data = map[string]interface{}{} + } + got := countHiddenElements(tt.data) + if got != tt.want { + t.Errorf("countHiddenElements() = %d, want %d", got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// severityLabel +// --------------------------------------------------------------------------- + +func TestSeverityLabel(t *testing.T) { + tests := []struct { + score int + want string + }{ + {95, "critical"}, + {90, "critical"}, + {80, "high"}, + {75, "high"}, + {60, "medium"}, + {55, "medium"}, + {30, "low"}, + {0, "low"}, + } + for _, tt := range tests { + t.Run(strconv.Itoa(tt.score), func(t *testing.T) { + got := severityLabel(tt.score) + if got != tt.want { + t.Errorf("severityLabel(%d) = %q, want %q", tt.score, got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// ternaryStatus +// --------------------------------------------------------------------------- + +func TestTernaryStatus(t *testing.T) { + if got := ternaryStatus(true, "yes", "no"); got != "yes" { + t.Errorf("ternaryStatus(true) = %q, want yes", got) + } + if got := ternaryStatus(false, "yes", "no"); got != "no" { + t.Errorf("ternaryStatus(false) = %q, want no", got) + } +} + +// --------------------------------------------------------------------------- +// suggestInputValue +// --------------------------------------------------------------------------- + +func TestSuggestInputValue(t *testing.T) { + tests := []struct { + label string + want string + }{ + {"Email Address", "user@example.com"}, + {"email", "user@example.com"}, + {"Password", ""}, + {"Phone Number", ""}, + {"Full Name", ""}, + {"other_field", ""}, + {"description", ""}, + } + for _, tt := range tests { + t.Run(tt.label, func(t *testing.T) { + got := suggestInputValue(tt.label) + if got != tt.want { + t.Errorf("suggestInputValue(%q) = %q, want %q", tt.label, got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// toMapSlice +// --------------------------------------------------------------------------- + +func TestToMapSlice(t *testing.T) { + tests := []struct { + name string + in interface{} + wantLen int + }{ + {"nil", nil, 0}, + {"typed []map", []map[string]interface{}{{"a": 1}}, 1}, + {"[]interface with maps", []interface{}{ + map[string]interface{}{"x": 1}, + map[string]interface{}{"y": 2}, + }, 2}, + {"[]interface mixed", []interface{}{ + map[string]interface{}{"x": 1}, + "not a map", + }, 1}, + {"non-slice", "hello", 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := toMapSlice(tt.in) + if len(got) != tt.wantLen { + t.Errorf("toMapSlice() len = %d, want %d", len(got), tt.wantLen) + } + }) + } +} + +// --------------------------------------------------------------------------- +// toStringSlice +// --------------------------------------------------------------------------- + +func TestToStringSlice(t *testing.T) { + tests := []struct { + name string + in interface{} + wantLen int + }{ + {"nil", nil, 0}, + {"typed []string", []string{"a", "b"}, 2}, + {"[]interface with strings", []interface{}{"x", "y"}, 2}, + {"[]interface with empty", []interface{}{"x", ""}, 1}, // empty trimmed out + {"non-slice", 42, 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := toStringSlice(tt.in) + if len(got) != tt.wantLen { + t.Errorf("toStringSlice() len = %d, want %d", len(got), tt.wantLen) + } + }) + } +} + +// --------------------------------------------------------------------------- +// extractTimestamp +// --------------------------------------------------------------------------- + +func TestExtractTimestamp(t *testing.T) { + tests := []struct { + name string + row map[string]interface{} + keys []string + want int64 + }{ + { + name: "key present", + row: map[string]interface{}{"ts": int64(1000)}, + keys: []string{"ts"}, + want: 1000, + }, + { + name: "key missing", + row: map[string]interface{}{"other": 1}, + keys: []string{"ts"}, + want: 0, + }, + { + name: "fallback key", + row: map[string]interface{}{"ts2": int64(500)}, + keys: []string{"ts1", "ts2"}, + want: 500, + }, + { + name: "first matching key wins", + row: map[string]interface{}{"ts1": int64(100), "ts2": int64(200)}, + keys: []string{"ts1", "ts2"}, + want: 100, + }, + { + name: "zero value skipped", + row: map[string]interface{}{"ts1": int64(0), "ts2": int64(300)}, + keys: []string{"ts1", "ts2"}, + want: 300, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractTimestamp(tt.row, tt.keys...) + if got != tt.want { + t.Errorf("extractTimestamp() = %d, want %d", got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// filterInteractiveData +// --------------------------------------------------------------------------- + +func TestFilterInteractiveData(t *testing.T) { + mkElem := func(typ string) interface{} { + return map[string]interface{}{"type": typ, "ref": "r_" + typ} + } + allElems := []interface{}{ + mkElem("button"), mkElem("input"), mkElem("link"), + mkElem("select"), mkElem("checkbox"), mkElem("radio"), + } + base := map[string]interface{}{ + "elements": allElems, + "summary": map[string]interface{}{"total": 6}, + "extra": "kept", + } + + tests := []struct { + name string + filter string + wantCount int + returnsAll bool // if true, result should equal input + }{ + {"empty filter", "", -1, true}, + {"all filter", "all", -1, true}, + {"unknown filter", "widgets", -1, true}, + {"buttons", "buttons", 1, false}, + {"inputs", "inputs", 3, false}, // input + checkbox + radio + {"links", "links", 1, false}, + {"selects", "selects", 1, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := filterInteractiveData(base, tt.filter) + if tt.returnsAll { + // Should return original data reference + if got["extra"] != "kept" { + t.Error("expected original data returned") + } + return + } + elems, ok := got["elements"].([]interface{}) + if !ok { + t.Fatal("expected elements in result") + } + if len(elems) != tt.wantCount { + t.Errorf("filtered elements len = %d, want %d", len(elems), tt.wantCount) + } + summary, ok := got["summary"].(map[string]interface{}) + if !ok { + t.Fatal("expected summary map") + } + if total, _ := summary["total"].(int); total != tt.wantCount { + t.Errorf("summary total = %d, want %d", total, tt.wantCount) + } + // extra key should still be present + if got["extra"] != "kept" { + t.Error("expected extra key preserved") + } + }) + } +} + +// --------------------------------------------------------------------------- +// buildRefSet +// --------------------------------------------------------------------------- + +func TestBuildRefSet(t *testing.T) { + tests := []struct { + name string + data map[string]interface{} + wantLen int + }{ + {"nil", nil, 0}, + {"empty elements", map[string]interface{}{"elements": []interface{}{}}, 0}, + { + "elements with refs", + map[string]interface{}{ + "elements": []interface{}{ + map[string]interface{}{"ref": "btn1"}, + map[string]interface{}{"ref": "btn2"}, + map[string]interface{}{"ref": ""}, + map[string]interface{}{"other": "no ref"}, + }, + }, + 2, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildRefSet(tt.data) + if len(got) != tt.wantLen { + t.Errorf("buildRefSet() len = %d, want %d", len(got), tt.wantLen) + } + }) + } +} + +// --------------------------------------------------------------------------- +// buildHrefSet +// --------------------------------------------------------------------------- + +func TestBuildHrefSet(t *testing.T) { + tests := []struct { + name string + data map[string]interface{} + wantLen int + }{ + {"nil", nil, 0}, + {"empty", map[string]interface{}{}, 0}, + { + "areas with hrefs", + map[string]interface{}{ + "nav": map[string]interface{}{"home": "/", "about": "/about"}, + "side": map[string]interface{}{"settings": "/settings"}, + "main": map[string]interface{}{"link": "/main"}, + "foot": map[string]interface{}{"privacy": "/privacy"}, + }, + 5, + }, + { + "ignores non-area keys", + map[string]interface{}{ + "other": map[string]interface{}{"x": "/x"}, + "nav": map[string]interface{}{"y": "/y"}, + }, + 1, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildHrefSet(tt.data) + if len(got) != tt.wantLen { + t.Errorf("buildHrefSet() len = %d, want %d", len(got), tt.wantLen) + } + }) + } +} + +// --------------------------------------------------------------------------- +// filterActionCandidates +// --------------------------------------------------------------------------- + +func TestFilterActionCandidates(t *testing.T) { + tests := []struct { + name string + candidates []map[string]interface{} + allowedRefs map[string]bool + allowedHrefs map[string]bool + wantLen int + }{ + { + name: "empty candidates", + candidates: []map[string]interface{}{}, + wantLen: 0, + }, + { + name: "global action kept (no ref, no label)", + candidates: []map[string]interface{}{ + {"action": "scroll_down", "ref": "", "label": ""}, + }, + wantLen: 1, + }, + { + name: "navigate with allowed href", + candidates: []map[string]interface{}{ + {"action": "navigate", "label": "/about", "ref": ""}, + }, + allowedHrefs: map[string]bool{"/about": true}, + wantLen: 1, + }, + { + name: "navigate with allowed ref", + candidates: []map[string]interface{}{ + {"action": "navigate", "label": "", "ref": "btn1"}, + }, + allowedRefs: map[string]bool{"btn1": true}, + wantLen: 1, + }, + { + name: "default action with allowed ref", + candidates: []map[string]interface{}{ + {"action": "click", "ref": "btn1", "label": "Submit"}, + }, + allowedRefs: map[string]bool{"btn1": true}, + wantLen: 1, + }, + { + name: "filtered out - ref not allowed", + candidates: []map[string]interface{}{ + {"action": "click", "ref": "btn_unknown", "label": "Submit"}, + }, + allowedRefs: map[string]bool{"btn1": true}, + wantLen: 0, + }, + { + name: "mixed - some pass some fail", + candidates: []map[string]interface{}{ + {"action": "scroll_down", "ref": "", "label": ""}, // global - kept + {"action": "click", "ref": "btn1", "label": "OK"}, // allowed ref - kept + {"action": "click", "ref": "btn_bad", "label": "Bad"}, // not allowed - dropped + }, + allowedRefs: map[string]bool{"btn1": true}, + wantLen: 2, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := filterActionCandidates(tt.candidates, tt.allowedRefs, tt.allowedHrefs) + if len(got) != tt.wantLen { + t.Errorf("filterActionCandidates() len = %d, want %d", len(got), tt.wantLen) + } + }) + } +} + +// --------------------------------------------------------------------------- +// limitAnySlice / limitMapSlice +// --------------------------------------------------------------------------- + +func TestLimitAnySlice(t *testing.T) { + items := []interface{}{"a", "b", "c", "d", "e"} + tests := []struct { + name string + max int + wantLen int + }{ + {"under limit", 10, 5}, + {"exact limit", 5, 5}, + {"over limit truncates", 3, 3}, + {"zero max returns all", 0, 5}, + {"negative max returns all", -1, 5}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := limitAnySlice(items, tt.max) + if len(got) != tt.wantLen { + t.Errorf("limitAnySlice(len=5, %d) len = %d, want %d", tt.max, len(got), tt.wantLen) + } + }) + } +} + +func TestLimitMapSlice(t *testing.T) { + items := []map[string]interface{}{{"a": 1}, {"b": 2}, {"c": 3}} + tests := []struct { + name string + max int + wantLen int + }{ + {"under limit", 10, 3}, + {"exact limit", 3, 3}, + {"over limit truncates", 2, 2}, + {"zero max returns all", 0, 3}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := limitMapSlice(items, tt.max) + if len(got) != tt.wantLen { + t.Errorf("limitMapSlice(len=3, %d) len = %d, want %d", tt.max, len(got), tt.wantLen) + } + }) + } +} + +// --------------------------------------------------------------------------- +// safeTraceFragment +// --------------------------------------------------------------------------- + +func TestSafeTraceFragment(t *testing.T) { + tests := []struct { + name string + input string + fallback string + want string + }{ + {"normal string", "hello_world", "fb", "hello_world"}, + {"special chars replaced", "hello world!@#", "fb", "hello_world"}, + {"empty uses fallback", "", "fb", "fb"}, + {"whitespace only uses fallback", " ", "fb", "fb"}, + {"dashes preserved", "my-trace-id", "fb", "my-trace-id"}, + {"all special chars", "!!!###", "fallback", "fallback"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := safeTraceFragment(tt.input, tt.fallback) + if got != tt.want { + t.Errorf("safeTraceFragment(%q, %q) = %q, want %q", tt.input, tt.fallback, got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// truncateMangleData +// --------------------------------------------------------------------------- + +func TestTruncateMangleData(t *testing.T) { + t.Run("[]interface truncation", func(t *testing.T) { + data := map[string]interface{}{ + "results": []interface{}{1, 2, 3, 4, 5}, + "meta": "unchanged", + } + got := truncateMangleData(data, 3) + results, ok := got["results"].([]interface{}) + if !ok { + t.Fatal("expected results as []interface{}") + } + if len(results) != 3 { + t.Errorf("results len = %d, want 3", len(results)) + } + if trunc, ok := got["results_truncated"].(bool); !ok || !trunc { + t.Error("expected results_truncated = true") + } + if got["meta"] != "unchanged" { + t.Error("expected meta unchanged") + } + }) + + t.Run("[]map truncation", func(t *testing.T) { + data := map[string]interface{}{ + "facts": []map[string]interface{}{{"a": 1}, {"b": 2}, {"c": 3}}, + } + got := truncateMangleData(data, 2) + facts, ok := got["facts"].([]map[string]interface{}) + if !ok { + t.Fatal("expected facts as []map") + } + if len(facts) != 2 { + t.Errorf("facts len = %d, want 2", len(facts)) + } + if trunc, ok := got["facts_truncated"].(bool); !ok || !trunc { + t.Error("expected facts_truncated = true") + } + }) + + t.Run("non-slice passthrough", func(t *testing.T) { + data := map[string]interface{}{ + "status": "ok", + "count": 42, + } + got := truncateMangleData(data, 1) + if got["status"] != "ok" { + t.Error("expected status preserved") + } + if got["count"] != 42 { + t.Error("expected count preserved") + } + }) + + t.Run("within limit no truncation flag", func(t *testing.T) { + data := map[string]interface{}{ + "results": []interface{}{1, 2}, + } + got := truncateMangleData(data, 5) + if _, ok := got["results_truncated"]; ok { + t.Error("expected no results_truncated key when within limit") + } + }) +} diff --git a/mcp-server/internal/mcp/progressive_tools.go b/mcp-server/internal/mcp/progressive_tools.go index d41fdc7..434a71f 100644 --- a/mcp-server/internal/mcp/progressive_tools.go +++ b/mcp-server/internal/mcp/progressive_tools.go @@ -1,4108 +1,4108 @@ -package mcp - -import ( - "context" - "encoding/json" - "fmt" - "math" - "net/url" - "os" - "path/filepath" - "reflect" - "sort" - "strconv" - "strings" - "time" - - "browsernerd-mcp-server/internal/browser" - "browsernerd-mcp-server/internal/docker" - "browsernerd-mcp-server/internal/mangle" - "browsernerd-mcp-server/internal/recorder" -) - -const ( - defaultProgressiveMaxItems = 20 - defaultObserveMaxRecs = 3 - defaultReasonMaxRecs = 4 - defaultReasonTimeWindowMs = 300000 - jsGateTTL = 10 * time.Minute -) - -// BrowserObserveTool provides progressive-disclosure page observation. -// This is a consolidated tool that wraps existing observe/extract tools. -type BrowserObserveTool struct { - sessions *browser.SessionManager - engine *mangle.Engine -} - -func (t *BrowserObserveTool) Name() string { return "browser-observe" } -func (t *BrowserObserveTool) Description() string { - return `Progressive page observation with token-aware modes. -Returns state/navigation/interactive data plus refs for browser-act. -Use mode for explicit slices (state, nav, interactive, hidden, grids, composite, sessions, screenshot, react, dom_snapshot).` -} - -func (t *BrowserObserveTool) InputSchema() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "session_id": map[string]interface{}{ - "type": "string", - "description": "Target session", - }, - "intent": map[string]interface{}{ - "type": "string", - "description": "Token-aware intent preset that applies progressive defaults when explicit knobs are omitted", - "enum": []string{"quick_status", "find_actions", "map_navigation", "hidden_content", "deep_audit", "check_sessions", "visual_check", "grid_hunt"}, - }, - "mode": map[string]interface{}{ - "type": "string", - "description": "Observation mode", - "enum": []string{"state", "nav", "interactive", "hidden", "grids", "composite", "sessions", "screenshot", "react", "dom_snapshot"}, - }, - "full_page": map[string]interface{}{ - "type": "boolean", - "description": "For screenshot mode: capture full scrollable page (default false)", - }, - "save_path": map[string]interface{}{ - "type": "string", - "description": "For screenshot mode: save screenshot to this file path", - }, - "format": map[string]interface{}{ - "type": "string", - "description": "For screenshot mode: image format (png|jpeg)", - "enum": []string{"png", "jpeg"}, - }, - "view": map[string]interface{}{ - "type": "string", - "description": "Disclosure depth: summary|compact|full", - "enum": []string{"summary", "compact", "full"}, - }, - "max_items": map[string]interface{}{ - "type": "integer", - "description": "Max number of items for list outputs (default 20)", - }, - "filter": map[string]interface{}{ - "type": "string", - "description": "Interactive filter: all|buttons|inputs|links|selects", - "enum": []string{"all", "buttons", "inputs", "links", "selects"}, - }, - "visible_only": map[string]interface{}{ - "type": "boolean", - "description": "Only visible interactive elements (default true)", - }, - "internal_only": map[string]interface{}{ - "type": "boolean", - "description": "For nav mode: only internal links", - }, - "emit_facts": map[string]interface{}{ - "type": "boolean", - "description": "Emit derived facts where supported (default true)", - }, - "include_action_plan": map[string]interface{}{ - "type": "boolean", - "description": "Include Mangle-derived action candidates and browser-act recommendations (default true)", - }, - "include_diagnostics": map[string]interface{}{ - "type": "boolean", - "description": "Include lightweight health signals (diagnose-page + toast counts) (default false; enabled by some intents)", - }, - "max_recommendations": map[string]interface{}{ - "type": "integer", - "description": "Maximum recommendation rows to return (default 3)", - }, - "max_grids": map[string]interface{}{ - "type": "integer", - "description": "Grids mode: max grids (default max_items)", - }, - "sample_rows": map[string]interface{}{ - "type": "integer", - "description": "Grids mode: sample rows (default 3, max 10)", - }, - "include_samples": map[string]interface{}{ - "type": "boolean", - "description": "Grids mode: include sample refs (default true; summary defaults false)", - }, - }, - } -} - -func (t *BrowserObserveTool) Execute(ctx context.Context, args map[string]interface{}) (interface{}, error) { - sessionID := getStringArg(args, "session_id") - intent := normalizeObserveIntent(getStringArg(args, "intent")) - intentCfg, hasIntent := resolveObserveIntentDefaults(intent) - - mode := strings.ToLower(getStringArg(args, "mode")) - view := normalizeProgressiveView(getStringArg(args, "view")) - maxItems := getIntArg(args, "max_items", defaultProgressiveMaxItems) - filter := strings.ToLower(getStringArg(args, "filter")) - visibleOnly := getBoolArg(args, "visible_only", true) - internalOnly := getBoolArg(args, "internal_only", false) - emitFacts := getBoolArg(args, "emit_facts", true) - includeActionPlan := getBoolArg(args, "include_action_plan", true) - includeDiagnostics := getBoolArg(args, "include_diagnostics", false) - maxRecommendations := getIntArg(args, "max_recommendations", defaultObserveMaxRecs) - if maxRecommendations <= 0 { - maxRecommendations = defaultObserveMaxRecs - } - - intentApplied := false - if hasIntent { - if !argHasNonEmptyString(args, "mode") && intentCfg.mode != "" { - mode = intentCfg.mode - intentApplied = true - } - if !argHasNonEmptyString(args, "view") && intentCfg.view != "" { - view = intentCfg.view - intentApplied = true - } - if !argHasInt(args, "max_items") && intentCfg.maxItems > 0 { - maxItems = intentCfg.maxItems - intentApplied = true - } - if !argHasNonEmptyString(args, "filter") && intentCfg.filter != "" { - filter = intentCfg.filter - intentApplied = true - } - if !argPresent(args, "visible_only") { - visibleOnly = intentCfg.visibleOnly - intentApplied = true - } - if !argPresent(args, "internal_only") { - internalOnly = intentCfg.internalOnly - intentApplied = true - } - if !argPresent(args, "include_action_plan") { - includeActionPlan = intentCfg.includeActionPlan - intentApplied = true - } - if !argPresent(args, "include_diagnostics") { - includeDiagnostics = intentCfg.includeDiagnostics - intentApplied = true - } - if !argHasInt(args, "max_recommendations") && intentCfg.maxRecommendations > 0 { - maxRecommendations = intentCfg.maxRecommendations - intentApplied = true - } - } - - if mode == "" { - mode = "composite" - } - if maxItems <= 0 { - maxItems = defaultProgressiveMaxItems - } - if filter == "" { - filter = "all" - } - - // Handle new delegating modes that return early - switch mode { - case "sessions": - delegate := &ListSessionsTool{sessions: t.sessions} - res, err := delegate.Execute(ctx, map[string]interface{}{}) - if err != nil { - return nil, err - } - resMap := asMap(res) - handles := []string{"observe:sessions"} - emitDisclosureFacts(ctx, t.engine, "", handles, "observe") - response := map[string]interface{}{ - "success": true, - "status": "ok", - "intent": ternaryStatus(hasIntent, intent, "custom"), - "intent_applied": intentApplied, - "mode": mode, - "view": view, - "evidence_handles": handles, - "truncated": false, - } - switch view { - case "summary": - sessions := toAnySlice(resMap["sessions"]) - response["summary"] = fmt.Sprintf("%d active session(s)", len(sessions)) - response["data"] = map[string]interface{}{"session_count": len(sessions)} - case "compact": - sessions := toAnySlice(resMap["sessions"]) - response["summary"] = fmt.Sprintf("%d active session(s)", len(sessions)) - response["data"] = map[string]interface{}{"sessions": limitAnySlice(sessions, maxItems)} - default: - response["data"] = resMap - } - return response, nil - - case "screenshot": - if sessionID == "" { - return map[string]interface{}{"success": false, "error": "session_id is required for screenshot mode"}, nil - } - delegate := &ScreenshotTool{sessions: t.sessions, engine: t.engine} - delegateArgs := map[string]interface{}{ - "session_id": sessionID, - } - if v, ok := args["full_page"]; ok { - delegateArgs["full_page"] = v - } - if v, ok := args["save_path"]; ok { - delegateArgs["save_path"] = v - } - if v, ok := args["format"]; ok { - delegateArgs["format"] = v - } - res, err := delegate.Execute(ctx, delegateArgs) - if err != nil { - return nil, err - } - handles := []string{"observe:" + sessionID + ":screenshot"} - emitDisclosureFacts(ctx, t.engine, sessionID, handles, "observe") - return map[string]interface{}{ - "success": true, - "status": "ok", - "intent": ternaryStatus(hasIntent, intent, "custom"), - "intent_applied": intentApplied, - "mode": mode, - "view": view, - "summary": "screenshot captured", - "data": res, - "evidence_handles": handles, - "truncated": false, - }, nil - - case "grids": - if sessionID == "" { - return map[string]interface{}{"success": false, "error": "session_id is required for grids mode"}, nil - } - delegate := &DiscoverGridsTool{sessions: t.sessions} - delegateArgs := map[string]interface{}{ - "session_id": sessionID, - "max_grids": maxItems, - } - if argHasInt(args, "max_grids") { - delegateArgs["max_grids"] = getIntArg(args, "max_grids", maxItems) - } - if argHasInt(args, "sample_rows") { - delegateArgs["sample_rows"] = getIntArg(args, "sample_rows", 3) - } - if argPresent(args, "include_samples") { - delegateArgs["include_samples"] = getBoolArg(args, "include_samples", true) - } else { - delegateArgs["include_samples"] = view != "summary" - } - - res, err := delegate.Execute(ctx, delegateArgs) - if err != nil { - return nil, err - } - resMap := asMap(res) - grids, _ := resMap["grids"].([]interface{}) - totalGrids := asInt(resMap["total_grids"]) - if totalGrids <= 0 { - totalGrids = len(grids) - } - - nextStep := map[string]interface{}{ - "tool": "browser-observe", - "args": map[string]interface{}{ - "session_id": sessionID, - "mode": "interactive", - "view": "compact", - }, - } - if len(grids) > 0 { - if firstGrid, ok := grids[0].(map[string]interface{}); ok { - if rowRefs, ok := firstGrid["sample_row_refs"].([]interface{}); ok && len(rowRefs) > 0 { - if firstRow, ok := rowRefs[0].(map[string]interface{}); ok { - ref := getStringFromMap(firstRow, "ref") - if ref != "" { - nextStep = map[string]interface{}{ - "tool": "browser-act", - "args": map[string]interface{}{ - "session_id": sessionID, - "operations": []map[string]interface{}{ - { - "type": "interact", - "action": "click", - "ref": ref, - }, - }, - }, - "reason": "Try the first sampled row ref to validate row targeting", - } - } - } - } - } - } - - handles := []string{"observe:" + sessionID + ":grids"} - emitDisclosureFacts(ctx, t.engine, sessionID, handles, "observe") - - response := map[string]interface{}{ - "success": true, - "status": "ok", - "intent": ternaryStatus(hasIntent, intent, "custom"), - "intent_applied": intentApplied, - "mode": mode, - "view": view, - "summary": fmt.Sprintf("%d grid surface(s) detected", totalGrids), - "evidence_handles": handles, - "truncated": false, - "next_step": nextStep, - } - switch view { - case "summary": - response["data"] = map[string]interface{}{ - "total_grids": totalGrids, - } - case "compact": - response["data"] = map[string]interface{}{ - "total_grids": totalGrids, - "grids": limitAnySlice(grids, maxItems), - } - default: - response["data"] = resMap - } - return response, nil - - case "react": - if sessionID == "" { - return map[string]interface{}{"success": false, "error": "session_id is required for react mode"}, nil - } - delegate := &ReifyReactTool{sessions: t.sessions, engine: t.engine} - res, err := delegate.Execute(ctx, map[string]interface{}{ - "session_id": sessionID, - }) - if err != nil { - return nil, err - } - resMap := asMap(res) - handles := []string{"observe:" + sessionID + ":react"} - emitDisclosureFacts(ctx, t.engine, sessionID, handles, "observe") - response := map[string]interface{}{ - "success": true, - "status": "ok", - "intent": ternaryStatus(hasIntent, intent, "custom"), - "intent_applied": intentApplied, - "mode": mode, - "view": view, - "evidence_handles": handles, - "truncated": false, - } - switch view { - case "summary": - componentCount := asInt(resMap["component_count"]) - response["summary"] = fmt.Sprintf("React tree: %d component(s)", componentCount) - response["data"] = map[string]interface{}{ - "component_count": componentCount, - "success": resMap["success"], - } - case "compact": - componentCount := asInt(resMap["component_count"]) - response["summary"] = fmt.Sprintf("React tree: %d component(s)", componentCount) - response["data"] = resMap - default: - response["data"] = resMap - } - return response, nil - - case "dom_snapshot": - if sessionID == "" { - return map[string]interface{}{"success": false, "error": "session_id is required for dom_snapshot mode"}, nil - } - delegate := &SnapshotDOMTool{sessions: t.sessions, engine: t.engine} - res, err := delegate.Execute(ctx, map[string]interface{}{ - "session_id": sessionID, - }) - if err != nil { - return nil, err - } - resMap := asMap(res) - handles := []string{"observe:" + sessionID + ":dom_snapshot"} - emitDisclosureFacts(ctx, t.engine, sessionID, handles, "observe") - response := map[string]interface{}{ - "success": true, - "status": "ok", - "intent": ternaryStatus(hasIntent, intent, "custom"), - "intent_applied": intentApplied, - "mode": mode, - "view": view, - "evidence_handles": handles, - "truncated": false, - } - switch view { - case "summary": - nodeCount := asInt(resMap["node_count"]) - response["summary"] = fmt.Sprintf("DOM snapshot: %d node(s)", nodeCount) - response["data"] = map[string]interface{}{ - "node_count": nodeCount, - "success": resMap["success"], - } - case "compact": - nodeCount := asInt(resMap["node_count"]) - response["summary"] = fmt.Sprintf("DOM snapshot: %d node(s)", nodeCount) - response["data"] = resMap - default: - response["data"] = resMap - } - return response, nil - } - - // Require session_id for original modes - if sessionID == "" { - return map[string]interface{}{"success": false, "error": "session_id is required"}, nil - } - - stateTool := &GetPageStateTool{sessions: t.sessions} - navTool := &GetNavigationLinksTool{sessions: t.sessions, engine: t.engine} - interactiveTool := &GetInteractiveElementsTool{sessions: t.sessions, engine: t.engine} - hiddenTool := &DiscoverHiddenContentTool{sessions: t.sessions} - - stateData := map[string]interface{}{} - navData := map[string]interface{}{} - interactiveData := map[string]interface{}{} - interactivePlanningData := map[string]interface{}{} - hiddenData := map[string]interface{}{} - diagnosticsData := map[string]interface{}{} - toastData := map[string]interface{}{} - - fetchState := mode == "state" || mode == "composite" - fetchNav := mode == "nav" || mode == "composite" - fetchInteractive := mode == "interactive" || mode == "composite" - fetchHidden := mode == "hidden" || (mode == "composite" && view == "full") - fetchDiagnostics := includeDiagnostics - - // Planning snapshot: when action planning is enabled, prefer a wider interactive extraction - // than the returned output to avoid missing primary CTAs (while keeping output token-light). - planningLimit := maxItems - planningFilter := filter - if includeActionPlan { - planningLimit = maxInt(maxItems, 80) - // Action planning benefits from seeing all interactive elements even if output filter is narrower. - planningFilter = "all" - } - - if fetchState { - res, err := stateTool.Execute(ctx, map[string]interface{}{"session_id": sessionID}) - if err != nil { - return nil, err - } - stateData = asMap(res) - } - - if fetchNav { - res, err := navTool.Execute(ctx, map[string]interface{}{ - "session_id": sessionID, - "internal_only": internalOnly, - "max_per_area": maxInt(maxItems, 20), - "emit_facts": emitFacts, - }) - if err != nil { - return nil, err - } - navData = asMap(res) - } - - if fetchInteractive { - res, err := interactiveTool.Execute(ctx, map[string]interface{}{ - "session_id": sessionID, - "filter": planningFilter, - "visible_only": visibleOnly, - "limit": planningLimit, - "verbose": view == "full", - }) - if err != nil { - return nil, err - } - interactivePlanningData = asMap(res) - if fetchInteractive { - interactiveData = filterInteractiveData(interactivePlanningData, filter) - } - } - - if fetchHidden { - res, err := hiddenTool.Execute(ctx, map[string]interface{}{ - "session_id": sessionID, - }) - if err != nil { - return nil, err - } - hiddenData = asMap(res) - } - - if fetchDiagnostics { - diagTool := &DiagnosePageTool{engine: t.engine} - diagView := "compact" - if view == "summary" { - diagView = "summary" - } else if view == "full" { - diagView = "full" - } - res, err := diagTool.Execute(ctx, map[string]interface{}{ - "session_id": sessionID, - "view": diagView, - "max_items": minInt(maxItems, 20), - }) - if err == nil { - diagnosticsData = asMap(res) - } else { - diagnosticsData = map[string]interface{}{"status": "error", "summary": err.Error()} - } - - if t.engine != nil { - toastTool := &GetToastNotificationsTool{engine: t.engine} - toastView := "summary" - if view == "compact" { - toastView = "compact" - } else if view == "full" { - toastView = "full" - } - toastLimit := minInt(maxItems, 10) - level := "all" - if view != "full" { - level = "error" - } - toastRes, tErr := toastTool.Execute(ctx, map[string]interface{}{ - "session_id": sessionID, - "level": level, - "view": toastView, - "include_correlations": view == "full", - "limit": toastLimit, - }) - if tErr == nil { - toastData = asMap(toastRes) - } else { - toastData = map[string]interface{}{"status": "error", "summary": tErr.Error()} - } - } else { - toastData = map[string]interface{}{"status": "unavailable", "summary": "mangle engine unavailable"} - } - } - - handles := make([]string, 0, 4) - data := map[string]interface{}{} - - if fetchState { - handles = append(handles, "observe:"+sessionID+":state") - } - if fetchNav { - handles = append(handles, "observe:"+sessionID+":nav") - } - if fetchInteractive { - handles = append(handles, "observe:"+sessionID+":interactive") - } - if fetchHidden { - handles = append(handles, "observe:"+sessionID+":hidden") - } - if fetchDiagnostics { - handles = append(handles, "observe:"+sessionID+":diagnostics") - handles = append(handles, "observe:"+sessionID+":toasts") - } - - actionCandidates := []map[string]interface{}{} - recommendations := []map[string]interface{}{} - if includeActionPlan && t.engine != nil && (fetchInteractive || fetchNav || mode == "composite") { - // Query more than we plan to return so we can filter stale candidates safely. - queryLimit := maxInt(planningLimit*4, 300) - actionCandidatesRaw := queryActionCandidates(ctx, t.engine, sessionID, queryLimit) - - // Filter to candidates that match the *current* observe snapshot (prevents stale actions from prior pages). - allowedRefs := buildRefSet(interactivePlanningData) - allowedHrefs := buildHrefSet(navData) - actionCandidates = filterActionCandidates(actionCandidatesRaw, allowedRefs, allowedHrefs) - - currentURL := getStringFromMap(stateData, "url") - if currentURL == "" { - currentURL = resolveCurrentURL(ctx, t.engine, sessionID) - } - recommendations = buildActionPlanRecommendations(actionCandidates, maxRecommendations, sessionID, originFromURL(currentURL)) - handles = append(handles, "observe:"+sessionID+":action_candidates") - handles = append(handles, "observe:"+sessionID+":recommendations") - } - - topErrors := buildObserveTopErrors(sessionID, diagnosticsData, toastData, maxInt(6, minInt(maxItems, 20))) - investigationItems := buildInvestigationItems(sessionID, topErrors, minInt(maxItems, 8)) - if len(topErrors) > 0 { - handles = append(handles, "observe:"+sessionID+":top_errors") - } - if len(investigationItems) > 0 { - handles = append(handles, "observe:"+sessionID+":investigation_items") - } - - switch view { - case "summary": - if fetchState { - data["state"] = map[string]interface{}{ - "url": getStringFromMap(stateData, "url"), - "title": getStringFromMap(stateData, "title"), - "loading": stateData["loading"], - "hasDialog": stateData["hasDialog"], - } - } - if fetchNav { - if counts, ok := navData["counts"].(map[string]interface{}); ok { - data["nav_counts"] = counts - } - } - if fetchInteractive { - if summary, ok := interactiveData["summary"].(map[string]interface{}); ok { - data["interactive_summary"] = summary - } - } - if fetchHidden { - data["hidden_count"] = countHiddenElements(hiddenData) - } - if fetchDiagnostics { - data["diagnostics"] = map[string]interface{}{ - "status": getStringFromMap(diagnosticsData, "status"), - "counts": diagnosticsData["counts"], - "summary": diagnosticsData["summary"], - } - data["toasts"] = map[string]interface{}{ - "error_count": toastData["error_count"], - "warning_count": toastData["warning_count"], - "success_count": toastData["success_count"], - "info_count": toastData["info_count"], - "summary": toastData["summary"], - } - } - if includeActionPlan { - data["action_candidate_count"] = len(actionCandidates) - data["recommendation_count"] = len(recommendations) - } - if len(topErrors) > 0 { - data["top_errors"] = limitMapSlice(topErrors, minInt(3, maxItems)) - } - if len(investigationItems) > 0 { - data["investigation_items"] = limitMapSlice(investigationItems, minInt(3, maxItems)) - } - case "compact": - if fetchState { - data["state"] = stateData - } - if fetchNav { - data["nav"] = navData - } - if fetchInteractive { - data["interactive"] = compactInteractiveData(interactiveData, maxItems) - } - if fetchHidden { - data["hidden"] = compactHiddenData(hiddenData, maxItems) - } - if fetchDiagnostics { - data["diagnostics"] = map[string]interface{}{ - "status": getStringFromMap(diagnosticsData, "status"), - "counts": diagnosticsData["counts"], - "summary": diagnosticsData["summary"], - } - data["toasts"] = compactToastData(toastData, maxItems) - } - if includeActionPlan { - data["action_candidates"] = limitMapSlice(actionCandidates, maxItems) - data["recommendations"] = recommendations - } - if len(topErrors) > 0 { - data["top_errors"] = limitMapSlice(topErrors, minInt(maxItems, 10)) - } - if len(investigationItems) > 0 { - data["investigation_items"] = limitMapSlice(investigationItems, minInt(maxItems, 8)) - } - default: // full - if fetchState { - data["state"] = stateData - } - if fetchNav { - data["nav"] = navData - } - if fetchInteractive { - data["interactive"] = interactiveData - } - if fetchHidden { - data["hidden"] = hiddenData - } - if fetchDiagnostics { - data["diagnostics"] = diagnosticsData - data["toasts"] = toastData - } - if includeActionPlan { - data["action_candidates"] = actionCandidates - data["recommendations"] = recommendations - } - if len(topErrors) > 0 { - data["top_errors"] = topErrors - } - if len(investigationItems) > 0 { - data["investigation_items"] = investigationItems - } - } - - summary := buildObserveSummary(data) - emitDisclosureFacts(ctx, t.engine, sessionID, handles, "observe") - - return map[string]interface{}{ - "success": true, - "status": "ok", - "intent": ternaryStatus(hasIntent, intent, "custom"), - "intent_applied": intentApplied, - "mode": mode, - "view": view, - "summary": summary, - "data": data, - "next_step": suggestObserveNextStep(sessionID, data, mode, view, recommendations), - "evidence_handles": handles, - "truncated": false, - }, nil -} - -// BrowserActTool consolidates browser actions with progressive-disclosure results. -type BrowserActTool struct { - sessions *browser.SessionManager - engine *mangle.Engine -} - -func (t *BrowserActTool) Name() string { return "browser-act" } -func (t *BrowserActTool) Description() string { - return `Perform browser actions -- navigate, click, type, manage sessions, wait, run JS. - -Pass an operations array; they execute in sequence. Use browser-observe first to get ref IDs. - -Operation types: - Session: session_create (url), session_attach (target_id), session_fork (clone auth state) - Navigate: navigate (url), history (back/forward/reload) - Interact: click, type, select, toggle -- requires ref from browser-observe - Forms: fill -- batch multiple fields [{ref, value}] + optional submit - Keyboard: key -- e.g. "Enter", "Tab", "Control+a" - Waiting: await_stable (idle detection), await_fact, await_conditions, wait, sleep - Advanced: js (eval JavaScript, gated), plan (Mangle-derived action sequence) - -Options: stop_on_error (default true), view (summary|compact|full). - -Use browser-observe to understand the page, browser-reason if something goes wrong.` -} - -func (t *BrowserActTool) InputSchema() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "session_id": map[string]interface{}{ - "type": "string", - "description": "Target session", - }, - "operations": map[string]interface{}{ - "type": "array", - "description": "Action operations to execute", - "items": map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "type": map[string]interface{}{ - "type": "string", - "enum": []string{"navigate", "interact", "fill", "key", "history", "sleep", "session_create", "session_attach", "session_fork", "wait", "await_stable", "await_fact", "await_conditions", "js", "plan"}, - }, - }, - "required": []string{"type"}, - }, - }, - "stop_on_error": map[string]interface{}{ - "type": "boolean", - "description": "Stop at first failed operation (default true)", - }, - "view": map[string]interface{}{ - "type": "string", - "description": "summary|compact|full", - "enum": []string{"summary", "compact", "full"}, - }, - "max_items": map[string]interface{}{ - "type": "integer", - "description": "Max operation results returned in compact mode (default 20)", - }, - }, - "required": []string{"operations"}, - } -} - -func (t *BrowserActTool) Execute(ctx context.Context, args map[string]interface{}) (interface{}, error) { - sessionID := getStringArg(args, "session_id") - - rawOps, ok := args["operations"].([]interface{}) - if !ok || len(rawOps) == 0 { - return map[string]interface{}{"success": false, "error": "operations must be a non-empty array"}, nil - } - - stopOnError := getBoolArg(args, "stop_on_error", true) - view := normalizeProgressiveView(getStringArg(args, "view")) - maxItems := getIntArg(args, "max_items", defaultProgressiveMaxItems) - if maxItems <= 0 { - maxItems = defaultProgressiveMaxItems - } - - navTool := &NavigateURLTool{sessions: t.sessions, engine: t.engine} - interactTool := &InteractTool{sessions: t.sessions, engine: t.engine} - fillTool := &FillFormTool{sessions: t.sessions, engine: t.engine} - keyTool := &PressKeyTool{sessions: t.sessions, engine: t.engine} - historyTool := &BrowserHistoryTool{sessions: t.sessions, engine: t.engine} - - results := make([]map[string]interface{}, 0, len(rawOps)) - succeeded := 0 - failed := 0 - - for idx, raw := range rawOps { - op, ok := raw.(map[string]interface{}) - if !ok { - results = append(results, map[string]interface{}{ - "index": idx, - "type": "unknown", - "success": false, - "error": "operation must be an object", - }) - failed++ - if stopOnError { - break - } - continue - } - - opType := strings.ToLower(getStringFromMap(op, "type")) - entry := map[string]interface{}{ - "index": idx, - "type": opType, - } - - var ( - opResult interface{} - err error - ) - - switch opType { - case "navigate": - url := getStringFromMap(op, "url") - waitUntil := getStringFromMap(op, "wait_until") - opResult, err = navTool.Execute(ctx, map[string]interface{}{ - "session_id": sessionID, - "url": url, - "wait_until": waitUntil, - }) - case "interact": - opResult, err = interactTool.Execute(ctx, map[string]interface{}{ - "session_id": sessionID, - "ref": getStringFromMap(op, "ref"), - "action": getStringFromMap(op, "action"), - "value": getStringFromMap(op, "value"), - "submit": op["submit"], - }) - case "fill": - opResult, err = fillTool.Execute(ctx, map[string]interface{}{ - "session_id": sessionID, - "fields": op["fields"], - "submit": op["submit"], - "submit_button": getStringFromMap(op, "submit_button"), - }) - case "key": - opResult, err = keyTool.Execute(ctx, map[string]interface{}{ - "session_id": sessionID, - "key": getStringFromMap(op, "key"), - "modifiers": op["modifiers"], - }) - case "history": - opResult, err = historyTool.Execute(ctx, map[string]interface{}{ - "session_id": sessionID, - "action": getStringFromMap(op, "action"), - }) - case "sleep": - ms := getIntArg(op, "duration_ms", 250) - if ms < 0 { - ms = 0 - } - err = sleepWithContext(ctx, time.Duration(ms)*time.Millisecond) - opResult = map[string]interface{}{"success": err == nil, "slept_ms": ms} - - case "session_create": - createTool := &CreateSessionTool{sessions: t.sessions} - opResult, err = createTool.Execute(ctx, map[string]interface{}{ - "url": getStringFromMap(op, "url"), - }) - - case "session_attach": - attachTool := &AttachSessionTool{sessions: t.sessions} - opResult, err = attachTool.Execute(ctx, map[string]interface{}{ - "target_id": getStringFromMap(op, "target_id"), - }) - - case "session_fork": - forkTool := &ForkSessionTool{sessions: t.sessions} - forkArgs := map[string]interface{}{ - "source_session_id": getStringFromMap(op, "source_session_id"), - } - if u := getStringFromMap(op, "url"); u != "" { - forkArgs["url"] = u - } - opResult, err = forkTool.Execute(ctx, forkArgs) - - case "wait": - waitTool := &WaitForConditionTool{sessions: t.sessions, engine: t.engine} - waitArgs := map[string]interface{}{ - "predicate": getStringFromMap(op, "predicate"), - } - if v, ok := op["match_args"]; ok { - waitArgs["match_args"] = v - } - if v, ok := op["timeout_ms"]; ok { - waitArgs["timeout_ms"] = v - } - opResult, err = waitTool.Execute(ctx, waitArgs) - - case "await_stable": - stableTool := &AwaitStableStateTool{engine: t.engine} - stableArgs := map[string]interface{}{} - if v, ok := op["timeout_ms"]; ok { - stableArgs["timeout_ms"] = v - } - opResult, err = stableTool.Execute(ctx, stableArgs) - - case "await_fact": - awaitTool := &AwaitFactTool{engine: t.engine} - awaitArgs := map[string]interface{}{ - "predicate": getStringFromMap(op, "predicate"), - } - if v, ok := op["args"]; ok { - awaitArgs["args"] = v - } - if v, ok := op["timeout_ms"]; ok { - awaitArgs["timeout_ms"] = v - } - opResult, err = awaitTool.Execute(ctx, awaitArgs) - - case "await_conditions": - condTool := &AwaitConditionsTool{engine: t.engine} - condArgs := map[string]interface{}{} - if v, ok := op["conditions"]; ok { - condArgs["conditions"] = v - } - if v, ok := op["timeout_ms"]; ok { - condArgs["timeout_ms"] = v - } - opResult, err = condTool.Execute(ctx, condArgs) - - case "js": - jsTool := &EvaluateJSTool{sessions: t.sessions, engine: t.engine} - jsArgs := map[string]interface{}{ - "session_id": sessionID, - "script": getStringFromMap(op, "script"), - } - if v, ok := op["timeout_ms"]; ok { - jsArgs["timeout_ms"] = v - } - if v, ok := op["gate_reason"]; ok { - jsArgs["gate_reason"] = v - } - if v, ok := op["approved_by_handle"]; ok { - jsArgs["approved_by_handle"] = v - } - opResult, err = jsTool.Execute(ctx, jsArgs) - - case "plan": - planTool := &ExecutePlanTool{sessions: t.sessions, engine: t.engine} - planArgs := map[string]interface{}{ - "session_id": sessionID, - } - if v, ok := op["actions"]; ok { - planArgs["actions"] = v - } - if v, ok := op["predicate"]; ok { - planArgs["predicate"] = v - } - if v, ok := op["delay_ms"]; ok { - planArgs["delay_ms"] = v - } - opResult, err = planTool.Execute(ctx, planArgs) - - default: - err = fmt.Errorf("unknown operation type: %s", opType) - } - - success := err == nil - if resultMap, ok := opResult.(map[string]interface{}); ok { - if s, exists := resultMap["success"].(bool); exists { - success = success && s - } - } - - if err != nil { - entry["error"] = err.Error() - } - entry["success"] = success - entry["result"] = opResult - - results = append(results, entry) - if success { - succeeded++ - } else { - failed++ - if stopOnError { - break - } - } - } - - now := time.Now().UnixMilli() - handle := fmt.Sprintf("act:%s:%d", sessionID, now) - emitDisclosureFacts(ctx, t.engine, sessionID, []string{handle}, "act") - - response := map[string]interface{}{ - "success": failed == 0, - "status": ternaryStatus(failed == 0, "ok", "error"), - "summary": fmt.Sprintf("Executed %d operation(s): %d succeeded, %d failed", len(results), succeeded, failed), - "counts": map[string]interface{}{"total": len(results), "succeeded": succeeded, "failed": failed}, - "evidence_handles": []string{handle}, - "view": view, - } - - switch view { - case "summary": - // no per-operation payload - case "compact": - compact := make([]map[string]interface{}, 0, len(results)) - for _, r := range results { - compact = append(compact, map[string]interface{}{ - "index": r["index"], - "type": r["type"], - "success": r["success"], - "error": r["error"], - }) - } - response["results"] = limitMapSlice(compact, maxItems) - response["truncated"] = len(compact) > maxItems - default: - response["results"] = results - response["truncated"] = false - } - - return response, nil -} - -// BrowserReasonTool performs Mangle-first reasoning with progressive disclosure. -type BrowserReasonTool struct { - engine *mangle.Engine - dockerClient *docker.Client -} - -func (t *BrowserReasonTool) Name() string { return "browser-reason" } -func (t *BrowserReasonTool) Description() string { - return `Diagnose browser problems -- health checks, root cause analysis, blocking issues. - -Use when something goes wrong or you need guidance on what to do next. -Analyzes Mangle facts (network, console, DOM) with optional Docker log correlation. - -Topics: - health: Page health score with error/warning counts - next_best_action: Ranked recommendations for what to do next - blocking_issue: What prevents progress (modals, auth walls, errors) - why_failed: Root cause analysis with causal chains - what_changed_since: Diff facts since a timestamp (pass time_window_ms) - -Intents (presets that apply topic + view defaults): triage, act_now, debug_failure, unblock - -Views: summary (verdict + counts), compact (default, verdict + key evidence), full (all evidence). - -Results include evidence handles for drill-down -- pass them back via expand_handles to dig deeper. -Use browser-mangle instead for raw Mangle queries; use browser-observe to re-check page state. - -Use since_navigation=true to scope results to new errors after the latest navigation event -for the current route.` -} - -func (t *BrowserReasonTool) InputSchema() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "session_id": map[string]interface{}{ - "type": "string", - "description": "Session context for gating and handles", - }, - "intent": map[string]interface{}{ - "type": "string", - "description": "Reasoning preset that applies topic/view defaults when explicit knobs are omitted", - "enum": []string{"triage", "act_now", "debug_failure", "unblock"}, - }, - "topic": map[string]interface{}{ - "type": "string", - "description": "Reasoning topic", - "enum": []string{"health", "next_best_action", "blocking_issue", "why_failed", "what_changed_since"}, - }, - "view": map[string]interface{}{ - "type": "string", - "description": "summary|compact|full", - "enum": []string{"summary", "compact", "full"}, - }, - "max_items": map[string]interface{}{ - "type": "integer", - "description": "Max rows per section (default 20)", - }, - "expand_handles": map[string]interface{}{ - "type": "array", - "description": "Only expand matching evidence handles", - "items": map[string]interface{}{"type": "string"}, - }, - "include_action_plan": map[string]interface{}{ - "type": "boolean", - "description": "Include Mangle-derived browser-act operation recommendations (default true)", - }, - "max_recommendations": map[string]interface{}{ - "type": "integer", - "description": "Maximum recommendation rows to return (default 4)", - }, - "time_window_ms": map[string]interface{}{ - "type": "integer", - "description": "Only include evidence newer than now-window (default 300000; set 0 for all history)", - }, - "since_navigation": map[string]interface{}{ - "type": "boolean", - "description": "When true, scope errors to events after latest navigation_event(SessionId, Url, Timestamp)", - }, - }, - "required": []string{"session_id"}, - } -} - -func (t *BrowserReasonTool) Execute(ctx context.Context, args map[string]interface{}) (interface{}, error) { - if t.engine == nil { - return map[string]interface{}{"success": false, "error": "mangle engine is not available"}, nil - } - - sessionID := getStringArg(args, "session_id") - if sessionID == "" { - return map[string]interface{}{"success": false, "error": "session_id is required"}, nil - } - - intent := normalizeReasonIntent(getStringArg(args, "intent")) - intentCfg, hasIntent := resolveReasonIntentDefaults(intent) - topic := strings.ToLower(getStringArg(args, "topic")) - view := normalizeProgressiveView(getStringArg(args, "view")) - if hasIntent { - if !argHasNonEmptyString(args, "topic") && intentCfg.topic != "" { - topic = intentCfg.topic - } - if !argHasNonEmptyString(args, "view") && intentCfg.view != "" { - view = intentCfg.view - } - } - if topic == "" { - topic = "health" - } - maxItems := getIntArg(args, "max_items", defaultProgressiveMaxItems) - if maxItems <= 0 { - maxItems = defaultProgressiveMaxItems - } - includeActionPlan := getBoolArg(args, "include_action_plan", true) - maxRecommendations := getIntArg(args, "max_recommendations", defaultReasonMaxRecs) - if maxRecommendations <= 0 { - maxRecommendations = defaultReasonMaxRecs - } - timeWindowMs := getIntArg(args, "time_window_ms", defaultReasonTimeWindowMs) - if timeWindowMs < 0 { - timeWindowMs = 0 - } - if timeWindowMs > 86400000 { - timeWindowMs = 86400000 - } - sinceNavigation := getBoolArg(args, "since_navigation", false) - sinceMs := int64(0) - if timeWindowMs > 0 { - sinceMs = time.Now().UnixMilli() - int64(timeWindowMs) - } - navigationSinceMs := int64(0) - if sinceNavigation { - navigationSinceMs = latestNavigationTimestamp(ctx, t.engine, sessionID) - } - effectiveSinceMs := sinceMs - if navigationSinceMs > effectiveSinceMs { - effectiveSinceMs = navigationSinceMs - } - - rootCauses := queryToRows(ctx, t.engine, fmt.Sprintf("root_cause_at(%q, ConsoleMsg, Source, Cause, Ts).", sessionID)) - if len(rootCauses) == 0 { - rootCauses = queryToRows(ctx, t.engine, fmt.Sprintf("root_cause(%q, ConsoleMsg, Source, Cause).", sessionID)) - } - failedReqs := queryToRows(ctx, t.engine, fmt.Sprintf("failed_request_at(%q, ReqId, Url, Status, ReqTs).", sessionID)) - if len(failedReqs) == 0 { - failedReqs = queryToRows(ctx, t.engine, fmt.Sprintf("failed_request(%q, ReqId, Url, Status).", sessionID)) - } - slowApis := queryToRows(ctx, t.engine, fmt.Sprintf("slow_api_at(%q, ReqId, Url, Duration, ReqTs).", sessionID)) - if len(slowApis) == 0 { - slowApis = queryToRows(ctx, t.engine, fmt.Sprintf("slow_api(%q, ReqId, Url, Duration).", sessionID)) - } - blockingIssues := filterRowsByField(queryToRows(ctx, t.engine, "interaction_blocked(SessionId, Reason)."), "SessionId", sessionID) - userVisibleErrors := queryToRows(ctx, t.engine, fmt.Sprintf("user_visible_error(%q, Source, Message, Timestamp).", sessionID)) - actionCandidates := queryActionCandidates(ctx, t.engine, sessionID, maxItems) - - if effectiveSinceMs > 0 { - rootCauses = filterRowsSince(rootCauses, []string{"Ts", "Timestamp"}, effectiveSinceMs) - failedReqs = filterRowsSince(failedReqs, []string{"ReqTs", "Timestamp"}, effectiveSinceMs) - slowApis = filterRowsSince(slowApis, []string{"ReqTs", "Timestamp"}, effectiveSinceMs) - userVisibleErrors = filterRowsSince(userVisibleErrors, []string{"Timestamp", "Ts"}, effectiveSinceMs) - } - userVisibleErrors = dedupeUserVisibleErrors(userVisibleErrors) - - contradictions := detectContradictions(ctx, t.engine, sessionID) - - confidence := computeReasonConfidence(len(rootCauses), len(failedReqs), len(slowApis), len(contradictions), topic) - status := "ok" - if len(failedReqs) > 0 || len(contradictions) > 0 || len(rootCauses) > 0 { - status = "error" - } else if len(slowApis) > 0 || len(blockingIssues) > 0 { - status = "warning" - } - - baseOrigin := originFromURL(resolveCurrentURL(ctx, t.engine, sessionID)) - recommendations := recommendNextActions(sessionID, topic, status, len(failedReqs), len(rootCauses), len(contradictions), confidence) - if includeActionPlan { - recommendations = append(buildActionPlanRecommendations(actionCandidates, maxRecommendations, sessionID, baseOrigin), recommendations...) - } - recommendations = limitMapSlice(recommendations, maxRecommendations) - topErrors := buildReasonTopErrors(sessionID, failedReqs, rootCauses, userVisibleErrors, blockingIssues, slowApis, maxInt(6, minInt(maxItems, 20))) - investigationItems := buildInvestigationItems(sessionID, topErrors, minInt(maxItems, 10)) - - data := map[string]interface{}{ - "top_errors": topErrors, - "investigation_items": investigationItems, - "failed_requests": failedReqs, - "root_causes": rootCauses, - "slow_apis": slowApis, - "blocking_issues": blockingIssues, - "user_visible_errors": userVisibleErrors, - "contradictions": contradictions, - "action_candidates": actionCandidates, - "recommendations": recommendations, - } - if topic == "what_changed_since" { - data["changes"] = buildReasonChangeFeed(rootCauses, failedReqs, slowApis, userVisibleErrors, blockingIssues, maxItems) - } - - handles := []string{ - "reason:" + sessionID + ":top_errors", - "reason:" + sessionID + ":investigation_items", - "reason:" + sessionID + ":failed_requests", - "reason:" + sessionID + ":root_causes", - "reason:" + sessionID + ":slow_apis", - "reason:" + sessionID + ":blocking_issues", - "reason:" + sessionID + ":user_visible_errors", - "reason:" + sessionID + ":contradictions", - "reason:" + sessionID + ":action_candidates", - "reason:" + sessionID + ":recommendations", - } - if topic == "what_changed_since" { - handles = append(handles, "reason:"+sessionID+":changes") - } - selectedData := applyHandleFilter(data, args["expand_handles"]) - - emitFacts := []mangle.Fact{ - { - Predicate: "confidence_score", - Args: []interface{}{sessionID, topic, int(math.Round(confidence * 100.0)), time.Now().UnixMilli()}, - Timestamp: time.Now(), - }, - } - if confidence < 0.70 { - emitFacts = append(emitFacts, mangle.Fact{ - Predicate: "js_gate_open", - Args: []interface{}{sessionID, "low_confidence", time.Now().UnixMilli()}, - Timestamp: time.Now(), - }) - } - if len(contradictions) > 0 { - emitFacts = append(emitFacts, mangle.Fact{ - Predicate: "js_gate_open", - Args: []interface{}{sessionID, "contradiction_detected", time.Now().UnixMilli()}, - Timestamp: time.Now(), - }) - } - if len(recommendations) == 0 { - emitFacts = append(emitFacts, mangle.Fact{ - Predicate: "js_gate_open", - Args: []interface{}{sessionID, "no_matching_tool", time.Now().UnixMilli()}, - Timestamp: time.Now(), - }) - } - if len(emitFacts) > 0 { - _ = t.engine.AddFacts(ctx, emitFacts) - } - emitDisclosureFacts(ctx, t.engine, sessionID, handles, "reason") - - response := map[string]interface{}{ - "success": true, - "intent": ternaryStatus(hasIntent, intent, "custom"), - "topic": topic, - "status": status, - "confidence": confidence, - "summary": buildReasonSummary(status, confidence, len(rootCauses), len(failedReqs), len(slowApis), len(contradictions)), - "top_errors": limitMapSlice(topErrors, minInt(5, maxItems)), - "investigation_items": limitMapSlice(investigationItems, minInt(5, maxItems)), - "evidence_handles": handles, - "expansion_suggested": confidence < 0.70 || len(contradictions) > 0, - "view": view, - "time_window_ms": timeWindowMs, - "since_navigation": sinceNavigation, - "navigation_since_ms": navigationSinceMs, - "effective_since_ms": effectiveSinceMs, - } - - switch view { - case "summary": - response["counts"] = map[string]interface{}{ - "root_causes": len(rootCauses), - "failed_requests": len(failedReqs), - "slow_apis": len(slowApis), - "blocking_issues": len(blockingIssues), - "contradictions": len(contradictions), - } - case "compact": - response["data"] = truncateReasonData(selectedData, maxItems) - default: - response["data"] = selectedData - } - - return response, nil -} - -func normalizeProgressiveView(view string) string { - switch strings.ToLower(view) { - case "summary", "compact", "full": - return strings.ToLower(view) - default: - return "compact" - } -} - -func asMap(v interface{}) map[string]interface{} { - if m, ok := v.(map[string]interface{}); ok { - return m - } - return map[string]interface{}{} -} - -func countHiddenElements(hiddenData map[string]interface{}) int { - elems, ok := hiddenData["hidden_elements"].([]interface{}) - if !ok { - return 0 - } - return len(elems) -} - -func compactInteractiveData(data map[string]interface{}, maxItems int) map[string]interface{} { - out := map[string]interface{}{} - if summary, ok := data["summary"]; ok { - out["summary"] = summary - } - if elems, ok := data["elements"].([]interface{}); ok { - out["elements"] = limitAnySlice(elems, maxItems) - out["truncated"] = len(elems) > maxItems - } - return out -} - -func compactHiddenData(data map[string]interface{}, maxItems int) map[string]interface{} { - out := map[string]interface{}{} - if elems, ok := data["hidden_elements"].([]interface{}); ok { - out["hidden_elements"] = limitAnySlice(elems, maxItems) - out["count"] = len(elems) - out["truncated"] = len(elems) > maxItems - } - if summary, ok := data["summary"]; ok { - out["summary"] = summary - } - return out -} - -func compactToastData(data map[string]interface{}, maxItems int) map[string]interface{} { - out := map[string]interface{}{} - if status, ok := data["status"]; ok && status != nil { - out["status"] = status - } - if summary, ok := data["summary"]; ok && summary != nil { - out["summary"] = summary - } - - for _, k := range []string{"error_count", "warning_count", "success_count", "info_count"} { - if v, ok := data[k]; ok && v != nil { - out[k] = v - } - } - - if reps, ok := data["repeated_errors"].([]interface{}); ok && len(reps) > 0 { - out["repeated_errors"] = limitAnySlice(reps, minInt(maxItems, 5)) - } - - // Include a small sample of toasts only if present. - if toasts, ok := data["toasts"].([]map[string]interface{}); ok && len(toasts) > 0 { - limit := minInt(maxItems, 5) - out["toasts"] = limitMapSlice(toasts, limit) - out["toast_count"] = len(toasts) - out["truncated"] = len(toasts) > limit - } else if toasts, ok := data["toasts"].([]interface{}); ok && len(toasts) > 0 { - limit := minInt(maxItems, 5) - out["toasts"] = limitAnySlice(toasts, limit) - out["toast_count"] = len(toasts) - out["truncated"] = len(toasts) > limit - } - - return out -} - -func buildObserveSummary(data map[string]interface{}) string { - parts := make([]string, 0, 4) - if state, ok := data["state"].(map[string]interface{}); ok { - if loading, exists := state["loading"].(bool); exists { - parts = append(parts, fmt.Sprintf("loading=%t", loading)) - } - } - if diag, ok := data["diagnostics"].(map[string]interface{}); ok { - status := strings.TrimSpace(getStringFromMap(diag, "status")) - if status != "" && status != "ok" { - parts = append(parts, "diag="+status) - } - } - if toasts, ok := data["toasts"].(map[string]interface{}); ok { - if errCount := asInt(toasts["error_count"]); errCount > 0 { - parts = append(parts, fmt.Sprintf("toast_err=%d", errCount)) - } - } - if navCounts, ok := data["nav_counts"].(map[string]interface{}); ok { - if total, exists := navCounts["total"]; exists { - parts = append(parts, fmt.Sprintf("links=%v", total)) - } - } else if nav, ok := data["nav"].(map[string]interface{}); ok { - if counts, ok := nav["counts"].(map[string]interface{}); ok { - if total, exists := counts["total"]; exists { - parts = append(parts, fmt.Sprintf("links=%v", total)) - } - } - } - if interSummary, ok := data["interactive_summary"].(map[string]interface{}); ok { - if total, exists := interSummary["total"]; exists { - parts = append(parts, fmt.Sprintf("interactive=%v", total)) - } - } else if inter, ok := data["interactive"].(map[string]interface{}); ok { - if summary, ok := inter["summary"].(map[string]interface{}); ok { - if total, exists := summary["total"]; exists { - parts = append(parts, fmt.Sprintf("interactive=%v", total)) - } - } - } - if candidateCount := asInt(data["action_candidate_count"]); candidateCount > 0 { - parts = append(parts, fmt.Sprintf("candidates=%d", candidateCount)) - } else if candidates, ok := data["action_candidates"].([]map[string]interface{}); ok { - parts = append(parts, fmt.Sprintf("candidates=%d", len(candidates))) - } else if candidatesAny, ok := data["action_candidates"].([]interface{}); ok && len(candidatesAny) > 0 { - parts = append(parts, fmt.Sprintf("candidates=%d", len(candidatesAny))) - } - if len(parts) == 0 { - return "observation complete" - } - return "observation: " + strings.Join(parts, ", ") -} - -type rankedErrorItem struct { - payload map[string]interface{} - severity int - timestamp int64 - dedupeKey string -} - -func buildObserveTopErrors(sessionID string, diagnosticsData, toastData map[string]interface{}, maxItems int) []map[string]interface{} { - items := make([]rankedErrorItem, 0, 16) - - toasts := toMapSlice(toastData["toasts"]) - for _, toast := range toasts { - level := strings.ToLower(strings.TrimSpace(getStringFromMap(toast, "level"))) - if level != "error" && level != "warning" { - continue - } - score := 70 - if level == "error" { - score = 90 - } - message := strings.TrimSpace(getStringFromMap(toast, "text")) - if message == "" { - message = strings.TrimSpace(getStringFromMap(toast, "summary")) - } - if message == "" { - message = "toast notification" - } - source := strings.TrimSpace(getStringFromMap(toast, "source")) - ts := asInt64(toast["timestamp"]) - items = appendRankedError(items, rankedErrorItem{ - severity: score, - timestamp: ts, - dedupeKey: "toast|" + level + "|" + message, - payload: map[string]interface{}{ - "kind": "toast_" + level, - "message": message, - "source": ternaryStatus(source != "", source, "toast"), - "severity": severityLabel(score), - "severity_score": score, - "timestamp": ts, - "evidence_handle": "observe:" + sessionID + ":toasts", - }, - }) - } - - repeated := toStringSlice(toastData["repeated_errors"]) - for _, msg := range repeated { - if strings.TrimSpace(msg) == "" { - continue - } - score := 85 - items = appendRankedError(items, rankedErrorItem{ - severity: score, - timestamp: 0, - dedupeKey: "toast_repeated|" + msg, - payload: map[string]interface{}{ - "kind": "toast_repeated_error", - "message": msg, - "source": "toast", - "severity": severityLabel(score), - "severity_score": score, - "evidence_handle": "observe:" + sessionID + ":toasts", - }, - }) - } - - for _, row := range toMapSlice(diagnosticsData["failed_requests"]) { - status := asInt(row["status"]) - score := 75 - if status >= 500 { - score = 95 - } - url := strings.TrimSpace(fmt.Sprintf("%v", row["url"])) - reqID := strings.TrimSpace(fmt.Sprintf("%v", row["id"])) - ts := asInt64(row["timestamp"]) - msg := fmt.Sprintf("HTTP %d %s", status, url) - if url == "" { - msg = fmt.Sprintf("HTTP %d failed request", status) - } - items = appendRankedError(items, rankedErrorItem{ - severity: score, - timestamp: ts, - dedupeKey: "failed_request|" + reqID + "|" + url + "|" + strconv.Itoa(status), - payload: map[string]interface{}{ - "kind": "failed_request", - "message": msg, - "url": url, - "request_id": reqID, - "status": status, - "severity": severityLabel(score), - "severity_score": score, - "timestamp": ts, - "evidence_handle": "observe:" + sessionID + ":diagnostics", - }, - }) - } - - for _, row := range toMapSlice(diagnosticsData["root_causes"]) { - cause := strings.TrimSpace(fmt.Sprintf("%v", row["Cause"])) - msg := strings.TrimSpace(fmt.Sprintf("%v", row["Msg"])) - if msg == "" { - msg = cause - } - if msg == "" { - msg = "root cause detected" - } - source := strings.TrimSpace(fmt.Sprintf("%v", row["Source"])) - score := 88 - items = appendRankedError(items, rankedErrorItem{ - severity: score, - timestamp: extractTimestamp(row, "Ts", "Timestamp"), - dedupeKey: "root_cause|" + source + "|" + msg + "|" + cause, - payload: map[string]interface{}{ - "kind": "root_cause", - "message": msg, - "cause": cause, - "source": source, - "severity": severityLabel(score), - "severity_score": score, - "timestamp": extractTimestamp(row, "Ts", "Timestamp"), - "evidence_handle": "observe:" + sessionID + ":diagnostics", - }, - }) - } - - for _, row := range toMapSlice(diagnosticsData["slow_apis"]) { - duration := asInt(row["duration"]) - score := 55 - if duration >= 3000 { - score = 65 - } - url := strings.TrimSpace(fmt.Sprintf("%v", row["url"])) - reqID := strings.TrimSpace(fmt.Sprintf("%v", row["id"])) - msg := fmt.Sprintf("Slow API %dms %s", duration, url) - if url == "" { - msg = fmt.Sprintf("Slow API %dms", duration) - } - items = appendRankedError(items, rankedErrorItem{ - severity: score, - timestamp: extractTimestamp(row, "ReqTs", "Timestamp"), - dedupeKey: "slow_api|" + reqID + "|" + url + "|" + strconv.Itoa(duration), - payload: map[string]interface{}{ - "kind": "slow_api", - "message": msg, - "url": url, - "request_id": reqID, - "duration_ms": duration, - "severity": severityLabel(score), - "severity_score": score, - "timestamp": extractTimestamp(row, "ReqTs", "Timestamp"), - "evidence_handle": "observe:" + sessionID + ":diagnostics", - }, - }) - } - - return finalizeRankedErrors(items, maxItems) -} - -func buildReasonTopErrors( - sessionID string, - failedReqs []map[string]interface{}, - rootCauses []map[string]interface{}, - userVisibleErrors []map[string]interface{}, - blockingIssues []map[string]interface{}, - slowApis []map[string]interface{}, - maxItems int, -) []map[string]interface{} { - items := make([]rankedErrorItem, 0, 24) - - for _, row := range failedReqs { - status := asInt(row["Status"]) - score := 78 - if status >= 500 { - score = 96 - } - url := strings.TrimSpace(fmt.Sprintf("%v", row["Url"])) - reqID := strings.TrimSpace(fmt.Sprintf("%v", row["ReqId"])) - msg := fmt.Sprintf("HTTP %d %s", status, url) - if url == "" { - msg = fmt.Sprintf("HTTP %d failed request", status) - } - ts := extractTimestamp(row, "ReqTs", "Timestamp") - items = appendRankedError(items, rankedErrorItem{ - severity: score, - timestamp: ts, - dedupeKey: "failed_request|" + reqID + "|" + url + "|" + strconv.Itoa(status), - payload: map[string]interface{}{ - "kind": "failed_request", - "message": msg, - "url": url, - "request_id": reqID, - "status": status, - "severity": severityLabel(score), - "severity_score": score, - "timestamp": ts, - "evidence_handle": "reason:" + sessionID + ":failed_requests", - }, - }) - } - - for _, row := range rootCauses { - cause := strings.TrimSpace(fmt.Sprintf("%v", row["Cause"])) - msg := strings.TrimSpace(fmt.Sprintf("%v", row["ConsoleMsg"])) - if msg == "" { - msg = cause - } - if msg == "" { - msg = "root cause detected" - } - source := strings.TrimSpace(fmt.Sprintf("%v", row["Source"])) - ts := extractTimestamp(row, "Ts", "Timestamp") - score := 90 - items = appendRankedError(items, rankedErrorItem{ - severity: score, - timestamp: ts, - dedupeKey: "root_cause|" + source + "|" + msg + "|" + cause, - payload: map[string]interface{}{ - "kind": "root_cause", - "message": msg, - "cause": cause, - "source": source, - "severity": severityLabel(score), - "severity_score": score, - "timestamp": ts, - "evidence_handle": "reason:" + sessionID + ":root_causes", - }, - }) - } - - for _, row := range userVisibleErrors { - source := strings.TrimSpace(fmt.Sprintf("%v", row["Source"])) - msg := strings.TrimSpace(fmt.Sprintf("%v", row["Message"])) - if msg == "" { - msg = "user-visible error" - } - ts := extractTimestamp(row, "Timestamp", "Ts") - kind, score := classifyUserVisibleError(source, msg) - fingerprint := normalizeErrorFingerprint(msg) - if fingerprint == "" { - fingerprint = strings.ToLower(strings.TrimSpace(msg)) - } - items = appendRankedError(items, rankedErrorItem{ - severity: score, - timestamp: ts, - dedupeKey: "user_visible|" + strings.ToLower(strings.TrimSpace(source)) + "|" + fingerprint, - payload: map[string]interface{}{ - "kind": kind, - "message": msg, - "source": source, - "severity": severityLabel(score), - "severity_score": score, - "timestamp": ts, - "evidence_handle": "reason:" + sessionID + ":user_visible_errors", - }, - }) - } - - for _, row := range blockingIssues { - reason := strings.TrimSpace(fmt.Sprintf("%v", row["Reason"])) - if reason == "" { - reason = "interaction blocked" - } - score := 82 - items = appendRankedError(items, rankedErrorItem{ - severity: score, - timestamp: extractTimestamp(row, "Timestamp", "Ts"), - dedupeKey: "blocking|" + reason, - payload: map[string]interface{}{ - "kind": "blocking_issue", - "message": reason, - "severity": severityLabel(score), - "severity_score": score, - "timestamp": extractTimestamp(row, "Timestamp", "Ts"), - "evidence_handle": "reason:" + sessionID + ":blocking_issues", - }, - }) - } - - for _, row := range slowApis { - duration := asInt(row["Duration"]) - score := 50 - if duration >= 3000 { - score = 64 - } - url := strings.TrimSpace(fmt.Sprintf("%v", row["Url"])) - reqID := strings.TrimSpace(fmt.Sprintf("%v", row["ReqId"])) - msg := fmt.Sprintf("Slow API %dms %s", duration, url) - if url == "" { - msg = fmt.Sprintf("Slow API %dms", duration) - } - ts := extractTimestamp(row, "ReqTs", "Timestamp") - items = appendRankedError(items, rankedErrorItem{ - severity: score, - timestamp: ts, - dedupeKey: "slow_api|" + reqID + "|" + url + "|" + strconv.Itoa(duration), - payload: map[string]interface{}{ - "kind": "slow_api", - "message": msg, - "url": url, - "request_id": reqID, - "duration_ms": duration, - "severity": severityLabel(score), - "severity_score": score, - "timestamp": ts, - "evidence_handle": "reason:" + sessionID + ":slow_apis", - }, - }) - } - - return finalizeRankedErrors(items, maxItems) -} - -func buildInvestigationItems(sessionID string, topErrors []map[string]interface{}, maxItems int) []map[string]interface{} { - if maxItems <= 0 { - maxItems = 5 - } - items := make([]map[string]interface{}, 0, minInt(len(topErrors), maxItems)) - for idx, errRow := range topErrors { - if idx >= maxItems { - break - } - message := strings.TrimSpace(fmt.Sprintf("%v", errRow["message"])) - if message == "" { - message = "investigate issue" - } - evidenceHandle := strings.TrimSpace(fmt.Sprintf("%v", errRow["evidence_handle"])) - items = append(items, map[string]interface{}{ - "priority": idx + 1, - "severity": errRow["severity"], - "kind": errRow["kind"], - "issue": message, - "evidence_handle": evidenceHandle, - "next_step": buildInvestigationStep(sessionID, errRow), - }) - } - return items -} - -func buildInvestigationStep(sessionID string, errRow map[string]interface{}) map[string]interface{} { - kind := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", errRow["kind"]))) - switch kind { - case "failed_request": - return map[string]interface{}{ - "tool": "browser-mangle", - "args": map[string]interface{}{ - "operation": "query", - "query": fmt.Sprintf("failed_request(%q, ReqId, Url, Status).", sessionID), - "view": "compact", - "max_items": 20, - }, - } - case "root_cause": - return map[string]interface{}{ - "tool": "browser-mangle", - "args": map[string]interface{}{ - "operation": "query", - "query": fmt.Sprintf("root_cause_at(%q, ConsoleMsg, Source, Cause, Ts).", sessionID), - "view": "compact", - "max_items": 20, - }, - } - case "compiler_error", "console_error": - return map[string]interface{}{ - "tool": "browser-mangle", - "args": map[string]interface{}{ - "operation": "query", - "query": fmt.Sprintf("console_event(%q, \"error\", Msg, Ts).", sessionID), - "view": "compact", - "max_items": 20, - }, - } - case "user_visible_error": - source := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", errRow["source"]))) - if source == "console" { - return map[string]interface{}{ - "tool": "browser-mangle", - "args": map[string]interface{}{ - "operation": "query", - "query": fmt.Sprintf("console_event(%q, \"error\", Msg, Ts).", sessionID), - "view": "compact", - "max_items": 20, - }, - } - } - fallthrough - case "toast_error", "toast_warning", "toast_repeated_error": - return map[string]interface{}{ - "tool": "browser-mangle", - "args": map[string]interface{}{ - "operation": "query", - "query": fmt.Sprintf("toast_notification(%q, Text, Level, Source, Timestamp).", sessionID), - "view": "compact", - "max_items": 20, - }, - } - case "blocking_issue": - return map[string]interface{}{ - "tool": "browser-observe", - "args": map[string]interface{}{ - "session_id": sessionID, - "mode": "interactive", - "view": "compact", - "include_diagnostics": true, - "include_action_plan": true, - "max_recommendations": 3, - "max_items": 20, - }, - } - case "slow_api": - return map[string]interface{}{ - "tool": "browser-mangle", - "args": map[string]interface{}{ - "operation": "query", - "query": fmt.Sprintf("slow_api_at(%q, ReqId, Url, Duration, ReqTs).", sessionID), - "view": "compact", - "max_items": 20, - }, - } - default: - return map[string]interface{}{ - "tool": "browser-reason", - "args": map[string]interface{}{ - "session_id": sessionID, - "topic": "why_failed", - "view": "compact", - "max_items": 20, - "time_window_ms": defaultReasonTimeWindowMs, - }, - } - } -} - -func appendRankedError(items []rankedErrorItem, item rankedErrorItem) []rankedErrorItem { - if item.payload == nil { - item.payload = map[string]interface{}{} - } - if item.payload["severity"] == nil { - item.payload["severity"] = severityLabel(item.severity) - } - item.payload["severity_score"] = item.severity - if item.payload["timestamp"] == nil && item.timestamp > 0 { - item.payload["timestamp"] = item.timestamp - } - return append(items, item) -} - -func finalizeRankedErrors(items []rankedErrorItem, maxItems int) []map[string]interface{} { - if len(items) == 0 { - return []map[string]interface{}{} - } - if maxItems <= 0 { - maxItems = 10 - } - - sort.SliceStable(items, func(i, j int) bool { - if items[i].severity != items[j].severity { - return items[i].severity > items[j].severity - } - if items[i].timestamp != items[j].timestamp { - return items[i].timestamp > items[j].timestamp - } - msgI := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", items[i].payload["message"]))) - msgJ := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", items[j].payload["message"]))) - return msgI < msgJ - }) - - seen := make(map[string]bool, len(items)) - out := make([]map[string]interface{}, 0, minInt(maxItems, len(items))) - for _, item := range items { - key := strings.TrimSpace(item.dedupeKey) - if key != "" { - if seen[key] { - continue - } - seen[key] = true - } - out = append(out, item.payload) - if len(out) >= maxItems { - break - } - } - return out -} - -func severityLabel(score int) string { - switch { - case score >= 90: - return "critical" - case score >= 75: - return "high" - case score >= 55: - return "medium" - default: - return "low" - } -} - -func normalizeErrorFingerprint(message string) string { - return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(message)), " ")) -} - -func classifyUserVisibleError(source, message string) (string, int) { - src := strings.ToLower(strings.TrimSpace(source)) - fp := normalizeErrorFingerprint(message) - if fp == "" { - fp = strings.ToLower(strings.TrimSpace(message)) - } - - compilerSignals := []string{ - "module not found", - "can't resolve", - "cannot resolve", - "cannot find module", - "failed to compile", - "compilation failed", - "import trace", - "./src/", - "webpack", - "nextjs.org/docs/messages/module-not-found", - } - for _, signal := range compilerSignals { - if strings.Contains(fp, signal) { - return "compiler_error", 99 - } - } - - switch src { - case "console": - return "console_error", 92 - case "toast": - return "toast_error", 84 - default: - return "user_visible_error", 88 - } -} - -func dedupeUserVisibleErrors(rows []map[string]interface{}) []map[string]interface{} { - if len(rows) == 0 { - return []map[string]interface{}{} - } - - type agg struct { - source string - message string - count int - firstTs int64 - lastTs int64 - } - - byKey := make(map[string]*agg, len(rows)) - for _, row := range rows { - source := strings.TrimSpace(fmt.Sprintf("%v", row["Source"])) - if source == "" { - source = strings.TrimSpace(fmt.Sprintf("%v", row["source"])) - } - message := strings.TrimSpace(fmt.Sprintf("%v", row["Message"])) - if message == "" { - message = strings.TrimSpace(fmt.Sprintf("%v", row["message"])) - } - if message == "" { - continue - } - - fp := normalizeErrorFingerprint(message) - if fp == "" { - continue - } - sourceKey := strings.ToLower(strings.TrimSpace(source)) - dedupeKey := sourceKey + "|" + fp - ts := extractTimestamp(row, "Timestamp", "Ts") - - existing, ok := byKey[dedupeKey] - if !ok { - byKey[dedupeKey] = &agg{ - source: source, - message: message, - count: 1, - firstTs: ts, - lastTs: ts, - } - continue - } - - existing.count++ - if ts > 0 && (existing.firstTs == 0 || ts < existing.firstTs) { - existing.firstTs = ts - } - if ts > existing.lastTs { - existing.lastTs = ts - } - } - - out := make([]map[string]interface{}, 0, len(byKey)) - for _, item := range byKey { - row := map[string]interface{}{ - "Source": item.source, - "Message": item.message, - "Timestamp": item.lastTs, - "Count": item.count, - } - if item.firstTs > 0 { - row["FirstTimestamp"] = item.firstTs - } - if item.lastTs > 0 { - row["LastTimestamp"] = item.lastTs - } - out = append(out, row) - } - - sort.SliceStable(out, func(i, j int) bool { - tsI := extractTimestamp(out[i], "Timestamp", "LastTimestamp") - tsJ := extractTimestamp(out[j], "Timestamp", "LastTimestamp") - if tsI != tsJ { - return tsI > tsJ - } - countI := asInt(out[i]["Count"]) - countJ := asInt(out[j]["Count"]) - if countI != countJ { - return countI > countJ - } - msgI := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", out[i]["Message"]))) - msgJ := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", out[j]["Message"]))) - return msgI < msgJ - }) - - return out -} - -func toMapSlice(value interface{}) []map[string]interface{} { - if value == nil { - return []map[string]interface{}{} - } - if rows, ok := value.([]map[string]interface{}); ok { - return rows - } - rawRows, ok := value.([]interface{}) - if !ok { - return []map[string]interface{}{} - } - rows := make([]map[string]interface{}, 0, len(rawRows)) - for _, raw := range rawRows { - if row, ok := raw.(map[string]interface{}); ok { - rows = append(rows, row) - } - } - return rows -} - -func toStringSlice(value interface{}) []string { - if value == nil { - return []string{} - } - if rows, ok := value.([]string); ok { - return rows - } - rawRows, ok := value.([]interface{}) - if !ok { - return []string{} - } - rows := make([]string, 0, len(rawRows)) - for _, raw := range rawRows { - msg := strings.TrimSpace(fmt.Sprintf("%v", raw)) - if msg != "" { - rows = append(rows, msg) - } - } - return rows -} - -func extractTimestamp(row map[string]interface{}, keys ...string) int64 { - for _, key := range keys { - if key == "" { - continue - } - if val, ok := row[key]; ok { - ts := asInt64(val) - if ts > 0 { - return ts - } - } - } - return 0 -} - -func queryToRows(ctx context.Context, engine *mangle.Engine, query string) []map[string]interface{} { - results, err := engine.Query(ctx, query) - if err != nil { - return []map[string]interface{}{} - } - rows := make([]map[string]interface{}, 0, len(results)) - for _, r := range results { - row := make(map[string]interface{}, len(r)) - for k, v := range r { - row[k] = v - } - rows = append(rows, row) - } - return rows -} - -func latestNavigationTimestamp(ctx context.Context, engine *mangle.Engine, sessionID string) int64 { - if engine == nil || strings.TrimSpace(sessionID) == "" { - return 0 - } - rows := queryToRows(ctx, engine, fmt.Sprintf("navigation_event(%q, Url, Timestamp).", sessionID)) - latest := int64(0) - for _, row := range rows { - ts := extractTimestamp(row, "Timestamp", "Ts", "TNav") - if ts > latest { - latest = ts - } - } - return latest -} - -func detectContradictions(ctx context.Context, engine *mangle.Engine, sessionID string) []map[string]interface{} { - contradictions := make([]map[string]interface{}, 0) - if engine == nil || sessionID == "" { - return contradictions - } - - // failed_request is derived; query the store, scoped to the session. - failedRows := queryToRows(ctx, engine, fmt.Sprintf("failed_request(%q, ReqId, Url, Status).", sessionID)) - if len(failedRows) == 0 { - return contradictions - } - - // toast_notification is a base event stored in the temporal buffer. - toasts := engine.FactsByPredicate("toast_notification") - successToastCount := 0 - for _, t := range toasts { - // toast_notification(SessionId, Text, Level, Source, Timestamp) - if len(t.Args) < 3 { - continue - } - if fmt.Sprintf("%v", t.Args[0]) != sessionID { - continue - } - level := fmt.Sprintf("%v", t.Args[2]) - if level == "success" { - successToastCount++ - } - } - - if successToastCount > 0 { - contradictions = append(contradictions, map[string]interface{}{ - "type": "success_toast_with_failed_requests", - "failed_request_count": len(failedRows), - "success_toast_count": successToastCount, - "confidence_impact_delta": -0.25, - }) - } - - return contradictions -} - -func computeReasonConfidence(rootCauses, failedReqs, slowApis, contradictions int, topic string) float64 { - score := 0.95 - if failedReqs > 0 { - score = 0.80 - } - if rootCauses > 0 { - score += 0.08 - } - if slowApis > 0 && failedReqs == 0 { - score -= 0.10 - } - if contradictions > 0 { - score -= 0.25 - } - if topic == "next_best_action" && rootCauses == 0 && failedReqs == 0 && contradictions == 0 { - score -= 0.20 - } - return math.Max(0.10, math.Min(0.99, score)) -} - -func recommendNextActions(sessionID, topic, status string, failedReqs, rootCauses, contradictions int, confidence float64) []map[string]interface{} { - _ = topic - recs := make([]map[string]interface{}, 0, 3) - if status == "ok" { - recs = append(recs, map[string]interface{}{ - "tool": "browser-observe", - "reason": "No critical issues detected; continue with focused observation.", - "args": map[string]interface{}{ - "session_id": sessionID, - "mode": "interactive", - "view": "compact", - }, - }) - } - if failedReqs > 0 || rootCauses > 0 { - recs = append(recs, map[string]interface{}{ - "tool": "browser-reason", - "reason": "Expand failure evidence for targeted remediation.", - "args": map[string]interface{}{ - "session_id": sessionID, - "topic": "why_failed", - "view": "full", - }, - }) - } - if contradictions > 0 { - recs = append(recs, map[string]interface{}{ - "tool": "browser-act", - "reason": "Contradiction detected; JS inspection is now permitted.", - "args": map[string]interface{}{ - "session_id": sessionID, - "operations": []map[string]interface{}{ - {"type": "js", "gate_reason": "contradiction_detected"}, - }, - }, - }) - } - if confidence < 0.70 { - recs = append(recs, map[string]interface{}{ - "tool": "browser-act", - "reason": "Low confidence reasoning result; permit targeted JS fallback.", - "args": map[string]interface{}{ - "session_id": sessionID, - "operations": []map[string]interface{}{ - {"type": "js", "gate_reason": "low_confidence"}, - }, - }, - }) - } - return recs -} - -func buildReasonSummary(status string, confidence float64, rootCauses, failedReqs, slowApis, contradictions int) string { - return fmt.Sprintf( - "status=%s confidence=%.2f root_causes=%d failed_requests=%d slow_apis=%d contradictions=%d", - status, - confidence, - rootCauses, - failedReqs, - slowApis, - contradictions, - ) -} - -func truncateReasonData(data map[string]interface{}, maxItems int) map[string]interface{} { - out := make(map[string]interface{}, len(data)) - for k, v := range data { - switch rows := v.(type) { - case []map[string]interface{}: - out[k] = limitMapSlice(rows, maxItems) - default: - out[k] = v - } - } - return out -} - -func applyHandleFilter(data map[string]interface{}, rawHandles interface{}) map[string]interface{} { - raw, ok := rawHandles.([]interface{}) - if !ok || len(raw) == 0 { - return data - } - - selected := make(map[string]bool) - for _, h := range raw { - handle := strings.ToLower(fmt.Sprintf("%v", h)) - switch { - case strings.Contains(handle, "top_errors"): - selected["top_errors"] = true - case strings.Contains(handle, "investigation_items"): - selected["investigation_items"] = true - case strings.Contains(handle, "failed_requests"): - selected["failed_requests"] = true - case strings.Contains(handle, "root_causes"): - selected["root_causes"] = true - case strings.Contains(handle, "slow_apis"): - selected["slow_apis"] = true - case strings.Contains(handle, "blocking_issues"): - selected["blocking_issues"] = true - case strings.Contains(handle, "contradictions"): - selected["contradictions"] = true - case strings.Contains(handle, "action_candidates"): - selected["action_candidates"] = true - case strings.Contains(handle, "recommendations"): - selected["recommendations"] = true - case strings.Contains(handle, "user_visible_errors"): - selected["user_visible_errors"] = true - case strings.Contains(handle, "changes"): - selected["changes"] = true - } - } - if len(selected) == 0 { - return data - } - - filtered := map[string]interface{}{} - for k, v := range data { - if selected[k] { - filtered[k] = v - } - } - return filtered -} - -func emitDisclosureFacts(ctx context.Context, engine *mangle.Engine, sessionID string, handles []string, reason string) { - if engine == nil || sessionID == "" || len(handles) == 0 { - return - } - now := time.Now() - facts := make([]mangle.Fact, 0, len(handles)) - for _, h := range handles { - facts = append(facts, mangle.Fact{ - Predicate: "disclosure_handle", - Args: []interface{}{sessionID, h, reason, now.UnixMilli()}, - Timestamp: now, - }) - } - _ = engine.AddFacts(ctx, facts) -} - -func hasRecentGateFact(engine *mangle.Engine, predicate, sessionID, matchValue string, ttl time.Duration) bool { - if engine == nil { - return false - } - facts := engine.FactsByPredicate(predicate) - cutoff := time.Now().Add(-ttl) - for i := len(facts) - 1; i >= 0; i-- { - f := facts[i] - if f.Timestamp.Before(cutoff) { - continue - } - if len(f.Args) < 2 { - continue - } - if fmt.Sprintf("%v", f.Args[0]) != sessionID { - continue - } - if fmt.Sprintf("%v", f.Args[1]) == matchValue { - return true - } - } - return false -} - -type observeIntentDefaults struct { - mode string - view string - maxItems int - filter string - visibleOnly bool - internalOnly bool - includeActionPlan bool - includeDiagnostics bool - maxRecommendations int -} - -func normalizeObserveIntent(intent string) string { - return strings.ToLower(strings.TrimSpace(intent)) -} - -func resolveObserveIntentDefaults(intent string) (observeIntentDefaults, bool) { - switch intent { - case "quick_status": - return observeIntentDefaults{ - mode: "state", - view: "summary", - maxItems: 5, - filter: "all", - visibleOnly: true, - internalOnly: false, - includeActionPlan: false, - includeDiagnostics: true, - maxRecommendations: 2, - }, true - case "find_actions": - return observeIntentDefaults{ - mode: "interactive", - view: "compact", - maxItems: 12, - filter: "all", - visibleOnly: true, - internalOnly: false, - includeActionPlan: true, - includeDiagnostics: false, - maxRecommendations: defaultObserveMaxRecs, - }, true - case "map_navigation": - return observeIntentDefaults{ - mode: "nav", - view: "compact", - maxItems: 20, - filter: "all", - visibleOnly: true, - internalOnly: true, - includeActionPlan: false, - includeDiagnostics: false, - maxRecommendations: defaultObserveMaxRecs, - }, true - case "hidden_content": - return observeIntentDefaults{ - mode: "hidden", - view: "compact", - maxItems: 20, - filter: "all", - visibleOnly: true, - internalOnly: false, - includeActionPlan: false, - includeDiagnostics: false, - maxRecommendations: defaultObserveMaxRecs, - }, true - case "deep_audit": - return observeIntentDefaults{ - mode: "composite", - view: "full", - maxItems: 50, - filter: "all", - visibleOnly: true, - internalOnly: false, - includeActionPlan: true, - includeDiagnostics: true, - maxRecommendations: defaultReasonMaxRecs, - }, true - case "check_sessions": - return observeIntentDefaults{ - mode: "sessions", - view: "compact", - maxItems: 20, - filter: "all", - visibleOnly: true, - internalOnly: false, - includeActionPlan: false, - includeDiagnostics: false, - maxRecommendations: 0, - }, true - case "visual_check": - return observeIntentDefaults{ - mode: "screenshot", - view: "compact", - maxItems: 1, - filter: "all", - visibleOnly: true, - internalOnly: false, - includeActionPlan: false, - includeDiagnostics: false, - maxRecommendations: 0, - }, true - case "grid_hunt": - return observeIntentDefaults{ - mode: "grids", - view: "compact", - maxItems: 12, - filter: "all", - visibleOnly: true, - internalOnly: false, - includeActionPlan: false, - includeDiagnostics: false, - maxRecommendations: 0, - }, true - default: - return observeIntentDefaults{}, false - } -} - -type reasonIntentDefaults struct { - topic string - view string -} - -func normalizeReasonIntent(intent string) string { - return strings.ToLower(strings.TrimSpace(intent)) -} - -func resolveReasonIntentDefaults(intent string) (reasonIntentDefaults, bool) { - switch intent { - case "triage": - return reasonIntentDefaults{topic: "health", view: "compact"}, true - case "act_now": - return reasonIntentDefaults{topic: "next_best_action", view: "compact"}, true - case "debug_failure": - return reasonIntentDefaults{topic: "why_failed", view: "full"}, true - case "unblock": - return reasonIntentDefaults{topic: "blocking_issue", view: "compact"}, true - default: - return reasonIntentDefaults{}, false - } -} - -func argPresent(args map[string]interface{}, key string) bool { - _, ok := args[key] - return ok -} - -func argHasNonEmptyString(args map[string]interface{}, key string) bool { - raw, ok := args[key] - if !ok { - return false - } - value, ok := raw.(string) - if !ok { - return false - } - return strings.TrimSpace(value) != "" -} - -func argHasInt(args map[string]interface{}, key string) bool { - raw, ok := args[key] - if !ok { - return false - } - switch raw.(type) { - case int, int8, int16, int32, int64, float32, float64: - return true - default: - return false - } -} - -func suggestObserveNextStep(sessionID string, data map[string]interface{}, mode, view string, recommendations []map[string]interface{}) map[string]interface{} { - mode = strings.ToLower(strings.TrimSpace(mode)) - view = strings.ToLower(strings.TrimSpace(view)) - if state, ok := data["state"].(map[string]interface{}); ok { - if loading, exists := state["loading"].(bool); exists && loading { - return map[string]interface{}{ - "tool": "browser-act", - "args": map[string]interface{}{ - "session_id": sessionID, - "operations": []map[string]interface{}{ - {"type": "await_stable", "timeout_ms": 10000}, - }, - }, - } - } - } - - if diag, ok := data["diagnostics"].(map[string]interface{}); ok { - status := strings.TrimSpace(getStringFromMap(diag, "status")) - if status == "error" { - return map[string]interface{}{ - "tool": "browser-reason", - "args": map[string]interface{}{ - "session_id": sessionID, - "topic": "why_failed", - "view": "compact", - }, - } - } - if status == "warning" { - return map[string]interface{}{ - "tool": "browser-reason", - "args": map[string]interface{}{ - "session_id": sessionID, - "topic": "health", - "view": "compact", - }, - } - } - } - - if len(recommendations) > 0 { - first := recommendations[0] - next := map[string]interface{}{} - toolName := strings.TrimSpace(getStringFromMap(first, "tool")) - if toolName != "" { - next["tool"] = toolName - } - if args, ok := first["args"].(map[string]interface{}); ok { - if toolRequiresSessionID(toolName) && sessionID != "" { - args["session_id"] = sessionID - } - next["args"] = args - } - if reason, ok := first["reason"].(string); ok { - next["reason"] = reason - } - if len(next) > 0 { - return next - } - } - - if interactive, ok := data["interactive"].(map[string]interface{}); ok { - if summary, ok := interactive["summary"].(map[string]interface{}); ok { - if total := asInt(summary["total"]); total > 0 { - return map[string]interface{}{ - "tool": "browser-reason", - "args": map[string]interface{}{ - "session_id": sessionID, - "topic": "next_best_action", - "view": "compact", - }, - } - } - // If we have *no* visible interactive elements, expand scope before falling back to JS. - if asInt(summary["total"]) == 0 { - return map[string]interface{}{ - "tool": "browser-observe", - "args": map[string]interface{}{ - "session_id": sessionID, - "mode": "hidden", - "view": "compact", - "max_items": 20, - "emit_facts": true, - "internal_only": false, - }, - } - } - } - } - if interSummary, ok := data["interactive_summary"].(map[string]interface{}); ok { - if total := asInt(interSummary["total"]); total > 0 { - return map[string]interface{}{ - "tool": "browser-reason", - "args": map[string]interface{}{ - "session_id": sessionID, - "topic": "next_best_action", - "view": "compact", - }, - } - } - if asInt(interSummary["total"]) == 0 { - return map[string]interface{}{ - "tool": "browser-observe", - "args": map[string]interface{}{ - "session_id": sessionID, - "mode": "hidden", - "view": "compact", - "max_items": 20, - "emit_facts": true, - "internal_only": false, - }, - } - } - } - - if navCounts, ok := data["nav_counts"].(map[string]interface{}); ok { - if total := asInt(navCounts["total"]); total > 0 { - return map[string]interface{}{ - "tool": "browser-observe", - "args": map[string]interface{}{ - "session_id": sessionID, - "mode": "interactive", - "view": "compact", - }, - } - } - } - if nav, ok := data["nav"].(map[string]interface{}); ok { - if counts, ok := nav["counts"].(map[string]interface{}); ok { - if total := asInt(counts["total"]); total > 0 { - return map[string]interface{}{ - "tool": "browser-observe", - "args": map[string]interface{}{ - "session_id": sessionID, - "mode": "interactive", - "view": "compact", - }, - } - } - } - } - - // If the caller didn't request composite, we likely just need more context. - if mode != "" && mode != "composite" { - return map[string]interface{}{ - "tool": "browser-observe", - "args": map[string]interface{}{ - "session_id": sessionID, - "mode": "composite", - "view": "compact", - }, - } - } - - // If composite data still looks empty, a screenshot is often the cheapest way to understand what's happening. - navTotal := -1 - if navCounts, ok := data["nav_counts"].(map[string]interface{}); ok { - navTotal = asInt(navCounts["total"]) - } else if nav, ok := data["nav"].(map[string]interface{}); ok { - if counts, ok := nav["counts"].(map[string]interface{}); ok { - navTotal = asInt(counts["total"]) - } - } - interTotal := -1 - if interSummary, ok := data["interactive_summary"].(map[string]interface{}); ok { - interTotal = asInt(interSummary["total"]) - } else if inter, ok := data["interactive"].(map[string]interface{}); ok { - if summary, ok := inter["summary"].(map[string]interface{}); ok { - interTotal = asInt(summary["total"]) - } - } - if navTotal == 0 && interTotal == 0 { - return map[string]interface{}{ - "tool": "browser-observe", - "args": map[string]interface{}{ - "session_id": sessionID, - "mode": "screenshot", - }, - } - } - - return map[string]interface{}{ - "tool": "browser-reason", - "args": map[string]interface{}{ - "session_id": sessionID, - "topic": "next_best_action", - "view": "compact", - }, - } -} - -func toolRequiresSessionID(tool string) bool { - switch strings.ToLower(strings.TrimSpace(tool)) { - case "attach-session", - "browser-act", - "browser-history", - "browser-mangle", - "browser-observe", - "browser-reason", - "create-session", - "discover-grids", - "discover-hidden-content", - "evaluate-js", - "fill-form", - "fork-session", - "get-interactive-elements", - "get-navigation-links", - "get-page-state", - "interact", - "launch-browser", - "list-sessions", - "navigate-url", - "press-key", - "reify-react", - "screenshot", - "snapshot-dom": - return true - default: - return false - } -} - -func resolveCurrentURL(ctx context.Context, engine *mangle.Engine, sessionID string) string { - if engine == nil || sessionID == "" { - return "" - } - rows := filterRowsByField(queryToRows(ctx, engine, "current_url(SessionId, Url)."), "SessionId", sessionID) - if len(rows) == 0 { - return "" - } - // Prefer the newest binding if there are multiple. - return fmt.Sprintf("%v", rows[len(rows)-1]["Url"]) -} - -func originFromURL(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - u, err := url.Parse(raw) - if err != nil { - return "" - } - if u.Scheme == "" || u.Host == "" { - return "" - } - return u.Scheme + "://" + u.Host -} - -func filterRowsByField(rows []map[string]interface{}, field, expected string) []map[string]interface{} { - if expected == "" { - return rows - } - filtered := make([]map[string]interface{}, 0, len(rows)) - for _, row := range rows { - if fmt.Sprintf("%v", row[field]) == expected { - filtered = append(filtered, row) - } - } - return filtered -} - -func filterRowsSince(rows []map[string]interface{}, timestampFields []string, sinceMs int64) []map[string]interface{} { - if sinceMs <= 0 || len(rows) == 0 { - return rows - } - filtered := make([]map[string]interface{}, 0, len(rows)) - for _, row := range rows { - ts, hasTimestamp := rowTimestampMs(row, timestampFields) - if !hasTimestamp || ts >= sinceMs { - filtered = append(filtered, row) - } - } - return filtered -} - -func rowTimestampMs(row map[string]interface{}, timestampFields []string) (int64, bool) { - for _, field := range timestampFields { - value, exists := row[field] - if !exists { - continue - } - ts := asInt64(value) - if ts > 0 { - return ts, true - } - } - return 0, false -} - -func buildReasonChangeFeed( - rootCauses []map[string]interface{}, - failedReqs []map[string]interface{}, - slowApis []map[string]interface{}, - userVisibleErrors []map[string]interface{}, - blockingIssues []map[string]interface{}, - maxItems int, -) []map[string]interface{} { - changes := make([]map[string]interface{}, 0, len(failedReqs)+len(slowApis)+len(userVisibleErrors)+len(blockingIssues)+len(rootCauses)) - - for _, row := range failedReqs { - changes = append(changes, map[string]interface{}{ - "type": "failed_request", - "key": fmt.Sprintf("%v", row["ReqId"]), - "detail": fmt.Sprintf("%v (%v)", row["Url"], row["Status"]), - "timestamp": asInt64(row["ReqTs"]), - }) - } - for _, row := range slowApis { - changes = append(changes, map[string]interface{}{ - "type": "slow_api", - "key": fmt.Sprintf("%v", row["ReqId"]), - "detail": fmt.Sprintf("%v (%vms)", row["Url"], row["Duration"]), - "timestamp": asInt64(row["ReqTs"]), - }) - } - for _, row := range userVisibleErrors { - changes = append(changes, map[string]interface{}{ - "type": "user_visible_error", - "key": fmt.Sprintf("%v", row["Source"]), - "detail": fmt.Sprintf("%v", row["Message"]), - "timestamp": asInt64(row["Timestamp"]), - }) - } - for _, row := range blockingIssues { - changes = append(changes, map[string]interface{}{ - "type": "blocking_issue", - "key": fmt.Sprintf("%v", row["SessionId"]), - "detail": fmt.Sprintf("%v", row["Reason"]), - "timestamp": 0, - }) - } - for _, row := range rootCauses { - changes = append(changes, map[string]interface{}{ - "type": "root_cause", - "key": fmt.Sprintf("%v", row["Source"]), - "detail": fmt.Sprintf("%v", row["Cause"]), - "timestamp": asInt64(row["Ts"]), - }) - } - - sort.SliceStable(changes, func(i, j int) bool { - return asInt64(changes[i]["timestamp"]) > asInt64(changes[j]["timestamp"]) - }) - - return limitMapSlice(changes, maxItems) -} - -func queryActionCandidates(ctx context.Context, engine *mangle.Engine, sessionID string, maxItems int) []map[string]interface{} { - if engine == nil || strings.TrimSpace(sessionID) == "" { - return []map[string]interface{}{} - } - - type candidate struct { - Action string - Ref string - Label string - Priority int - Reason string - Source string - } - - best := make(map[string]candidate) - - dedupKey := func(isGlobal bool, action, ref, label string) string { - a := strings.ToLower(strings.TrimSpace(action)) - r := strings.ToLower(strings.TrimSpace(ref)) - l := strings.ToLower(strings.TrimSpace(label)) - - if isGlobal { - if a == "" { - return "global|unknown" - } - return "global|" + a - } - - switch a { - case "navigate": - // For navigate, label holds the target href; multiple refs can point to the same href. - if l != "" { - return a + "|" + l - } - if r != "" { - return a + "|" + r - } - return a + "|unknown" - default: - // For clicks/typing/toggling, ref is the stable key. - if r != "" { - return a + "|" + r - } - if l != "" { - return a + "|" + l - } - if a != "" { - return a + "|" + a - } - return "unknown|unknown" - } - } - - upsert := func(isGlobal bool, action, ref, label string, priority int, reason, source string) { - key := dedupKey(isGlobal, action, ref, label) - c := candidate{ - Action: action, - Ref: ref, - Label: label, - Priority: priority, - Reason: reason, - Source: source, - } - if prev, ok := best[key]; ok { - // Keep the highest-priority candidate for the same semantic action. - if c.Priority <= prev.Priority { - return - } - } - best[key] = c - } - - rows := queryToRows(ctx, engine, fmt.Sprintf("action_candidate(%q, Ref, Label, Action, Priority, Reason).", sessionID)) - for _, row := range rows { - upsert(false, - fmt.Sprintf("%v", row["Action"]), - fmt.Sprintf("%v", row["Ref"]), - fmt.Sprintf("%v", row["Label"]), - asInt(row["Priority"]), - fmt.Sprintf("%v", row["Reason"]), - "mangle", - ) - } - - globalRows := queryToRows(ctx, engine, fmt.Sprintf("global_action(%q, Action, Priority, Reason).", sessionID)) - for _, row := range globalRows { - upsert(true, - fmt.Sprintf("%v", row["Action"]), - "", - "", - asInt(row["Priority"]), - fmt.Sprintf("%v", row["Reason"]), - "mangle", - ) - } - - candidates := make([]candidate, 0, len(best)) - for _, c := range best { - candidates = append(candidates, c) - } - - sort.SliceStable(candidates, func(i, j int) bool { - if candidates[i].Priority != candidates[j].Priority { - return candidates[i].Priority > candidates[j].Priority - } - ai := strings.ToLower(strings.TrimSpace(candidates[i].Action)) - aj := strings.ToLower(strings.TrimSpace(candidates[j].Action)) - if ai != aj { - return ai < aj - } - li := strings.ToLower(strings.TrimSpace(candidates[i].Label)) - lj := strings.ToLower(strings.TrimSpace(candidates[j].Label)) - if li != lj { - return li < lj - } - ri := strings.ToLower(strings.TrimSpace(candidates[i].Ref)) - rj := strings.ToLower(strings.TrimSpace(candidates[j].Ref)) - if ri != rj { - return ri < rj - } - reasonI := strings.ToLower(strings.TrimSpace(candidates[i].Reason)) - reasonJ := strings.ToLower(strings.TrimSpace(candidates[j].Reason)) - return reasonI < reasonJ - }) - - out := make([]map[string]interface{}, 0, len(candidates)) - for _, c := range candidates { - out = append(out, map[string]interface{}{ - "action": c.Action, - "ref": c.Ref, - "label": c.Label, - "priority": c.Priority, - "reason": c.Reason, - "source": c.Source, - }) - } - - return limitMapSlice(out, maxItems) -} - -func buildActionPlanRecommendations(candidates []map[string]interface{}, max int, sessionID, baseOrigin string) []map[string]interface{} { - if len(candidates) == 0 { - return nil - } - recs := make([]map[string]interface{}, 0, len(candidates)) - - for _, candidate := range candidates { - action := strings.ToLower(fmt.Sprintf("%v", candidate["action"])) - ref := fmt.Sprintf("%v", candidate["ref"]) - label := fmt.Sprintf("%v", candidate["label"]) - reason := fmt.Sprintf("%v", candidate["reason"]) - priority := asInt(candidate["priority"]) - - var ops []map[string]interface{} - requiresUserInput := false - switch action { - case "navigate": - target := strings.TrimSpace(label) - if target == "" { - continue - } - if strings.HasPrefix(target, "/") && baseOrigin != "" { - target = strings.TrimRight(baseOrigin, "/") + target - } - ops = []map[string]interface{}{ - {"type": "navigate", "url": target, "wait_until": "networkidle"}, - } - case "click": - if strings.TrimSpace(ref) == "" { - continue - } - ops = []map[string]interface{}{ - {"type": "interact", "action": "click", "ref": ref}, - } - case "press_escape": - ops = []map[string]interface{}{ - {"type": "key", "key": "Escape"}, - } - case "type": - if strings.TrimSpace(ref) == "" { - continue - } - suggested := suggestInputValue(label) - if strings.HasPrefix(suggested, "<") { - requiresUserInput = true - } - ops = []map[string]interface{}{ - {"type": "interact", "action": "type", "ref": ref, "value": suggested}, - } - case "select": - if strings.TrimSpace(ref) == "" { - continue - } - requiresUserInput = true - ops = []map[string]interface{}{ - {"type": "interact", "action": "select", "ref": ref, "value": "