Skip to content

feat(serve): Chat assistant — streaming tool-calling harness over WhyGraph's tools - #38

Merged
cvetty merged 5 commits into
mainfrom
feature/chat-assistant
Jul 30, 2026
Merged

feat(serve): Chat assistant — streaming tool-calling harness over WhyGraph's tools#38
cvetty merged 5 commits into
mainfrom
feature/chat-assistant

Conversation

@cvetty

@cvetty cvetty commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Implements plans/chat-assistant-plan.md Phase 1 — all seven rollout steps (§15.1–7).

The playground gets a second top-level view: a chat assistant that answers questions about the repo from live tool calls against CodeGraph, the WhyGraph history, and the source tree, with every tool invocation visible in the UI.

Why this shape

The assistant calls the same plain functions the MCP server and the Explorer already call — in-process, no MCP protocol roundtrip. That's the point: its answers can't drift from the rest of WhyGraph, because there's one implementation per capability and three adapters over it (mcp/, serve/, now chat/).

The chat port is parallel to LlmClient, not a widening of it. The analyze/rationale path needs exactly one thing — sync complete() returning text — and its frozen dataclasses have no notion of tool calls or incremental output. Adding streaming there would ripple through all five existing adapters for one new consumer. So LlmClient and all five adapters are byte-identical in this PR.

Zero new Python runtime dependencies. OpenRouter / OpenAI / DeepSeek share one OpenAI-compatible adapter; Anthropic gets its own; both SDKs were already direct deps.

What's here

Backend

  • services/llm/openrouter.py — OpenRouter as a first-class provider (so analyze/rationale can use it too), mirroring the DeepSeek adapter across config, factory, template, golden fixture, the init wizard, and OPENROUTER_API_KEY in all three shim env lines.
  • services/llm/chat.py — the new ChatClient port. Sync generators, not async: serve's documented contract is sync-def-in-threadpool, and both SDKs stream synchronously. One consumer, one thread, no event-loop coupling.
  • services/llm/openai_chat.py / anthropic_chat.py — the hard part. Two very different wire dialects (fragmented tool_calls argument deltas vs tool_use content blocks with explicit block-stop) normalized to one event stream.
  • chat/tools.py (11 tool specs + a per-turn ToolRegistry), files.py (the clamp), harness.py (run_turn + build_window), prompts/system.md. The harness is persistence-free — it takes messages and yields events, which is what makes the loop testable against a scripted fake client with no DB in the picture.
  • serve/chat.py — session CRUD plus the SSE turn. Rows persist per completed round, so a disconnect loses at most the in-flight turn.
  • ChatSession / ChatMessage tables + one migration, applied automatically by the ensure_initialized() call create_app already makes.

Frontend

  • Zustand view switch + header segmented control (no router dep — matches the house idiom).
  • components/chat/ — session sidebar, thread with auto-stick, tool-activity cards (collapsed → expandable to arguments/result), composer with Stop via AbortController, provider picker.
  • react-markdown with raw HTML disabled; whygraph://symbol/<qn> links render as chips that call openNode() and jump into the Explorer.

Security

The file tools are the only genuinely new attack surface, and they're deliberately paranoid: root clamp via is_relative_to, plus a deny-list inside the root for .git / .whygraph / .codegraph / whygraph.toml / .env* — the live config holds real API keys and a transcript is a place secrets would be persisted. Binary refusal, 400-line / 100 KB caps. No write tools, no shell tool; the registry is a closed allow-list.

The only nested LLM spend is get_rationale on a cache miss, capped per turn by [chat].max_rationale_generations (default 2) and running whygraph_rationale_brief verbatim — the same audited generate-and-cache flow the MCP tool and the Explorer's Generate button perform, using the [rationale] provider/model so the cache key can't fragment.

Chat is absent from the MCP surface, which stays narrow per the CLAUDE.md invariant.

Verification

Gate Result
ruff check src/ tests/ pass
ruff format --check src/ tests/ 179 files formatted
pytest 686 passed (was 564 → +122)
npm --prefix src/playground run build pass (tsc + vite)

Verified live against the real Anthropic API, not only in tests: one turn streamed 3 parallel search_symbols calls → results → prose; another triangulated search_symbolslist_dirread_fileget_symbol and answered with a working whygraph://symbol/… deep-link. Transcript replay shows the correct row shape (user → assistant(3 calls) → 3 tool rows matched by id → assistant) with first-message titling applied.

New test files: test_llm_chat.py (both dialects incl. parallel calls, fragmented and malformed argument JSON, truncated streams), test_chat_tools.py (every AC #6 clamp case + the generation-budget accounting with a spy), test_chat_harness.py (loop termination, ordering, and the "never orphan a tool message" window invariant asserted structurally across the budget range), test_serve_chat.py (frame sequences, which rows land, in-band failures), test_services_llm_openrouter.py.

Reviewer notes

Two deviations from the plan as written, both deliberate:

  • ChatConfig (§9) landed in step 4, not step 6 — the tool registry reads [chat].max_rationale_generations, so the config had to exist before it.
  • chat_session.updated_at is not indexed. §6.2 specified only ix_chat_message_session_id; autogenerate proposed an updated_at index and it was dropped for plan fidelity (a single-user tool never holds enough sessions to need it).

Migration scope: alembic --autogenerate also wanted to drop ix_commit_file_change_path_commit_sha and strip three server_defaults. That's pre-existing model↔migration drift, not part of this change, and dropping that composite index would regress area-history queries — so the migration was hand-finished to the two new tables only (verified zero compare_metadata diff). The drift is worth a separate focused PR.

Two real bugs were caught and fixed during implementation, both worth knowing about if you touch this code:

  • _load_history iterated rows after get_session()'s commit expired them → DetachedInstanceError on every turn. Conversion now happens inside the session.
  • The first _turn_frames draft collapsed multi-round turns into a single assistant row and never persisted tool-result rows at all. Rewritten with an explicit per-round buffer.

Not in this PR: Phase 2 charts (§12, deferred by design), and no syntax highlighting in chat code blocks (§0.1 #5rehype-highlight is the designated follow-up). The manual smoke for openai/deepseek/openrouter (AC #5) is outstanding — those three share the one adapter that is unit-tested against recorded streams, but no keys for them are configured locally.

Local build note: the playground build needs Node 18+ (vite 5). Node 16 fails with crypto.getRandomValues is not a function after tsc passes, which reads like a build bug but isn't — CI and the Dockerfile already pin Node 22.

…Graph's tools

Adds a second top-level view to the `whygraph serve` playground: a chat
assistant that answers questions about the repo from live tool calls
against CodeGraph, the WhyGraph history, and the source tree.

Implements plans/chat-assistant-plan.md Phase 1 (all seven rollout steps).

Backend
- `services/llm/openrouter.py` — OpenRouter as a first-class provider,
  mirroring the Deepseek adapter (config section, factory row, template,
  golden fixture, init wizard, and `OPENROUTER_API_KEY` in all three shim
  env lines).
- `services/llm/chat.py` — a NEW tool-calling + streaming `ChatClient`
  port, parallel to the untouched `LlmClient`. Sync generators, not async:
  serve's contract is sync-def-in-threadpool and both SDKs stream
  synchronously.
- `services/llm/openai_chat.py` / `anthropic_chat.py` — the two dialects
  normalized to one event stream (fragmented `tool_calls` deltas vs
  `tool_use` content blocks). Zero new runtime dependencies.
- `chat/` — the harness. `tools.py` holds 11 tool specs + a per-turn
  `ToolRegistry` carrying the rationale-generation budget; `files.py` is
  clamped read-only file access; `harness.py` is `run_turn` plus the
  token-budgeted, tool-pair-safe `build_window`. The harness is
  persistence-free.
- `serve/chat.py` — session CRUD plus the SSE turn. Rows persist per
  completed round, so a disconnect loses at most the in-flight turn.
- `ChatSession` / `ChatMessage` tables + one migration.

Frontend
- Zustand `view` switch and a header segmented control (no router).
- `components/chat/` — session sidebar, thread, tool-activity cards,
  composer with Stop, provider picker.
- react-markdown with raw HTML disabled; `whygraph://symbol/<qn>` links
  render as chips that open the symbol in the Explorer.

Security
- The file tools are the only new attack surface: root clamp via
  `is_relative_to`, a deny-list for `.git` / `.whygraph` / `.codegraph` /
  `whygraph.toml` / `.env*` even inside the root, binary refusal, and
  400-line / 100 KB caps. No write tools, no shell tool; the registry is a
  closed allow-list.
- The only nested LLM spend is `get_rationale` on a cache miss, capped per
  turn by `[chat].max_rationale_generations` and running the audited MCP
  flow verbatim so the cache row can't drift.

Notes
- Chat is absent from the MCP surface, which stays narrow by design.
- `alembic --autogenerate` also proposed dropping
  `ix_commit_file_change_path_commit_sha` and stripping three
  `server_default`s — pre-existing model/migration drift, excluded from
  this migration so area-history queries don't regress.

686 tests pass (+122); ruff check, ruff format --check, and the playground
build are green. Verified end-to-end against the live Anthropic API.
@cvetty cvetty self-assigned this Jul 29, 2026
@cvetty cvetty added the enhancement New feature or request label Jul 29, 2026
Replaces the free-text model input with a real model dropdown, and makes
both provider and model switchable from the thread header at any time
(previously they were fixed for a session's lifetime).

Model options are fetched from the provider rather than hardcoded — a
baked-in list would rot on every model release and could never cover
OpenRouter's ~370-model catalogue.

- `ChatClient.list_models()` on the port, implemented by both adapters
  (`client.models.list()` on each SDK). Anthropic is the only provider
  that returns display names, so its dropdown shows "Claude Opus 5"
  rather than a bare id.
- `GET /api/chat/models?provider=X` — returns `source: "live"`, or
  `source: "fallback"` plus the provider's error. Listing is *expected*
  to fail sometimes: a scoped Anthropic key can be valid for /messages
  and still 401 on /models, so the short built-in `FALLBACK_MODELS` list
  keeps the dropdown usable and the UI says why it looks short.
- `PATCH /sessions/{id}` now accepts provider/model. Switching provider
  resolves that provider's default model, since the old id is meaningless
  on a new provider.
- `chat_message.provider` / `.model` (new migration) record which model
  produced each assistant turn. Without them a transcript spanning a
  mid-session switch would attribute every turn to whatever is selected
  now. Bubbles show the per-turn model.
- Frontend: one shared `ModelSelect` used by both the New-chat panel and
  the thread header, so they can't drift. A filter box appears once the
  list exceeds 25 entries (OpenRouter).

Two latent bugs found and fixed while testing this:

- Both adapters caught `APIError`, but the SDKs raise their *base* class
  (`openai.OpenAIError`) for missing credentials at client-construction
  time — which escaped as an unhandled 500 from the new endpoint, and as
  a raw SDK message from the streaming path. Both now catch the base.
- The Anthropic SDK raises a bare `TypeError` when no auth is
  resolvable. Catching `TypeError` would swallow real bugs in our own
  translation code, so the adapters check for a key up front and raise a
  `LlmError` naming the provider and its env var.

Verified live: all four providers return 200 from /models and degrade to
the fallback list with an accurate reason.

703 tests pass (+17); ruff check, ruff format --check, and the playground
build are green. Single migration head.
@cvetty

cvetty commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up: provider + model as two dropdowns (a6185a7)

Replaces the free-text model input with a real model dropdown, and makes provider and model switchable from the thread header at any time — previously both were fixed for a session's lifetime.

Model options are fetched live from each provider, not hardcoded: a baked-in list rots on every model release and could never cover OpenRouter's ~370-model catalogue.

  • ChatClient.list_models() on the port, implemented by both adapters via client.models.list(). Anthropic is the only provider returning display names, so its dropdown reads "Claude Opus 5" rather than a bare id.
  • GET /api/chat/models?provider=Xsource: "live", or source: "fallback" plus the provider's error. Listing is expected to fail sometimes — a scoped Anthropic key can be valid for /messages and still 401 on /models (confirmed against a real key here), so a short built-in FALLBACK_MODELS list keeps the dropdown usable and the UI explains why it looks short. Verified live: all four providers return 200 and degrade with an accurate reason.
  • PATCH /sessions/{id} accepts provider/model. Switching provider resolves that provider's default model, since the old id is meaningless on a new one.
  • One shared ModelSelect component backs both the New-chat panel and the header, so they can't drift. A filter box appears once the list exceeds 25 entries.

Reviewer notes

This overturns a documented decision from the first commit. The session originally pinned provider+model so a transcript was unambiguous about what produced it. To keep that property under mid-session switching, chat_message gains provider / model columns recording which model produced each assistant turn — without them, a transcript spanning a switch would attribute every turn to whatever is selected now. Bubbles show the per-turn model.

Second migration, deliberately. f3582dfcc817 adds those two columns on top of b18c23ad33e8 rather than amending it. The first migration had already run against local DBs on this branch, so amending would leave anyone who checked it out inconsistent with the file. Same pre-existing ix_commit_file_change_path_commit_sha drift was excluded again. Still a single head.

Two latent bugs in the first commit, found while testing this and fixed here:

  1. Both adapters caught APIError, but the SDKs raise their base class (openai.OpenAIError) for missing credentials at client-construction time. That escaped as an unhandled 500 from the new endpoint, and as a raw SDK message from the streaming path. Both now catch the base class, which is strictly wider (APIError is a subclass).
  2. The Anthropic SDK raises a bare TypeError when no auth is resolvable. Catching TypeError would swallow genuine bugs in our own translation code, so the adapters now check for a key up front and raise LlmError naming the provider and its env var — which is also a much better message than the SDK's generic "Missing credentials".

Verification: 703 tests pass (+17), ruff check + format clean, playground build green.

A fresh live chat turn could not be re-run for this commit: the ambient ANTHROPIC_API_KEY on my machine stopped authenticating partway through the session (a direct curl with no WhyGraph code involved also 401s, including on a model that had succeeded ~40 minutes earlier). The earlier turns in this PR were real; the model-picker paths here are covered by the integration tests plus the live /models check across all four providers.

cvetty added 3 commits July 30, 2026 14:42
…lick chat

First real use of the Chat assistant surfaced two bugs and two UX gaps. The
bugs shared one root: the turn-completion handler trusted a cache-governed
fetchQuery and then discarded the only live copy of what the user had just
watched stream in.

Streaming that sticks. The app-wide `staleTime: 30_000` made the completion
`fetchQuery` resolve from cache with the *pre-send* transcript, so clearing
`liveTurns` erased the reply — reappearing only after a refresh, which gave a
cold cache. A per-call `staleTime: 0` forces the fetch. Verified against the
installed @tanstack/query-core: without it the second fetchQuery performs no
network call and returns the stale rows; with it, the new rows arrive and the
cache updates before the live copy is dropped.

Durable errors. `chat_message` gains a nullable `error` column (revision
c7d4a1e8b3f2). `_flush_round(error=...)` now writes a row even with empty
buffers, so a 401 before the first token no longer leaves the user message
with no reply at all — previously the failure existed only as an SSE frame and
died with the stream. The unconfigured-provider path persists too, which the
plan had left out: it broke the same "no user message without a visible reply"
contract stated in this module's own docstring.

Thinking indicator. A transient `thinking` flag on the live-turn reducers fills
the silent gaps before the first token (the system prompt builds from live repo
facts after the HTTP head is sent) and between a tool result and the next
round. Client-side by design — no new SSE frame, since the empty assistant turn
is already on screen at click time, before any byte could arrive.

URL-backed navigation. A ~110-line store<->URL sync module, no router: the
store is already the single source of truth with one canonical mutation point
per transition, and a router would invert that to URL-as-truth for two views.
`/explorer?node=<qn>&file=<path>` and `/chat/<id>` now survive a refresh and
back/forward. The SPA catch-all already served every GET path, so no backend
change. Exercised against a stubbed window: pushState vs replaceState, popstate
re-entrancy (a popstate-driven write regenerates the same URL and no-ops),
`/` normalization, dead ids passing through unvalidated by design, and a
generic-typed qualified name round-tripping.

Chat UX. "+ New chat" creates immediately — the server already resolved every
field from config, so the picker step was friction with nothing behind it;
ProviderPicker is deleted. The provider/model dropdowns move to just above the
composer and are owned by MessageThread, the only component that knows whether
a turn is streaming (switching mid-stream would repoint the session row under
the in-flight turn). Stable React keys replace index keys so a turn keeps its
identity across the live->persisted swap.

Also fixes ChatSession's docstring, which still claimed a session is never
re-pointed at another model mid-conversation — untrue since a6185a7.
…l names

Turns were exhausting the 8-round tool budget and answering nothing. The
persisted transcripts (readable because the previous commit added the `error`
column) showed where the rounds went: six `get_symbol` calls on directory paths
in a single round, `get_commit {"sha": "HEAD"}`, `read_file` on a directory, and
`get_area_history {"path": "."}` returning zero commits.

Root cause was a tool-surface gap, not a bug. For "what have we developed
lately", every history tool needs an identifier the caller must already know — a
SHA, a PR number, a real file path — and get_repo_overview returns only counts.
So with 219 commits and 38 PRs indexed, the model had no entry point and fell
back to walking the tree with list_dir/read_file until the budget ran out.

list_recent_activity returns the newest commits, PRs, and issues in one call.
Compact by design — subject/title, author, date, description clipped to 240
chars — so it is an index to scan in one round, then drill down with
get_commit/get_pr on what matters. Commits are restricted to the first-parent
main walk, matching area-history and the refactor-walk: "what shipped" is a
default-branch question, and PR-origin commits recovered from squash merges
would double-count. The resource lives beside the other resource bodies to
reuse their session handling and OperationalError contract, but is not
registered as an MCP resource — the chat tool is its only caller, and WhyGraph's
MCP surface deliberately stays narrow.

A round-limited turn now always ends in prose. The loop dispatched the final
round's tools and appended the results to `messages`, then exited without
another provider call — so those results never reached the model and the turn
produced tool cards, an amber banner, and no answer. One more call with
`tools=()` leaves the model nothing to do but write up what it gathered. This
reverses a decision the parent plan stated explicitly (a forced no-tools turn
"is NOT attempted"), on the grounds that it only papers over a misbehaving
loop. Observed behaviour overruled it: these were not loops but ordinary broad
questions with no efficient tool, and the old choice shipped a turn that cost
eight calls and said nothing. RoundLimit's docstring carries the new rationale
and this history. Note the ceiling is now max_tool_rounds + 1 calls, and that
last one carries the turn's largest transcript.

Symbol tools documented the one name shape that cannot resolve. All three
`qualified_name` params said "Dotted symbol name" and the system prompt's own
worked example was whygraph://symbol/whygraph.chat.harness.run_turn. Verified
against a live index: `run_turn` resolves, the dotted path does not, and methods
are `Class::method`. So the docs taught a format that misses every lookup and
produces dead Explorer chips. One shared constant now names the three real
shapes and states that a dotted path will not resolve, with a test that fails if
it drifts back.

Verified live against `whygraph serve`: the question that previously burned all
eight rounds now answers in two (list_recent_activity, then six targeted
get_pr calls) with finish_reason=stop. With max_tool_rounds forced to 1, the
round_limit frame is followed by a 2085-char answer persisted as its own row.
Tool count 11 -> 12.
… project stats

The assistant had three jobs — planning, debugging, statistics — and measurably
failed at all three for the same reason: the specialized tools could not answer
the questions those jobs ask, so the model fell back to reading files or to
trusting developer commit prose. Four changes, no migration, no re-indexing, no
frontend work.

get_area_outline — CodeGraph indexes files, not packages, so "what's in this
subsystem" had no structural answer; a transcript showed the model try
get_symbol on six package paths, say "the files aren't indexed as packages",
and fall back to list_dir. CodeGraph.area() derives the answer from
nodes.file_path, grouped by file with line ranges, cross-linked to per-file
commit counts from commit_file_change. Degrades to a per-file map above the
symbol limit rather than truncating, because a truncated list hides what was
missed while a map redirects the next call.

find_changes — the debugging entry point. search_symbols matches symbol names
only, so a defect described behaviourally ("sessions vanish after a refresh")
was unreachable: nothing was keyed on content. Keyword and/or path filters over
the diff descriptions, reusing resolve_path_aliases so renamed history is not
silently lost, and folding in linked PR titles (1,967 chars across the repo)
but never PR bodies (77,420). Accepts a directory, which get_area_history
cannot.

Field authority — llm_description is generated from the diff alone, so a
careless commit message cannot contaminate it, but every serializer returned
subject first and nothing said which to trust. Reordered _commit_dict and
_hydrate_commit (key order only; the key set is asserted unchanged, so the MCP
contract the planner subagents rely on still holds) and added a shared
_DESCRIPTION_AUTHORITY constant to all five history tools.

run_project_stats — velocity, churn, hotspots, and cycle time were all already
in the schema with no tool to reach them. Read-only SQL behind four independent
layers: a mode=ro connection, a SQLite authorizer allowing only SELECT/READ/
FUNCTION over a frozenset table allowlist (chat_* and rationale_cache denied,
so the assistant cannot read its own transcripts), an aggregate-only shape
check, and row + progress-handler-deadline caps. Layers 1 and 2 are each tested
with the shape check bypassed — a bug in a string check must not be what stands
between the model and a DROP TABLE — and the DB is asserted byte-identical
after every hostile query. Ships an annotated schema description carrying four
measured silent-corruption rules (on_default_branch, strftime over substr,
julianday over string comparison, merged PRs are 'closed'), guarded by a test
so they cannot be trimmed to save tokens.

Two measurements corrected the design as written. Outline cost is ~130 chars
per symbol serialized, not the ~38 a values-only estimate suggested, so the
symbol limit is 150 rather than 500 — at 500 an outline would have been double
the 30,000-char result cap. And find_changes' row-count cap did not bound
payload size at all: limit=30 produced 30,014 chars, truncated mid-string into
JSON the model could not parse, so rows now assemble under a char budget and
report what they withheld.

Tool selection is measured, not assumed: scripts/eval_tool_choice.py drives
live providers through the HTTP surface and scores which tool gets reached
first. It found two real description bugs — list_dir said "use it to orient
yourself before reading files", and nothing said get_repo_overview has no
per-file breakdown. After fixing both, all three of the plan's selection
criteria pass on two models, with run_project_stats leaking onto a
non-statistical question 0 times across 20 questions. The lint gate now covers
scripts/, which pytest does not collect.
@cvetty
cvetty merged commit b469db2 into main Jul 30, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant