Skip to content

feat(example): agent-driven grounded reporting in web_ui_demo — Compose cited reports over MCP - #361

Merged
thewrz merged 7 commits into
mainfrom
feat/issue-353
Jul 4, 2026
Merged

feat(example): agent-driven grounded reporting in web_ui_demo — Compose cited reports over MCP#361
thewrz merged 7 commits into
mainfrom
feat/issue-353

Conversation

@thewrz

@thewrz thewrz commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Why

The MCP contract is complete (ADR-044): every user-facing REST operation is a contract-bound MCP tool, and the demo's server.mjs already auto-derives its tool list from tools/list on every chat. This PR is the demo's flagship demonstration of that — the agent driving grounded report composition — and it showcases SpecR's actual differentiator: deterministic-first, not RAG. The agent doesn't read spec blobs and summarize; it calls grounded tools, gets computed ground truth, and synthesizes a cited narrative.

This is example/POC code under examples/web_ui_demo/ — no changes under src/, and the product still holds no LLM key (the harness lives in the demo bridge).

What

A new Compose tab in the web UI demo:

  • Read-only /report bridge (server.mjs + new pure report-bridge.mjs): runs an OpenAI tool-calling loop over SpecR's MCP tools and streams progress back as newline-delimited JSON — one step per grounded tool call (running → done/error), periodic usage, and a final done carrying the composed narrative + deterministic citations.
  • Compose panel (js/compose.js + HTML/CSS): a request box (with example chips + a loaded-scope hint), a live grounding-step trace on the left, the composed report + a click-through Sources list on the right, a cost/scope meter, a read-only badge, and a disabled Download-PDF affordance.
  • Citations are click-through: each Source chip opens its cited section/paragraph in the existing Report audit pane, reusing the _meta['specr/anchors'] channel and audit.showAnchor — no new anchor scheme, no backend change.

Design decisions

  • PDF is deferred to feat(generator): PDF artifact egress — render specs to PDF sharing the DOCX style + CSI numbering source-of-truth #352 (per orchestrator scope adjustment — PDF egress blocker still open). The "Download PDF" button ships visibly disabled with a tooltip + code comment referencing feat(generator): PDF artifact egress — render specs to PDF sharing the DOCX style + CSI numbering source-of-truth #352. Hence Refs #353, not Closes #353: the demo-side reporting affordance is delivered; the PDF artifact remains for feat(generator): PDF artifact egress — render specs to PDF sharing the DOCX style + CSI numbering source-of-truth #352.
  • Read-only by construction (footgun: human-in-the-loop for writes). The reporting agent is handed only tools the MCP server flags readOnlyHint (ADR-045 tiers, surfaced on the wire as annotations). It physically cannot write/edit during composition. The internal __readOnly flag is stripped before the OpenAI wire. Free-form Ask SpecR chat keeps the read+write tier unchanged.
  • Deterministic citations, not parsed prose (footgun: show the grounding / determinism). Citations are collected from each tool's _meta['specr/anchors'], deduped, and rendered as click-throughs — independent of what the model writes. Only the wording varies between runs, so Regenerate reproduces the same findings.
  • Streaming = NDJSON, not SSE. One JSON object per line over a chunked response — trivial to emit from Node's http and to parse with a ReadableStream reader; avoids SSE framing ceremony. (Non-goal: streaming infra beyond the demo's needs.)
  • Bounded cost (footgun: scope/cost guardrails). The loop caps rounds (8), tool calls (12), and a token budget (~120k), and surfaces a running rounds · calls · ~tokens meter so the "hundreds of DOCX" case can't run away.
  • No "AI just because." Compose appears only where an agent beats a deterministic button — multi-spec / cross-project synthesis, NL slicing, composing several grounded reports. Everything a button already does stays a button.
  • Executed inline, not via subagent fan-out. The bridge, endpoint, client panel, and wiring are tightly coupled and share one design; a fresh zero-context subagent per task would re-derive the whole codebase understanding at high cost with integration friction. Implemented test-first with per-task verification instead. Documented here per the "decide and document" guidance.
  • No ADR added (docs/adr/ is the product/src decision register). These are demo/POC decisions; they live in this PR + the demo README instead.

Testing

  • Unit tests: node --test examples/web_ui_demo/report-bridge.test.mjs — 9 tests green (read-only filter, step labels, dedupe/citations, token estimate, message build, loop + guardrails).
  • Integration test: node --test examples/web_ui_demo/server.report.test.mjs — black-box drives the full /report NDJSON stream against a mock OpenAI + mock MCP; asserts step/usage/done events, deterministic citations, that write tools never reach the model, and that __readOnly is stripped before the OpenAI wire.
  • Manual browser verification (mock OpenAI+MCP, no real key): Compose tab renders; a request streams a grounding step, shows the usage meter, renders the cited narrative + 2 Sources chips; clicking a Source switches to the Report view (verified via DOM state). Screenshot captured during review.
  • Keyless path: /report with no OPENAI_API_KEY emits a single {type:'error', code:'no-key'} line; the panel shows the same "not configured" note as Ask SpecR.
  • Manual end-to-end with a real OpenAI key against the live API + seeded data (citation chips navigating to real paragraphs) — deferred; no key available in this environment.
  • CI green — note: examples/web_ui_demo/ is intentionally outside CI's lint/tsc/vitest scope (pnpm lint/test target src/ only), so the demo's node --test suites run locally, not in CI.

Note: examples/web_ui_demo/ must not require src/ changes — none are included.

🤖 Co-authored by Claude Fable 5. Refs #353.

Summary by CodeRabbit

  • New Features
    • Added a Compose view for agent-driven grounded reporting with streamed step progress, usage meter, cite-as-you-go chips, and “Regenerate” + example prompts (with Download PDF shipped disabled).
    • Added a streaming Report (POST /report) NDJSON endpoint to power Compose with scoped, deterministic grounding output.
  • Bug Fixes
    • Enforced read-only MCP tool usage with deny-by-default blocking for disallowed actions during report generation.
  • Documentation
    • Updated the demo README and added Compose workflow documentation, grounding/citation behavior, and manual verification notes.
  • Tests
    • Added unit and integration coverage for streaming event order, citation determinism, security boundaries, and oversized-request handling.

thewrz and others added 4 commits July 3, 2026 23:40
Read-only tool-calling loop for agent-driven grounded reporting (#353):
derives citations deterministically from each MCP tool's specr/anchors
_meta, filters to readOnlyHint tools, and bounds rounds/tool-calls/tokens.
I/O is injected so the loop is unit-testable without a live API or key.

Refs #353
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…porting

Adds POST /report — an agent-driven grounded-reporting endpoint that streams
its tool-calling steps live (step/usage/done/error NDJSON). Reuses the MCP +
OpenAI plumbing but hands the model only readOnlyHint tools, so the composer
cannot mutate state; the internal __readOnly flag is stripped before the
OpenAI wire. Bounded rounds/tool-calls/token budget guard the corpus case.
Black-box integration test drives the full stream against mock OpenAI + MCP.

Refs #353
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-through citations

Adds the demo's flagship 'agent driving grounded report composition' surface: a
Compose tab whose request box drives the read-only /report loop, streams each
grounded tool call live (running → done/error), renders the composed narrative,
and lists every source as a click-through chip that opens the cited paragraph in
the Report/audit view. Surfaces the cost/scope meter (rounds · calls · tokens),
a read-only badge, and a disabled Download-PDF affordance deferred to #352.

Verified end-to-end in a browser against a mock OpenAI+MCP: streamed steps, cited
narrative, and citation→Report navigation all work.

Refs #353
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Refs #353
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: adbbd855-f80c-4b34-8b24-5937c88c7a4c

📥 Commits

Reviewing files that changed from the base of the PR and between 3c2e395 and 59005f9.

📒 Files selected for processing (1)
  • examples/web_ui_demo/server.report.test.mjs
🚧 Files skipped from review as they are similar to previous changes (1)
  • examples/web_ui_demo/server.report.test.mjs

📝 Walkthrough

Walkthrough

This PR adds agent-driven grounded reporting to examples/web_ui_demo: a bounded read-only reporting loop, a streamed /report endpoint, a new Compose UI, and matching tests and docs.

Changes

Agent-driven grounded reporting feature

Layer / File(s) Summary
Design plan document
docs/superpowers/plans/2026-07-03-agent-driven-grounded-reporting.md
Adds the planning document describing the report bridge, server endpoint, Compose UI, and task checklist.
report-bridge orchestration module and tests
examples/web_ui_demo/report-bridge.mjs, examples/web_ui_demo/report-bridge.test.mjs
Implements the grounded tool-calling loop, read-only tool filtering, citation handling, guardrails, and helper/unit coverage.
Server /report endpoint and tool shaping
examples/web_ui_demo/server.mjs, examples/web_ui_demo/server.report.test.mjs
Adds OpenAI tool shaping, /report NDJSON streaming, request limits, and integration coverage for streaming, blocked tools, and oversized bodies.
Compose browser panel and wiring
examples/web_ui_demo/js/compose.js, examples/web_ui_demo/js/app.js, examples/web_ui_demo/index.html, examples/web_ui_demo/css/app.css
Adds the Compose client, boot/view wiring, HTML panel, citation navigation, and view styling.
Documentation updates
examples/web_ui_demo/.env.example, examples/web_ui_demo/README.md
Documents Compose usage, shared API key behavior, workspace navigation, and PDF state.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant compose.js
  participant server.mjs
  participant report-bridge.mjs
  participant MCP tools

  Browser->>compose.js: submit report request
  compose.js->>server.mjs: POST /report (request, scope)
  server.mjs->>report-bridge.mjs: runReport(...)
  loop bounded rounds
    report-bridge.mjs->>MCP tools: read-only tool call
    MCP tools-->>report-bridge.mjs: result + anchors
    report-bridge.mjs-->>server.mjs: step / usage / done
    server.mjs-->>compose.js: NDJSON event stream
    compose.js-->>Browser: render steps and citations
  end
  report-bridge.mjs-->>server.mjs: reply + citations
  server.mjs-->>compose.js: final done event
  compose.js-->>Browser: render report output
Loading

Possibly related PRs

  • wrzonance/SpecR#324: Shares the same examples/web_ui_demo/server.mjs tool-calling bridge and anchor plumbing that this PR extends for /report.
  • wrzonance/SpecR#330: Uses the same _meta['specr/anchors'] citation contract that the Compose reporting flow consumes for grounded navigation.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the new Compose grounded-reporting workflow in examples/web_ui_demo.
Linked Issues check ✅ Passed The changes match #353: a demo Compose tab, read-only /report bridge, streamed steps, deterministic citations, and PDF deferred to #352.
Out of Scope Changes check ✅ Passed The PR stays within the demo/POC reporting surface and related docs, with no unrelated product-code changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/issue-353

Comment @coderabbitai help to get the list of available commands.

…get (report bridge)

Codex review of #361 found the read-only guarantee was enforced only by
filtering the ADVERTISED tool list — a hallucinated or injection-induced tool
call for a name never advertised was still forwarded to MCP by name, so write
tools could execute (the demo's MCP server exposes read+write by default). Fixes:

- Deny-by-default at the execution boundary: build an allow-list Set of the
  read-only tool names and reject any call outside it before it reaches MCP,
  answering the tool_call_id with an error so the compose turn stays valid.
- Per-call budget check: a single assistant message with many tool calls could
  blow past maxToolCalls because the cap was only checked after the whole batch.
  Now each call checks the budget first; over-budget calls are skipped (no MCP)
  and still answered.

Pinned with regression tests (unit: never reaches MCP / stops mid-batch; and an
end-to-end server test asserting a model-emitted write tool never hits MCP).

Refs #353
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@thewrz
thewrz marked this pull request as ready for review July 4, 2026 13:42

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (2)
examples/web_ui_demo/js/compose.js (1)

65-65: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Inconsistent null-safety for regenBtn.

regenBtn?.addEventListener treats the element as optional, but run()/renderDone() dereference regenBtn.hidden directly without a guard. Either include regenBtn in the line 65 required-elements check or use optional chaining consistently at the two write sites.

Also applies to: 127-127, 152-152, 171-171

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/web_ui_demo/js/compose.js` at line 65, The null-safety around
regenBtn is inconsistent in compose.js: it is treated as optional for
addEventListener but later accessed directly in run() and renderDone() via
regenBtn.hidden. Fix this by making regenBtn part of the required-elements guard
alongside input, runBtn, stepsEl, and outputEl, or by switching the hidden
assignments in run() and renderDone() to optional chaining so all reads/writes
match the same lifecycle.
examples/web_ui_demo/server.report.test.mjs (1)

111-112: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Avoid deterministic test ports.

The PID-based port formula can collide with a local service or parallel test run, and waitForPort() would accept any responder. Allocate a free port before spawning the child.

Suggested fix
-  const demoPort = 3000 + (process.pid % 500) + 7;
+  const demoPort = await getFreePort();
@@
-  const demoPort = 3000 + (process.pid % 500) + 8;
+  const demoPort = await getFreePort();
@@
 async function waitForPort(port) {
@@
 }
+
+async function getFreePort() {
+  const server = createServer();
+  server.listen(0, '127.0.0.1');
+  await once(server, 'listening');
+  const { port } = server.address();
+  await new Promise((resolve, reject) => {
+    server.close((err) => (err ? reject(err) : resolve()));
+  });
+  return port;
+}

Also applies to: 172-172

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/web_ui_demo/server.report.test.mjs` around lines 111 - 112, The
server.report.test.mjs port setup is deterministic and can collide with local
services or parallel runs, so update the test to allocate an unused port before
spawning the child instead of using the PID-based formula. Use the existing test
flow around the demo server launch and waitForPort() to first reserve a free
port, pass that port into the child process, and keep the port selection logic
centralized where demoPort is currently derived.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/superpowers/plans/2026-07-03-agent-driven-grounded-reporting.md`:
- Around line 180-181: Remove the unconditional final deps.callModel(messages,
[]) after the loop in runReport, since it can تجاوز the tokenBudget cap and
violate the bounded-cost behavior. Instead, have runReport return the best
available reply from the last assistant message or a truncated/partial closing
state when limits.maxToolCalls or estimateTokens(messages) exceeds the budget,
and keep the existing toolCalls, citations, and usage reporting consistent with
that early stop.

In `@examples/web_ui_demo/js/compose.js`:
- Around line 25-42: The readNdjson helper drops any buffered bytes left in the
TextDecoder when the stream ends, so trailing multi-byte UTF-8 characters can be
lost. Update readNdjson to flush the decoder after the read loop completes by
calling decoder.decode() once more and appending any returned text to buf before
processing the final tail; keep the rest of the NDJSON parsing logic in place so
safeParse still handles complete lines in readNdjson.

In `@examples/web_ui_demo/README.md`:
- Around line 147-150: The README paragraph in the PDF note is rendering
incorrectly because the raw `#352` at the start of a line is being parsed as a
Markdown heading. Update the text in the affected description so the reference
is written as issue `#352` or otherwise escapes the hash, keeping the surrounding
wording in the same PDF/download explanation.

In `@examples/web_ui_demo/report-bridge.mjs`:
- Around line 111-124: The tool-result handling in executeToolCall currently
appends the full tool response to ctx.messages before any token-budget guard is
applied, so oversized MCP output can still reach the final model turn. Add a
size check or truncation/fail-closed path before pushing the tool content into
messages, and apply the same safeguard anywhere tool results are collected
before the next call, including the other executeToolCall/overBudget flow sites
referenced in the report-bridge flow. Use the existing ctx.deps.execTool,
ctx.messages, and overBudget logic to keep the message payload within
tokenBudget before the forced final model step.
- Around line 222-226: The early-exit path in the report loop is passing the max
allowed rounds into finish instead of the number actually consumed, which skews
the meter. Update the finish call in report-bridge.mjs to use the current round
count/actual rounds used from the loop state rather than limits.maxRounds, and
keep the existing fallback message behavior intact.

In `@examples/web_ui_demo/server.mjs`:
- Around line 424-425: The request body in readRequestBody(req) is buffered
without any limit before JSON parsing, so add an early size cap in the /report
flow around readRequestBody/payload handling to reject oversized bodies before
they are fully accumulated. Update the logic near the payload parsing in
server.mjs so REPORT_MAX_REQUEST_CHARS is enforced while streaming or
immediately after reading each chunk, and keep the check tied to the existing
report request handling path.

---

Nitpick comments:
In `@examples/web_ui_demo/js/compose.js`:
- Line 65: The null-safety around regenBtn is inconsistent in compose.js: it is
treated as optional for addEventListener but later accessed directly in run()
and renderDone() via regenBtn.hidden. Fix this by making regenBtn part of the
required-elements guard alongside input, runBtn, stepsEl, and outputEl, or by
switching the hidden assignments in run() and renderDone() to optional chaining
so all reads/writes match the same lifecycle.

In `@examples/web_ui_demo/server.report.test.mjs`:
- Around line 111-112: The server.report.test.mjs port setup is deterministic
and can collide with local services or parallel runs, so update the test to
allocate an unused port before spawning the child instead of using the PID-based
formula. Use the existing test flow around the demo server launch and
waitForPort() to first reserve a free port, pass that port into the child
process, and keep the port selection logic centralized where demoPort is
currently derived.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2631f619-7163-43b9-904f-cea2d7d41271

📥 Commits

Reviewing files that changed from the base of the PR and between 9c2db58 and cfbb6ac.

📒 Files selected for processing (11)
  • docs/superpowers/plans/2026-07-03-agent-driven-grounded-reporting.md
  • examples/web_ui_demo/.env.example
  • examples/web_ui_demo/README.md
  • examples/web_ui_demo/css/app.css
  • examples/web_ui_demo/index.html
  • examples/web_ui_demo/js/app.js
  • examples/web_ui_demo/js/compose.js
  • examples/web_ui_demo/report-bridge.mjs
  • examples/web_ui_demo/report-bridge.test.mjs
  • examples/web_ui_demo/server.mjs
  • examples/web_ui_demo/server.report.test.mjs

Comment thread examples/web_ui_demo/js/compose.js
Comment thread examples/web_ui_demo/README.md Outdated
Comment thread examples/web_ui_demo/report-bridge.mjs Outdated
Comment thread examples/web_ui_demo/report-bridge.mjs Outdated
Comment thread examples/web_ui_demo/server.mjs Outdated
…unds/docs)

- report-bridge: clamp each MCP tool result (MAX_TOOL_RESULT_CHARS) before
  appending to the running messages, so one broad payload can't blow the token
  budget or bloat the forced final compose turn (fail-closed at the module
  boundary, independent of the transport's own truncation).
- report-bridge: report the ACTUAL rounds used after a budget break, not the
  configured max — the cost meter was over-reporting.
- server: read the /report body with a bounded reader that stops accumulating
  and rejects once the 16 KiB cap is crossed, so an oversized payload is not
  fully buffered before rejection.
- compose: flush the TextDecoder at stream end so a multi-byte char split across
  the final chunk can't corrupt the trailing NDJSON line.
- README: reword the PDF/#352 note so no line begins with '#352' (was rendering
  as a Markdown heading).

Pinned with regression tests (clampToolText + bounded-token runReport; actual
rounds after break; oversized-body rejection). 17/17 green across both suites.

Refs #353
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@examples/web_ui_demo/server.report.test.mjs`:
- Around line 217-234: The fixed demoPort in the server.report test can collide
across runs because t.after calls child.kill() without waiting for the process
to fully exit. Update the server spawn/cleanup flow in server.report.test.mjs so
the child process termination is awaited before the test completes or before the
next spawn can reuse the same port; use the existing child, t.after, and
waitForPort flow to ensure the socket is released before proceeding.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: de515f51-53d0-44e1-829a-02b05c98b155

📥 Commits

Reviewing files that changed from the base of the PR and between cfbb6ac and 3c2e395.

📒 Files selected for processing (6)
  • examples/web_ui_demo/README.md
  • examples/web_ui_demo/js/compose.js
  • examples/web_ui_demo/report-bridge.mjs
  • examples/web_ui_demo/report-bridge.test.mjs
  • examples/web_ui_demo/server.mjs
  • examples/web_ui_demo/server.report.test.mjs
✅ Files skipped from review due to trivial changes (1)
  • examples/web_ui_demo/README.md
🚧 Files skipped from review as they are similar to previous changes (3)
  • examples/web_ui_demo/js/compose.js
  • examples/web_ui_demo/report-bridge.mjs
  • examples/web_ui_demo/server.mjs

Comment thread examples/web_ui_demo/server.report.test.mjs
CodeRabbit review on #361: the integration tests spawn a demo server per test
but t.after only kill()'d the child without awaiting its exit, so its listening
socket could still be held when the file's next test binds — a port-race flake
unrelated to what's under test. Extract shared spawnDemo/stopDemo helpers and
have stopDemo await the child's 'exit' before closing the mock, so the socket is
released before the next spawn.

Refs #353
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant