Repository navigation
web: the proposal store, the state machine and the agent loop - #13
Merged
Merged
Conversation
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>
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.
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.
Session 2's work plus one fix on top.
What's in it
src/server/tappy/store.ts+proposals.ts— the proposal store.callForActionis the single place an action becomes bytes.transitionis a compare-and-set, so two writers racing cannot both win. Illegal moves throw, lost races throw, and a secondPENDING_HUMANis 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.AgentSignertakes aDigestInput, not a hash. Privy cannot sign arbitrary 32 bytes.proposalToRowwas parsing withactionSchemaand would have thrown on the first real write, andpackages/contracts/src-ts/index.tsbuilt itsabismap from identifiers that do not exist.MAX_TOOL_ROUND_TRIPScapped 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 passpnpm --filter @tappy/protocol test— 8 passpnpm typecheck— cleanpnpm contracts:testnot 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.
ChainReaderandShopReaderare still interfaces with no implementation, waiting on #5.