The brain proposes, the browser disposes — under guard
Story-1656 gave the brain a map (the surface registry) and a live read of
the page (the Current Page Context). Story-1655 gave it a mouth that survives
navigation (the Dock + thread). This Story gives it
hands: the client-tool dispatcher — the browser
half of the agent tool plane. The brain never clicks; it
emits typed intents, and this dispatcher turns a
plane:"client" intent into a real UI action — resolved by
surface_key against the registry, never a
string-built URL.
🛰️ Satellite — why an agent that can route itself can also lock the user out
The stakes: the agent's flagship power is moving the user around the app — "show me Acme's matters," "filter to open filings," "open this matter's record." But navigation is the one action that, done eagerly, is hostile. An agent that auto-routes rips the page out from under a counsel mid-thought; an agent that string-builds a URL invents routes that don't exist and 404s; an agent that fires a tool against a page the user already left does the right thing to the wrong screen.
The naive fix — "let the brain call router.push(url)" —
detonates three ways at once. (1) The brain doesn't know the URL
shape; it knows what a page is. Hand it URL-building and every route
rename silently breaks the agent. (2) Cross-page jumps with no confirm turn
the chat into a trap door. (3) Between the brain reading context and replying,
the user navigates — so the instruction is now about a page that isn't on
screen. The real fix keeps the brain in the proposing role and gives
the browser a guarded disposing role: resolve by key, confirm before
crossing pages, and drop anything that went stale.
✈️ Airplane — decide purely, then act; confirm-first across pages, direct on-page
One analogy: the dispatcher is an air-traffic controller, not a pilot. The brain (a pilot) radios a request — "I'd like to taxi to gate Clients." The controller checks the request is for a gate that exists (registry), checks the plane is still where it said it was (staleness), and for a runway crossing (a cross-page jump) hands back a clearance the user must accept — the "Go to Clients →" chip. For an in-cabin action that doesn't cross a runway (an on-page filter), it just lets it happen, using the plane's own controls (the page's registered handler), not a parallel set.
The dispatcher splits into a pure decision core and a thin React glue. The core is a framework-free function over (instruction, live-context reading) → a typed decision. The glue reads the live surface at dispatch time, performs the chosen effect (route / run / confirm / drop), and emits the live thread items.
| Acceptance criterion | How it's met |
|---|---|
Execute client-plane intents by surface_key via the registry; never string-build URLs |
decideClientTool resolves navigate_to through getSurface + buildSurfacePath (surface-registry navigation). An unknown key → rejected:unknown-surface; a missing route param → rejected:missing-nav-params. No URL is ever concatenated. |
Cross-page navigate_to → confirm-first chip; on tap route via the Next router + a "took you to X" marker |
A navigate decision becomes a live navChip thread item ("Go to X →"). It does not auto-route. On tap, the i18n useRouter().push() routes the registry path and a serializable navMarker ("took you to X") is appended to the thread (it survives refresh). |
On-page set_filter/open_view/focus execute directly, reusing existing handlers |
A same-page tool resolves to the page's own CpcClientTool.run registered in the CPC store (Story-1656). The dispatcher invokes that handler — never a parallel implementation — and leaves a quiet applied-note. |
Stale-instruction rejection on surfaceKey/contextVersion mismatch |
A new monotonic contextVersion on the CPC store bumps whenever the active surface or its params change. It rides into the brain's context (stamped on the surface provider). Each client intent echoes the surface key + version it reasoned over; the decision core drops it (stale-surface / stale-version) if either no longer matches the live surface. |
| State-changing/irreversible → confirm first | A CpcClientTool.confirm flag turns a same-page tool's decision into confirm → a proposal card; the handler runs only on explicit confirm. Reversible view ops leave the flag unset and run directly. |
i18n en+hi; no pnpm codegen; one repo |
Four new Agents keys added to both locales. The brain contract (AgentToolIntent) is imported from @/gen as-is; no codegen run. All changes are in pramaan-app. |
🔬 Microscopic — the seams that make it correct
Why a pure core and a glue, not one hook
decideClientTool(instruction, live) performs no effect —
no router.push, no tool.run, no store write. It
returns a discriminated ClientToolDecision. That makes every
branch — registry resolution, the confirm-first split, every staleness drop —
a plain unit test with no React, no router mock, no DOM. The glue
(useClientToolDispatcher) is the only part that touches the world,
and it stays small.
Why staleness is read with getState(), not a subscribed value
The whole point of the stale check is "is the surface the brain reasoned over
STILL the one in front of the user?" A value captured in a render closure
would answer "…as of the last render," which is exactly the stale read we are
guarding against. So the glue reads useCpcStore.getState() at the
moment of dispatch — the live truth — and compares the intent's echoed
surfaceKey/contextVersion against it.
Why the chip is a live item but the marker is stored
A nav chip carries an onConfirm callback — functions can't be
serialized, so chips (like proposals) live in the dispatcher's local state and
merge after the persisted thread, exactly as the live rail will merge
its stream. The resolved navMarker is plain data, so it — and only
it — is appended to the thread store and survives a refresh.
The trust boundary: normalize before deciding
clientInstructionFromIntent is where the wire shape is validated
and coerced (RFC-APB-002 #1): it drops non-client-plane intents, tolerates
snake_case or camelCase envelope keys, pulls only string/number route params,
and separates the staleness envelope + display label from the tool's own args.
The pure core only ever sees a clean ClientToolInstruction.
onSubmitTurn. AgentDockMount instantiates the
dispatcher and merges its live items today; the rail child calls
dispatcher.dispatch(intent) when it lands. The dispatcher itself
is fully exercised by unit tests (decision core + wire normalization) and the
AgentChatPanel → ClientToolDispatch Storybook story (the chip,
the marker, the applied-note). No empty-array "done" is shipped: the logic is
complete and tested; only the inbound transport is pending elsewhere.
Files
| Path | Change |
|---|---|
src/lib/agents/client-tool-dispatcher.ts | new pure decision core + wire normalizer |
src/lib/agents/use-client-tool-dispatcher.ts | new React glue: live read, router push, live items |
src/lib/agents/__tests__/client-tool-dispatcher.test.ts | new 17 tests — registry, confirm-first, staleness, normalization |
src/stores/cpc-store.ts | edit monotonic contextVersion + tool confirm flag |
src/lib/surface-registry/current-context.ts | edit contextVersion on snapshot + digest |
src/lib/surface-registry/session-context.ts | edit stamp contextVersion on the surface provider |
src/components/agents/agent-thread-item.ts | edit additive navChip item kind |
src/components/agents/agent-chat-panel.tsx | edit render the confirm-first chip |
src/components/agents/agent-dock-mount.tsx | edit instantiate dispatcher; merge live items; name the rail seam |
src/components/agents/agent-chat-panel.stories.tsx | edit ClientToolDispatch story |
messages/en.json · messages/hi.json | edit 4 new Agents keys × 2 locales |
Recall: the brain proposes typed intents; the browser disposes them by surface_key — confirm-first across pages, direct on-page, and never against a page the user already left.
Files changed
15 file(s), +827 / -23 lines
| Path | Role | + | - |
|---|---|---|---|
| messages/en.json | Config | +5 | -1 |
| messages/hi.json | Config | +5 | -1 |
| src/components/agents/agent-chat-panel.stories.tsx | Component | +31 | -0 |
| src/components/agents/agent-chat-panel.tsx | Component | +17 | -1 |
| src/components/agents/agent-dock-mount.tsx | Component | +22 | -12 |
| src/components/agents/agent-thread-item.ts | Component | +15 | -0 |
| src/lib/agents/__tests__/client-tool-dispatcher.test.ts | Library | +232 | -0 |
| src/lib/agents/client-tool-dispatcher.ts | Library | +248 | -0 |
| src/lib/agents/use-client-tool-dispatcher.ts | Library | +162 | -0 |
| src/lib/surface-registry/current-context.test.ts | Library | +1 | -0 |
| src/lib/surface-registry/current-context.ts | Library | +5 | -0 |
| src/lib/surface-registry/session-context.test.ts | Library | +13 | -0 |
| src/lib/surface-registry/session-context.ts | Library | +3 | -0 |
| src/stores/cpc-store.test.ts | Test | +22 | -0 |
| src/stores/cpc-store.ts | Source | +46 | -8 |