Skip to content

feat(cat): a local model's Cat can act - #988

Merged
catomean merged 2 commits into
mainfrom
feat/local-models-can-act
Sep 12, 2026
Merged

feat(cat): a local model's Cat can act#988
catomean merged 2 commits into
mainfrom
feat/local-models-can-act

Conversation

@catomean

@catomean catomean commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

ADR-0008 D2 — both halves. The browser holds the model, the server holds the rules.

/api/cat/local-complete was 52 lines that called saveMessages and nothing else. 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. 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 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 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 flipping actionsVia to '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. runLocalTurn 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. 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_project reads "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 and cat_action_log row. 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. intermediate is 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.ts said: "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: if runExecActions ever leaves local-complete, claiming anything but 'none' fails.

Verified

  • 3119 tests green, 0 lint errors
  • Server, 7 mutants caught: executor removed · raw reply stored · mid-loop step written to history · ceiling lifted · missing actor papered over · 'tools' claimed · pending reported as completed
  • Client, 8 mutants 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 · failed rendering as completed

Mutation testing corrected me once here: my first "unbounded loop" 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.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HJRuvHJEBd8t7iRA9Sb1iw

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
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
@catomean
catomean marked this pull request as ready for review September 12, 2026 12:24
@catomean
catomean merged commit 99abbc8 into main Sep 12, 2026
6 checks passed
@catomean
catomean deleted the feat/local-models-can-act branch September 12, 2026 12:31
@catomean
catomean restored the feat/local-models-can-act branch September 12, 2026 20:12
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