Skip to content

web: the proposal store, the state machine and the agent loop - #13

Merged
ryannzander merged 4 commits into
mainfrom
b/proposal-store
Sep 5, 2026
Merged

ryannzander merged 4 commits into
mainfrom
b/proposal-store

Conversation

@ryannzander

Copy link
Copy Markdown
Owner

Session 2's work plus one fix on top.

What's in it

  • src/server/tappy/store.ts + proposals.ts — the proposal store. callForAction is the single place an action becomes bytes. transition is a compare-and-set, so two writers racing cannot both win. Illegal moves throw, lost races throw, and a second PENDING_HUMAN is refused with the pending one on the error.
  • src/server/agent/loop.ts + tools.ts + handlers.ts + prompt.ts — the tool-calling loop and the six SPEC §3.5 tools. The OpenAI client is an interface, so the whole conversation is tested without a key.
  • AgentSigner takes a DigestInput, not a hash. Privy cannot sign arbitrary 32 bytes.
  • Two bugs fixed on the way: proposalToRow was parsing with actionSchema and would have thrown on the first real write, and packages/contracts/src-ts/index.ts built its abis map from identifiers that do not exist.
  • New in this PR: MAX_TOOL_ROUND_TRIPS capped the tools that ran but not the calls to the API, so a model that never stopped asking would loop and bill without bound. The cap now forces a final tools-forbidden request and ends the turn.

Checked

  • pnpm --filter @tappy/web test — 30 pass
  • pnpm --filter @tappy/protocol test — 8 pass
  • pnpm typecheck — clean
  • pnpm contracts:test not run locally, no Foundry on this machine. CI covers it and no Solidity changed.

For the reviewer
Nothing is deployed and no UI calls any of this. ChainReader and ShopReader are still interfaces with no implementation, waiting on #5.

ryannzander and others added 3 commits September 5, 2026 10:19
buildProposal turns an Action into the exact bytes the human approves, agent-signs the digest and
hands back the view the Flipper renders. callForAction is the only place a call is built, so there
is one function to audit rather than three.

transition is a compare-and-set: `from` is matched in the WHERE clause, so two writers racing to
move the same proposal cannot both win. An illegal move throws IllegalTransitionError and a lost
race throws StaleTransitionError. Neither logs and carries on. insertProposal refuses while another
proposal is PENDING_HUMAN and puts the pending one on the error, because the caller's next move is
to show the human what they are already being asked to approve.

Two things that were wrong and are now not:

- proposalToRow used actionSchema.parse to prepare the jsonb column. The schema runs the other way,
  string in and bigint out, so this compiled and then threw "Do not know how to serialize a BigInt"
  on the first real write. Amounts are stringified by hand now, and a test holds it.
- packages/contracts/src-ts/index.ts built its `abis` map from bare identifiers that do not exist.
  Nobody had imported it yet. Fixed in the generator as well, so forge build does not undo it.

AgentSigner takes a DigestInput rather than a finished hash. Privy signs typed data and cannot sign
an arbitrary 32 bytes, so the hash-shaped interface would have had exactly one implementation.

Checked by 16 vitest cases on the pure half and verify:store against the real Supabase for the
half a mock cannot answer: wei above 2^53 through text columns, the compare-and-set, one pending
at a time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
runAgentTurn takes one user message and returns one reply, with up to six tool round trips in
between. The client is an interface rather than an OpenAI instance, so the whole conversation is
testable without a key: 13 cases drive a scripted client through the tool call, the bad argument,
the unparseable JSON, the unknown tool and the runaway model.

The six tools from SPEC §3.5 are defined next to their zod schemas, because `arguments` arrives as
a JSON string and the model will send a shape that does not match `parameters`. Parse, then
validate, then dispatch. One place, so nothing skips it.

A failing tool goes back to the model as a result, not an exception. "No approval device
connected" is something the user has to be told, and a throw would end the turn in silence. The
error is still recorded on the turn so callers do not have to read prose.

The round-trip cap answers every outstanding function_call before it stops. Leaving one unanswered
makes the next request malformed, which reads as a confusing 400 rather than as the cap working.

handlers.ts wires the tools to the proposal store and refuses to propose with no device connected.
Its chain and shop reads are interfaces: nothing is deployed yet, and the shop tables do not
exist. Whoever lands the deploy writes one implementation and no other file changes.

Deliberately no injection defence in the tools or the prompt. The defence is the human pressing
Back. DECISIONS #10.

One known gap is pinned by a test rather than papered over: a wei integer is a valid decimal
string, so the schema cannot tell it from a very large amount. Only the tool description stands
against it. If spike 2 shows the model doing it, the fix is a bound checked against the balance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The proposal store and the agent loop exist now, both on this branch. The next three moves are
chat.send, the two reader implementations that unblock on the deploy, and spike 2, which is still
unrun. Records the AgentSigner shape change, because it differs from the sketch in the workstream
brief on purpose.

Adds the env lines the next session needs, since .env.example sits outside Claude's write
permissions and did not get them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ryannzander
ryannzander merged commit 6a23c06 into main Sep 5, 2026
2 checks passed
MAX_TOOL_ROUND_TRIPS capped the tools that ran, not the calls to the API.
Once the budget was spent the loop answered every pending call with "stop
calling tools" and went round again, so a model that kept asking kept
billing and the turn never returned.

The cap now sets forceAnswer. The next request forbids tools and is the
last one whatever comes back, and any call in that final response is
dropped rather than carried, because nothing will answer it and an
unanswered function_call in the history breaks every later turn.

The old cap test hid this: its scripted model gave up on its own after
twenty responses. The new one never stops asking, and hangs without the
fix.
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