Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,27 +18,29 @@
"preview": "next build && next start",
"spike:agent": "tsx src/server/agent/spike.ts",
"start": "next start",
"test": "vitest run",
"verify:channel": "tsx --env-file=.env src/server/tappy/verifyChannel.ts",
"verify:store": "tsx --env-file=.env src/server/tappy/verifyStore.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.69.0",
"@tappy/contracts": "workspace:*",
"@tappy/protocol": "workspace:*",
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
"drizzle-orm": "^0.41.0",
"next": "^15.2.3",
"openai": "^7.10.0",
"postgres": "^3.4.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"server-only": "^0.0.1",
"superjson": "^2.2.1",
"zod": "^3.24.2",
"@tappy/protocol": "workspace:*",
"@tappy/contracts": "workspace:*",
"viem": "^2.21.0",
"openai": "^7.10.0"
"zod": "^3.24.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
Expand All @@ -56,7 +58,8 @@
"tailwindcss": "^4.0.15",
"tsx": "^4.19.2",
"typescript": "^5.8.2",
"typescript-eslint": "^8.27.0"
"typescript-eslint": "^8.27.0",
"vitest": "^2.1.9"
},
"ct3aMetadata": {
"initVersion": "7.40.0"
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ export const env = createEnv({
.string()
.regex(/^0x[0-9a-fA-F]{40}$/)
.optional(),
/**
* Which key signs as the agent. `local` reads AGENT_PRIVATE_KEY; `privy` is cut line #4 and
* is not implemented yet. Testnet keys are checked in on purpose, so this is not a secret.
*/
AGENT_SIGNER: z.enum(["local", "privy"]).default("local"),
AGENT_PRIVATE_KEY: z
.string()
.regex(/^0x[0-9a-fA-F]{64}$/)
.optional(),
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
Expand All @@ -41,6 +50,8 @@ export const env = createEnv({
DATABASE_URL: process.env.DATABASE_URL,
BRIDGE_TOKEN: process.env.BRIDGE_TOKEN,
HUMAN_ADDRESS: process.env.HUMAN_ADDRESS,
AGENT_SIGNER: process.env.AGENT_SIGNER,
AGENT_PRIVATE_KEY: process.env.AGENT_PRIVATE_KEY,
NODE_ENV: process.env.NODE_ENV,
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
},
Expand Down
31 changes: 23 additions & 8 deletions apps/web/src/server/agent/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
The agent tool-calling loop. See `docs/workstreams/app.md`.

- `loop.ts` — the agent loop. Cap it at 6 tool round-trips.
- `tools.ts` — tool definitions (SPEC §3.5). Parse inputs with `JSON.parse`, never string matching.
- `prompt.ts` — the system prompt. It must tell the model it can only propose, and never to claim
success before status is `EXECUTED`.

Model is `gpt-5.6-terra` via the OpenAI SDK (`openai`). Tool calling must go through
`client.responses.create` — GPT-5.6 rejects function tools on `/v1/chat/completions`
while reasoning is on. `arguments` comes back as a JSON string, so `JSON.parse` it.
Built:

- `loop.ts` — `runAgentTurn`. One user message in, one reply out, up to 6 tool round trips. Takes
the client as an interface so tests drive it without a key.
- `tools.ts` — the six definitions from SPEC §3.5, their zod schemas, and the `AgentTools`
interface the loop calls.
- `prompt.ts` — the system prompt.
- `handlers.ts` — `createAgentTools`, which wires the tools to the proposal store and the signer.
- `spike.ts` — spike 2, unrun. Needs `OPENAI_API_KEY`.

Model is `gpt-5.6-terra` via the OpenAI SDK. Tool calling must go through
`client.responses.create`; GPT-5.6 rejects function tools on `/v1/chat/completions` while
reasoning is on. `arguments` comes back as a JSON string, so `JSON.parse` it and then validate.
`reasoning.effort` defaults to `medium`, which is the main cost lever; the loop uses `low`.

Not wired yet:

- `ChainReader` and `ShopReader` in `handlers.ts` are interfaces, because nothing is deployed
(issue #5) and the shop tables do not exist. One implementation each and the rest is unchanged.
- No tRPC procedure calls `runAgentTurn` yet. That is `chat.send`.

A failing tool comes back to the model as a result rather than an exception, because the user has
to be told. "No approval device connected" is an answer; a thrown error is silence.
211 changes: 211 additions & 0 deletions apps/web/src/server/agent/handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/**
* The tools, wired to the store and the signer.
*
* Everything that needs a chain goes through `ChainReader`, which is an interface rather than a
* viem client because nothing is deployed yet (issue #5). When the deploy lands, someone writes
* one implementation and no other file changes.
*
* The rules enforced here rather than in the model's prompt, because a prompt is not a guard:
* - No signer connected, no proposal. It fails loudly and never queues.
* - One proposal pending at a time; the store refuses the second and hands back the first.
*/

import { formatUnits, parseUnits } from "viem";
import type { Address } from "viem";

import { chainByKey, type Action } from "@tappy/protocol";

import type { db as Database } from "~/server/db";
import type { AgentSigner } from "~/server/tappy/agentSigner";
import { buildProposal } from "~/server/tappy/proposals";
import {
PendingProposalError,
getProposal,
insertProposal,
listProposals,
} from "~/server/tappy/store";
import type {
AgentTools,
ProposalSummary,
ProposeResult,
WalletSummary,
} from "./tools";

type Db = typeof Database;

/** The chain reads the tools need. One implementation, once something is deployed. */
export interface ChainReader {
/** `TappyGate.nonce()`. Guessing it produces a proposal that reverts. */
nonce(): Promise<bigint>;
/** The gate's native balance, in wei. */
balance(): Promise<bigint>;
/** `MockSwap.quote(valueIn)`, used when the model does not name a minimum. */
quoteSwap(sellWei: bigint): Promise<bigint>;
}

/** The shop's side of `propose_buy`. The server reads the price; the model never supplies one. */
export interface ShopReader {
item(itemId: string): Promise<{ invoiceId: string; itemName: string; priceWei: bigint; merchant: Address; shopUrl: string } | undefined>;
}

export interface AgentToolDeps {
db: Db;
signer: AgentSigner;
chain: ChainReader;
shop: ShopReader;
gate: Address;
/** The demo DEX and the token it pays out. Required, so a swap cannot quietly target the gate. */
dex: Address;
tokenOut: Address;
chainKey: string;
/** Whether the bridge is polling. Checked on every propose, not cached. */
signerConnected: () => Promise<{ connected: boolean; address?: string }>;
/** How much slippage to allow when the model does not name a minimum. 1% by default. */
swapSlippageBps?: number;
}

export function createAgentTools(deps: AgentToolDeps): AgentTools {
const chain = chainByKey(deps.chainKey);
const decimals = chain.nativeDecimals;
const slippageBps = BigInt(deps.swapSlippageBps ?? 100);

/**
* Building a proposal the human cannot be shown is worse than refusing: the row would sit
* PENDING_HUMAN, block every later proposal, and expire ten minutes later with no explanation.
*/
async function requireSigner(): Promise<void> {
const status = await deps.signerConnected();
if (!status.connected) {
throw new Error(
"No approval device connected. Nothing can be proposed until the human's Flipper is plugged in and the bridge is running.",
);
}
}

async function propose(action: Action, summary: string): Promise<ProposeResult> {
await requireSigner();
const { proposal, view } = await buildProposal(
{
action,
chainId: chain.chainId,
gate: deps.gate,
nonce: await deps.chain.nonce(),
chainKey: deps.chainKey,
originator: "chat",
},
deps.signer,
);

try {
const stored = await insertProposal(deps.db, proposal, view);
return { proposalId: stored.id, status: stored.status, summary };
} catch (error) {
if (error instanceof PendingProposalError) {
// Tell the model what is already on the device. Retrying would only collide again.
throw new Error(
`Proposal ${error.pending.id} (${describe(error.pending.action, chain.nativeSymbol, decimals)}) ` +
"is already waiting on the human's device. It has to be approved or rejected before another can be made.",
);
}
throw error;
}
}

function summarise(id: string, status: ProposalSummary["status"], text: string): ProposalSummary {
return { proposalId: id, status, summary: text };
}

return {
async get_wallet(): Promise<WalletSummary> {
const [balance, signer, agent] = await Promise.all([
deps.chain.balance(),
deps.signerConnected(),
deps.signer.address(),
]);
return {
gate: deps.gate,
chain: chain.name,
balance: `${formatUnits(balance, decimals)} ${chain.nativeSymbol}`,
agent,
human: signer.address ?? "unknown",
signerConnected: signer.connected,
};
},

propose_send(input) {
const valueWei = parseUnits(input.amountEth, decimals);
return propose(
{ kind: "send", to: input.to, valueWei, ...(input.memo ? { memo: input.memo } : {}) },
`Send ${input.amountEth} ${chain.nativeSymbol} to ${input.to}. Waiting on the human's device.`,
);
},

async propose_swap(input) {
const sellWei = parseUnits(input.sellEth, decimals);
// A missing minimum is not zero. Zero means "accept any amount of tokens", which is the one
// thing a swap must never do.
const minBuy = input.minTokensOut
? parseUnits(input.minTokensOut, decimals)
: ((await deps.chain.quoteSwap(sellWei)) * (10_000n - slippageBps)) / 10_000n;

return propose(
{
kind: "swap",
dex: deps.dex,
sellWei,
minBuy,
tokenOut: deps.tokenOut,
},
`Swap ${input.sellEth} ${chain.nativeSymbol} for at least ${formatUnits(minBuy, decimals)} tokens. Waiting on the human's device.`,
);
},

async propose_buy(input) {
const item = await deps.shop.item(input.itemId);
if (!item) throw new Error(`No shop item ${input.itemId}.`);

return propose(
{
kind: "buy",
merchant: item.merchant,
valueWei: item.priceWei,
invoiceId: item.invoiceId,
itemName: item.itemName,
shopUrl: item.shopUrl,
},
`Buy ${item.itemName} for ${formatUnits(item.priceWei, decimals)} ${chain.nativeSymbol}. Waiting on the human's device.`,
);
},

async get_proposal(input) {
const p = await getProposal(deps.db, input.proposalId);
if (!p) throw new Error(`No proposal ${input.proposalId}.`);
return {
proposalId: p.id,
status: p.status,
summary: describe(p.action, chain.nativeSymbol, decimals),
...(p.txHash ? { txHash: p.txHash } : {}),
...(p.error ? { error: p.error } : {}),
};
},

async list_proposals(input) {
const rows = await listProposals(deps.db, input.limit ?? 20);
return rows.map((p) =>
summarise(p.id, p.status, describe(p.action, chain.nativeSymbol, decimals)),
);
},
};
}

/** Prose for the model. The human's version of this is `toView`, and it is not this. */
function describe(action: Action, symbol: string, decimals: number): string {
switch (action.kind) {
case "send":
return `Send ${formatUnits(action.valueWei, decimals)} ${symbol} to ${action.to}`;
case "swap":
return `Swap ${formatUnits(action.sellWei, decimals)} ${symbol} for at least ${formatUnits(action.minBuy, decimals)} tokens`;
case "buy":
return `Buy ${action.itemName} for ${formatUnits(action.valueWei, decimals)} ${symbol}`;
}
}
Loading
Loading