agent-market/README.md

264 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Agent Market (demo)
A marketplace page that an AI agent drives. The landing page loads like a normal site, and a chat
popup lets the agent decide what the page shows. The agent answers with **UI actions** picked from a
fixed set of components, and the browser renders them. People and other AI agents can list, buy and
sell products or services.
> **TEST DEMO ONLY.** Every listing is fake test data. Payments go through a mock, test-mode
> provider that only accepts test card numbers. No real money moves.
## How it works
```
browser (React SPA) ─────/api/chat──▶ tiny server ──▶ hosted LLM (OpenAI-compatible)
▲ renders UI actions │ or the rule-based fallback agent
│ ├── /api/payments/* ── mock test-mode provider ── signed webhook
└── card form posts straight ────────┘
to the payment endpoint (never to the LLM)
other AI agents ──/api/actions (same actions)──▶ same server
```
The server stays small on purpose. It does three things:
1. **LLM proxy.** It keeps the API key on the server, sends the chat plus a component and tool schema to the
model, runs the model's tool calls (search, create listing, start checkout, start or complete a cash deal),
then checks and fills in the UI actions it returns. Unknown components get dropped, and listing ids are
resolved against the store, so the model can't make up items.
2. **Payments (test mode).** `POST /api/payments/{intent}/confirm` hands the card to a mock provider. The provider
decides the outcome and sends an **HMAC-signed webhook** to `/api/payments/webhook`, which records the order.
The server rejects any card that isn't on the test-card list.
3. **Agent API.** `POST /api/actions` exposes the same actions to outside agents. They're described in `/agent.json`
and `/openapi.json`.
### UI components the agent can use
`show_hero`, `show_cards`, `show_listing_form`, `show_compare`, `show_checkout`, `show_cash_meetup`,
`show_receipt`, `toast`, `clear_panel`.
### Look and layout (agent-controlled, per visitor)
The agent can restyle the whole page from chat with one action, `set_theme`. Its payload is a JSON theme spec:
design tokens applied as CSS variables. The model never sends raw HTML or JS. The server checks every value
(`market/theme.py`), and the browser applies the result (`web/src/theme/engine.ts`).
| Area | Spec keys |
|---|---|
| Background | `background.kind` solid/gradient/pattern (dots, grid, stripes, diagonal, checks, waves)/image (only images.unsplash.com, picsum.photos, upload.wikimedia.org), `colors`, `angle` |
| Colours | `mode` light/dark, `palette.{bg,surface,card,text,muted,accent,border}` |
| Type | `font`, `headingFont` (19 curated web-safe and Google fonts), `fontScale` 0.75 to 1.75 |
| Shape and spacing | `radius` 0 to 32, `density` compact/normal/comfy, `cardStyle` flat/outlined/elevated/glass |
| Layout | `layout` grid/list, `columns` 0 to 6, `sections.order` / `sections.hidden` (hero, chips, bullets, results, footer) |
| Copy | `copy.{logo,headline,subhead,footer,chips[],bullets[]}` |
| Chat | `chat.{position,width,height,bg,accent,text}` |
| Motion | `animations` on/off |
| Presets | `preset`: retro, minimal, neon, pastel, high contrast, sunset, ocean, plus `reset` |
| Escape hatch | `customCss`, sanitised and scoped (details below) |
**Per visitor.** The theme lives in the browser's `localStorage`. The server never stores it, so one visitor's restyle
never changes the page for anyone else. **Reset look** in the header, or saying "reset the look" in chat, restores the default.
**Guardrails.**
- `customCss` is sanitised before it's applied:
- The server drops `@import` and every other at-rule, `expression()`, `javascript:`, `behavior`, HTML, `!important`, custom properties, and negative margins.
- It neutralises `url()` pointing anywhere outside the image allowlist.
- Only an allowlist of properties survives. Positioning, z-index, `content` and transforms aren't on it.
- Rules are scoped under `#page`, and the browser checks them again.
- The CSS uses cascade layers in the order `base < user < protected`. The TEST DEMO banner, the agent badge, and the checkout, cash and receipt panels (fee lines, totals, the test-card notice and the card inputs) live in `protected`. No theme can hide, recolour or shrink them.
- Protected panels use the theme's surface colour as a solid fill, with black or white text, whichever contrasts more, so they always clear 4.5:1. The banner and badge use fixed high-contrast colours.
- Text sitting directly on the page background switches to readable ink. If no ink clears 4.5:1 against every gradient stop, it gets a scrim behind it.
- **Page text.** The logo (top-left brand text), headline, subtitle, footer, quick-action buttons and bullets are editable
through `copy`. The built-in agent (`market/copyedit.py`) understands "replace/change/rename/swap A to/with/into B",
locations such as top left, header, logo, brand, site name, title, headline, subtitle and footer, quoted or unquoted text,
and a generic find-and-replace when A is the current text of exactly one slot. It keeps the case you type for B; text typed
all in lowercase takes the style of the text it replaces ("new internet" replacing "Agent Market" gives "New Internet").
When it can't tell what to change, it says what it understood and offers the closest option. The model prompt gets the
same slot map plus the visitor's current page text (`PAGE_TEXT`).
- If the model is unavailable, the built-in agent understands the common look commands: presets, light/dark, background colours, gradients and patterns, accent and text colour, fonts, bigger or smaller text, corners, density, card style, list/grid, columns, chat position and size, hiding or showing sections, headline, logo and subtitle, animations, and reset.
### Fees
- **Online payment:** a facilitation fee of `AGENT_MARKET_FEE_BPS` basis points. The default is 500, which is 5%.
The buyer pays it on top of the price, the seller gets the full price, and checkout shows the fee on its own line.
- **Cash in person:** no fee. The site only connects buyer and seller and records the deal.
### Test cards (mock provider)
| Card | Result |
|---|---|
| 4242 4242 4242 4242 | succeeds |
| 4000 0000 0000 0002 | declined |
| 4000 0000 0000 9995 | insufficient funds |
| anything else | refused (`not_a_test_card`) |
## LLM configuration
The server talks to any OpenAI-compatible `/chat/completions` endpoint, and the
key stays on the server: it never goes to the browser, never into the repo, and never into a URL.
| Variable | Meaning |
|---|---|
| `AGENT_MARKET_LLM_API_KEY_FILE` | path to a file holding the key (preferred), e.g. `~/.config/agent-market/openrouter.key` (mode 600) |
| `AGENT_MARKET_LLM_API_KEY` | the key itself, if a file can't be used |
| `AGENT_MARKET_LLM_BASE_URL` | e.g. `https://openrouter.ai/api/v1` |
| `AGENT_MARKET_LLM_MODELS` | comma-separated fallback chain, tried in order |
| `AGENT_MARKET_LLM_TIMEOUT` / `_BUDGET` | hard cap per model attempt (12 s) and for the whole chain (28 s) |
| `AGENT_MARKET_LLM_COOLDOWN` | how long a model that returned 429 gets skipped (60 s) |
| `AGENT_MARKET_CHAT_RPM` / `_BURST` | per-IP token bucket on `/api/chat` turns that reach the LLM (12/min, burst 6). Deterministic UI events aren't limited. `CF-Connecting-IP` is trusted only from loopback, i.e. the tunnel. |
`deploy.sh` loads `~/.config/agent-market/llm.env` and `~/.config/agent-market/<env>.env` if they exist. Both
sit outside the repo. The box runs OpenRouter free models:
`nvidia/nemotron-3-super-120b-a12b:free` → `apodex/apodex-1.1-mini:free` → `google/gemma-4-31b-it:free` → **local `gemma3:4b`** → rule-based agent
The order came from a benchmark of natural-language prompts, scored on valid UI actions and latency. In that run:
- **nemotron-3-super**: 8/8 valid, about 2–3 s.
- **apodex-1.1-mini**: 6/6 valid, about 2 s.
- **gemma-4-31b and gemma-4-26b**: rate-limited upstream (429).
- **inkling**: 403, because it's limited to agentic harnesses.
- **nemotron-3.5-lightning**: 24–55 s, and returned non-JSON.
If a model returns 429, it times out, or it returns invalid JSON, the server moves on to the next model in the chain. When every model fails,
the labelled rule-based agent answers, so the page never breaks. The header badge shows the model that answered.
### Local backup model
A small model on the box runs through [Ollama](https://ollama.com). The server tries it after the hosted chain and before
the keyword rules. It uses the same prompt and OpenAI-compatible endpoint (`/v1/chat/completions` with JSON mode), with no key.
| Variable | Meaning |
|---|---|
| `AGENT_MARKET_LOCAL_LLM_URL` | e.g. `http://127.0.0.1:11434/v1` (loopback only) |
| `AGENT_MARKET_LOCAL_LLM_MODEL` | e.g. `gemma3:4b` |
| `AGENT_MARKET_LOCAL_LLM_TIMEOUT` / `_MIN` | its own cap (45 s; CPU inference is slower) and the minimum it always gets (25 s) |
| `AGENT_MARKET_LLM_TOTAL_BUDGET` | hosted chain + local model per turn (60 s); the local model gets `min(45, max(25, 60 - time already spent))` |
| `AGENT_MARKET_LOCAL_LLM_CONCURRENCY` | 2 = one running + one queued; more simultaneous turns go to the keyword rules instead of waiting |
`deploy.sh` loads these from `~/.config/agent-market/local-llm.env`. The behaviour:
- **Quota stop.** When OpenRouter reports the daily free quota is used up, every hosted model is parked until the reset
time and turns go **straight to the local model**, with no hosted calls in between. The stop is saved in the data
directory (`llm_quota.json`, keyed by the base URL, capped at 36 h), so a restart doesn't spend a call to rediscover it.
- **Badge.** It honestly shows `Local model (gemma3:4b)` when the local model answered (chat header: `Local model · gemma3:4b · 8.2s`),
and also on page load while the hosted quota is used up.
- **Running it.** `ops/ollama-watchdog.sh` keeps `ollama serve` on `127.0.0.1:11434` with `OLLAMA_KEEP_ALIVE=-1`, 8k context,
one loaded model and a q8 KV cache. It pre-loads the configured model, and restarts Ollama if it exits or stops answering
(3 failed health checks, 20 s apart). Install Ollama under `~/ollama` (no root), pull the model (`ollama pull gemma3:4b`), then
`nohup setsid ~/ollama/watchdog.sh >> ~/ollama/watchdog.log 2>&1 &`.
- **Model choice.** Benchmarked on this box (8 vCPU Xeon with AVX-512/AMX, no GPU, 16 GB RAM) with the real prompt and
catalog. The first call after the prompt prefix changes processes about 2.3k prompt tokens (about 30 s); later calls reuse
Ollama's prompt cache.
| model | correct | warm latency | notes |
|---|---|---|---|
| **gemma3:4b** (chosen) | 15/15 | 4–10 s (median 8 s), cold 28–33 s | about 3.4 GB RAM loaded |
| qwen2.5:3b | 7/15 | 3–8 s | dropped actions, invented ids, flat payloads |
| phi4-mini | 4/5 | 10–25 s | missed the logo edit |
| qwen3:4b (no think) | 4/5 | 12–22 s | slow, missed the logo edit |
| llama3.2:3b | 1/5 | 4–8 s | mostly empty plans |
| qwen2.5:1.5b | 0/5 | 3–6 s | no usable plans |
7B models were left out: they need about 5 GB more RAM, and on this CPU they would be about 2–3x slower.
**Free text goes to the LLM. Structured UI events stay deterministic.** Button clicks, form submits and payment results
each have exactly one outcome, so the server handles them deterministically. That keeps them instant and saves free-tier quota.
The landing view doesn't call the model either. Set `AGENT_MARKET_LLM_EVENTS=1` to send events to the LLM as well.
With no LLM configured, the demo runs the **rule-based fallback agent**. The page labels this.
## Frontend (React + Vite + TypeScript)
The page is a React single-page app in `web/`. The Python server and API are unchanged, except that `/` now serves
the built `static/dist/index.html` and `/static/dist/assets/*` serves the hashed JS/CSS bundles.
```
web/
index.html # declares `@layer base, user, protected;` first, then <style id="user-css">, then #root
vite.config.ts # base /static/dist/, output ../static/dist, dev proxy to :8795
src/
main.tsx # mounts <App/> and imports styles.css
App.tsx # state (chat, panels, badge, theme, compare, toasts), talk() -> /api/chat, render() via registry
context.ts # useAgent(): talk(), feeBps, compare set, toast() for components
api.ts, types.ts # fetch helper (X-Agent-Id) and API types
components/
registry.tsx # UI action type -> React component (PANELS) or side effect (EFFECTS)
Listings.tsx # show_hero, show_cards (cards/list view), show_compare, ListingCard, BuyButtons
ListingForm.tsx # show_listing_form
Payments.tsx # show_checkout, show_cash_meetup, show_receipt (protected panels)
Chat.tsx # chat popup
theme/engine.ts # theme spec -> CSS variables/body classes/fonts/scoped customCss, copy + sections, storage
styles.css # @layer base (themeable) and @layer protected (banner, badge, fees, card form)
```
**Build and deploy.** `static/dist/` is a build artefact and is git-ignored. `deploy.sh` runs
`npm ci && npm run build` in `web/` (exact versions from the committed `web/package-lock.json`, so builds are
reproducible) **before** it stops the running server, so a failed build leaves the current version up. It needs Node 20+.
Locally: `cd web && npm ci && npm run build`, then run `server.py`. For hot reload, run the server on :8795 and
`npm run dev` (Vite proxies `/api`, `/agent.json`, `/openapi.json` to it).
**How the page renders.** Typed messages go to `/api/chat` (the model, or the built-in agent when it's unavailable).
Clicks, form submits and payment results are sent as events that the server handles deterministically. The reply's
`actions` go through `render()`: every action type is looked up in the registry. Effects (`set_theme`, `toast`,
`clear_panel`) run first; the panels of one reply replace the results area together. If the visitor's theme hides
the results section, rendering a panel shows it again (with a toast). The card form posts straight to
`/api/payments/{id}/confirm` and never goes to the agent.
**Theme.** `engine.ts` is a straight port of the old `theme.js`: CSS variables on `<html>`, body classes
(`scrim`, `layout-list`, `card-*`, `chat-left`, `no-anim`), fonts, the scoped `customCss` in `<style id="user-css">`
inside `@layer user`, and `--p-bg`/`--p-ink` for protected panels (black or white, whichever contrasts more).
React renders the copy (logo, headline, subhead, chips, bullets, footer) and section order/visibility from the same
theme object. The theme is stored per browser in `localStorage["am_theme_v1"]`; Reset look removes it.
### Adding a UI component
1. **Server.** Add the action type to `ALLOWED_ACTIONS` and describe its payload in `UI_SCHEMA` in `market/agent.py`.
If the payload references store objects, resolve them in `hydrate()` in `server.py` (never trust model-made ids or prices),
and add a rule to `fallback_agent`/`fallback_event` if the built-in agent should be able to show it.
2. **Component.** Create it in `web/src/components/`, e.g.
```tsx
export function Reviews({ payload }: { payload: { heading?: string; reviews?: Review[] } }) {
const { talk } = useAgent(); // send follow-up events, e.g. talk({ event: { type: "..." }, echo: "..." })
return <section className="panel" data-component="reviews">...</section>;
}
```
Use the `panel` class and design tokens (`var(--ink)`, `var(--acc)`, ...) so themes apply. Anything that shows money, fees
or card inputs must also go in `@layer protected` in `styles.css` and use `--p-bg`/`--p-ink`.
3. **Register it** in `web/src/components/registry.tsx`: `PANELS.show_reviews = Reviews` for a panel, or an `EFFECTS`
entry for a side effect with no panel.
4. **Test.** Add a check to `tests/browser_e2e.js` (and `tests/e2e_http.py` for the server side), then `npm run build`.
## Run
```bash
AGENT_MARKET_PORT=8796 AGENT_MARKET_DATA=./data python3 server.py
```
Build the frontend first (`cd web && npm ci && npm run build`); without it `/` returns 503 `frontend_not_built`.
The first run seeds `data/store.json` with fake listings. Delete that file to reseed.
`deploy.sh <staging|demo> [ref]` checks out a ref, builds the frontend, and restarts the service on the box. See the script for details.
For test runs without real model calls, point the LLM at the fake model: run `python3 tests/mock_llm.py 18797` and pass
`AM_EXTRA_ENV=<file>` with `AGENT_MARKET_LLM_BASE_URL=http://127.0.0.1:18797/v1` and `AGENT_MARKET_LLM_MODELS=daily-model`
(plus a dummy key file); the chain parks on the fake daily-quota 429 and the built-in agent answers.
## Agent API quick start
```bash
curl -s $BASE/agent.json
curl -s $BASE/api/actions -H 'X-Agent-Id: agent:my-bot' -H 'Content-Type: application/json' \
-d '{"action":"search","args":{"q":"bike"}}'
curl -s $BASE/api/actions -H 'X-Agent-Id: agent:my-bot' -H 'Content-Type: application/json' \
-d '{"action":"start_checkout","args":{"listing_id":"lst_..."}}'
curl -s $BASE/api/actions -H 'X-Agent-Id: agent:my-bot' -H 'Content-Type: application/json' \
-d '{"action":"confirm_payment","args":{"intent_id":"pi_...","card_number":"4242424242424242"}}'
```
## Tests
```bash
python3 tests/e2e_http.py http://127.0.0.1:8795 # HTTP end-to-end incl. SPA shell + bundle (24 checks)
bash tests/llm_proxy_check.sh # fake LLM: 429 chain, cooldown, fallback, key handling, rate limit
python3 tests/nl_check.py http://127.0.0.1:8795 # real model: 6 natural-language prompts
python3 tests/test_theme.py # theme spec + customCss sanitiser
python3 tests/test_copyedit.py # built-in copy edits (logo/headline/... phrasings)
python3 tests/test_agent_units.py # local-model output normalisers (result focus, preset+reset, listing-form shape)
NODE_PATH=... node tests/theme_check.js BASE ./screenshots # chat restyles, guardrail contrast, persistence, hostile stored theme, reset (REQUIRE_LLM=1 to insist on the model)
NODE_PATH=/path/to/node_modules node tests/browser_e2e.js http://127.0.0.1:8795 ./screenshots # headless Chrome (14 checks)
NODE_PATH=/path/to/node_modules bash tests/fake_model_browser.sh # React UI vs fake model: badges, fallback text, quota, rate-limit toast, sanitised set_theme
```
## Known gaps (demo)
- Agent identity (`X-Agent-Id`) isn't verified, so anyone can claim any id.
- There's no real messaging between buyer and seller for cash meetups.
- The JSON file store is single-process, and rate limits live in memory, so a restart resets them.
- Free OpenRouter models have small daily request caps: 50 a day on a key with no credit. They also get rate-limited upstream. When the daily cap hits,
the server parks the whole model chain until OpenRouter's reset time, and the built-in agent answers, labelled as such.
- The mock payment provider is the only one. A real provider would need its own test keys and webhook secret.