feat(cat): a local model's Cat can act - #988
Merged
Merged
Conversation
ADR-0008 D2. `/api/cat/local-complete` was 52 lines that called saveMessages and nothing else, so a model running on the user's own hardware could do nothing — and worse, its exec_action block was stored VERBATIM, so the user read "Creating that now…" for something nothing would ever create. ADR-0006 D8 made the prompt honest about that limit. This makes the limit untrue, which is the better fix. The capability was one file away, behind no decision at all. `runExecActions` was module-private inside chat-orchestrator — not a policy, not a risk assessment, just a function nobody had moved. It is now services/cat/ exec-actions.ts and both paths call the one copy, so the gates cannot diverge because there is nothing to diverge from. actionsVia goes 'none' → 'prose', and NOT 'tools'. The model runs in the browser, so the server makes no inference call and there is no round trip in which to send definitions. 'tools' means the prose catalogue was dropped because definitions replace it; claiming it here would leave Cat with no verb at all — the same failure the observation cache exists to prevent, reached from the opposite direction. The loop is client-driven, and that grants nothing. Every action executes for the AUTHENTICATED user's own actor through CatActionExecutor, so a forged block can do only what that user could already do by calling the API directly, under the same permission checks, spend caps, confirmation flow and audit row. The client controls the text, never the authority. Two bounds differ from the hosted path: MAX_ACTIONS_PER_REPLY (6, matching MAX_ACTION_STEPS) bounds one reply since the browser drives the sequence, and a mid-loop step is executed but not written to history — the transcript carries the final answer, not the model thinking out loud. The route now stores the cleaned message. Once a local model is told how to ask for an action, the raw reply puts the envelope in the transcript, which is exactly the false announcement D8 described. The gate did its job. actions-via-wiring said "if this route ever grows one, this test should fail and be rewritten — that is the point", and it failed on both assertions at the first edit. The rewrite keeps the invariant that survived both decisions — the prompt's claim and the route's capability are one fact — and now pins the linkage: if runExecActions ever leaves local-complete, claiming anything but 'none' fails. Verified: 3110 tests green, 0 lint errors, seven mutants all caught — executor removed, raw reply stored, mid-loop step written, ceiling lifted, missing actor papered over, 'tools' claimed, pending reported as completed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJRuvHJEBd8t7iRA9Sb1iw
catomean
marked this pull request as draft
September 12, 2026 12:05
The server half of D2 was executing into a void. The client posted the reply
fire-and-forget — `void fetch(...).catch(() => {})` — discarded the results,
and rendered the RAW stream, so a local user would have watched an exec_action
block scroll past as JSON while the action silently ran and nothing on screen
said whether it worked.
Worse, that was a regression I was about to ship. Flipping actionsVia to
'prose' is what makes local models START emitting those blocks; before, they
were told they could not act and mostly did not. Shipping the server half alone
would have made the raw envelope MORE common, not less. The PR was drafted
rather than merged for exactly this reason.
`runLocalTurn` is the loop, extracted rather than grown into a 565-line hook so
it can be tested without React, a model, or a server. It streams a pass into
the bubble, posts it, settles the bubble to the PARSED text so the envelope
never stays on screen, emits a chip per action, feeds the outcome back, and
lets the model write its reply last — knowing what happened instead of
guessing. That is ADR-0006 D2's whole point, and a local model now gets it too.
The chips cost nothing to add because #986 already derives their words from the
action registry: a local create_project reads "Created project" exactly as it
does on the hosted path.
ONE RULE ABOVE ALL: a generated reply is posted exactly once. The server
executes on every post, so a second post of the same text is a duplicate
payment or a duplicate project. `intermediate` is therefore decided before the
post and never revisited, and the final pass always persists. The test asserts
the property directly — distinct replies, exactly one non-intermediate post.
Bounded at MAX_LOCAL_PASSES (3) because every pass is seconds of local
inference the user waits through. Mutation testing corrected me here: my first
mutant widened the `for` condition and ESCAPED, because the real bound lives in
`pass < MAX_LOCAL_PASSES - 1` inside willLoop and the `for` is defence in
depth. The mutant that removes the real bound is caught.
Verified: 3119 tests green, 0 lint errors, eight mutants all caught — reply
posted twice, every pass persisting, outcome never fed back, pending described
as done, envelope left on screen, real bound removed, chip ids colliding, and
a failed action rendering as completed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJRuvHJEBd8t7iRA9Sb1iw
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ADR-0008 D2 — both halves. The browser holds the model, the server holds the rules.
/api/cat/local-completewas 52 lines that calledsaveMessagesand nothing else. A model running on the user's own hardware could do nothing — and worse, itsexec_actionblock was stored verbatim, so the user read "Creating that now…" for something nothing would ever create. ADR-0006 D8 made the prompt honest about that limit. This makes the limit untrue, which is the better fix.The capability was one file away, behind no decision at all
runExecActionswas module-private insidechat-orchestrator. That is the whole reason the local route had no executor — not a policy, not a risk assessment, just a function nobody had moved. It is nowservices/cat/exec-actions.tsand both paths call the one copy, so the gates cannot diverge because there is nothing to diverge from.actionsViagoes'none'→'prose', and not'tools': the server makes no inference call here, so there is no round trip to put definitions in, and'tools'means the prose catalogue was dropped because definitions replace it — claiming it would leave Cat with no verb at all.Why this PR was drafted mid-review
The server half alone was a regression I was about to ship. The client posted the reply fire-and-forget —
void fetch(...).catch(() => {})— discarded the results, and rendered the raw stream. And flippingactionsViato'prose'is precisely what makes local models start emitting those blocks. Shipping only the server would have made raw JSON in the chat bubble more common, not less, while the action ran invisibly.So the browser half is here too.
runLocalTurnstreams a pass into the bubble, posts it, settles the bubble to the parsed text so the envelope never stays on screen, emits a chip per action, feeds the outcome back, and lets the model write its reply last — knowing what happened instead of guessing. It is a separate module rather than more of a 565-line hook, so it tests without React, a model, or a server.The chips cost nothing to add, because #986 already derives their words from the action registry: a local
create_projectreads "Created project" exactly as on the hosted path.Why a client-driven loop is not a new privilege
Every action executes for the authenticated user's own actor through
CatActionExecutor, so a forged block can do only what that user could already do by calling the API directly — same permission checks, spend caps, confirmation flow andcat_action_logrow. The client controls the text, never the authority.One rule above all: a generated reply is posted exactly once. The server executes on every post, so a second post of the same text is a duplicate payment or a duplicate project.
intermediateis decided before the post and never revisited; the final pass always persists. The test asserts that property directly.The gate was written to fail, and did
actions-via-wiring.test.tssaid: "If this route ever grows one, this test should fail and be rewritten — that is the point." It failed on both assertions at the first edit. The rewrite keeps the invariant that survived both decisions — the prompt's claim and the route's capability are one fact — and now pins the linkage: ifrunExecActionsever leaveslocal-complete, claiming anything but'none'fails.Verified
'tools'claimed · pending reported as completedMutation testing corrected me once here: my first "unbounded loop" mutant widened the
forcondition and escaped, because the real bound lives inpass < MAX_LOCAL_PASSES - 1insidewillLoopand theforis defence in depth. The mutant that removes the real bound is caught.🤖 Generated with Claude Code
https://claude.ai/code/session_01HJRuvHJEBd8t7iRA9Sb1iw