agent-market/README.md
leora 70f5b0525e Local model: compact prompt (1.2k tokens, same for every visitor) so typed replies take ~6 s instead of timing out
Root cause of the 45 s timeouts: the local model got the full hosted prompt (~3.1k tokens with examples). Any prompt-cache
miss meant ~40 s of CPU prefill on the single slot, and every request queued behind one paid it too.

- LOCAL_PROMPT/_local_system_prompt: terse action list, one-line examples, default page text, one line per listing
  (L-refs, dollar prices). No per-visitor data in it, so the cache always hits; it only changes with the catalog.
- _local_user: look overrides (if any) + keyword/budget MATCHING LISTINGS hint + 'VISITOR: <message>'.
- max_tokens 200 for the local model; over-budget card picks dropped when some fit (_respect_budget).
- Buy vs sell examples separated (sell my own thing -> listing form, never checkout).
- 18/18 correct on typical prompts, warm median 5.9 s, max 7.7 s; cold prefill ~13 s.
2026-10-08 22:37:57 +08:00

22 KiB
Raw Blame History

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 ──▶ free hosted LLMs (OpenAI-compatible)
        ▲   renders UI actions               │              then the local model on this box (llama-server)
        │                                    ├── /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 prompt maps where each slot is ("top left" = copy.logo, title, subtitle, footer, ...) and includes the visitor's current page text (PAGE_TEXT, from market/copyedit.py), so "replace agent market to new internet on the top left corner" becomes {"copy":{"logo":"New Internet"}}. There is no rule-based text or look editing: every typed request is answered by a model (see below).

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

That is the whole chain. There are no keyword rules for typed messages.

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 (or none is configured), the reply is an honest "The assistant is busy, please try again in a moment." with a Retry button that resends the same message (agent: "unavailable", no actions; the badge says AI: busy). Typed messages never get rule-based answers. The header badge shows the model that answered.

A small model runs on the box with llama.cpp's llama-server (the build that ships inside Ollama v0.40.1 under ~/ollama/lib/ollama/; any llama.cpp build works). The server tries it after the hosted chain, through the same OpenAI-compatible endpoint (/v1/chat/completions), with no key.

It gets its own compact prompt, because CPU prefill costs about 13 ms per prompt token:

  • LOCAL_PROMPT + _local_system_prompt(): a terse action list, a dozen one-line examples, the default page text and a one-line-per-listing catalog with short refs (L1, L2, ...; small models miscopy 8-hex ids, the server maps refs back) and dollar prices. About 1.2k tokens instead of about 3k, and it is the same for every visitor, so llama-server's prompt cache always hits. It only changes when the catalog does (the primer re-warms it then).
  • _local_user(): the per-visitor part (a few dozen tokens): the visitor's look overrides if any, a MATCHING LISTINGS hint (listings sharing a word with the message, within any stated budget), and the message.
  • A JSON GBNF grammar, max_tokens 200, no chat history and no tool round. Over-budget picks are dropped when some fit.

Measured on the demo catalog (12 typical prompts, warm): 12/12 correct, median 5.9 s, max 7.7 s. A turn right after a catalog change, before the primer runs, pays about 13 s of prefill once.

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 (the server's --alias)
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 get the busy reply instead of waiting
AGENT_MARKET_LOCAL_LLM_HISTORY chat turns sent to the local model (0)
AGENT_MARKET_LOCAL_LLM_MAX_TOKENS output cap for the local model (200; replies are one sentence + one action)
AGENT_MARKET_LOCAL_LLM_PRIME 1 = warm the prompt cache in the background (see below)

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.

  • Prompt cache. The first call with a new prompt prefix processes about 2.4k prompt tokens on the CPU (about 30–40 s). A background primer sends the current prompt once (1 output token) at start-up, whenever the catalog changes, and after the model server restarts (detected from llama-server's /metrics counter). Otherwise it only reads /metrics every 30 s, so an idle server stays at 0% CPU. --swa-full keeps Gemma's sliding-window layers reusable, so a warm turn only processes the new tokens (about 0.3 s). --cache-ram 1024 keeps a few prompt states (demo and staging) in RAM.

  • Running it. ops/local-llm-watchdog.sh --ensure (idempotent; deploy.sh calls it) starts a private copy of the watchdog under ~/llm/. It keeps llama-server on 127.0.0.1:11434 only, restarts it if it exits (backing off to 30 s if it keeps crashing) or fails 3 health checks 15 s apart, and rotates its log. Stop it with kill $(cat ~/llm/watchdog.pid). Lean flags so the rest of the box keeps headroom: text weights only (--no-mmproj, the 0.8 GB vision projector is never loaded), mmapped and not repacked (--no-repack, no second copy of the weights), -c 6144 -np 1, q8 KV cache, flash attention, -t 6 -tb 6 at nice 10, --poll 0 (threads sleep when idle). Overrides go in ~/.config/agent-market/local-llm-server.env. Model file: the gemma3:4b Q4_K_M text GGUF (2.4 GB), linked as ~/llm/gemma3-4b-it-Q4_K_M.gguf.

  • Resources (measured on this box: 8 vCPU, no GPU, 16 GB RAM). Idle: 0% CPU, 2.9 GB RSS, of which 0.6 GB is private and 2.4 GB is the mmapped model file (clean page cache the kernel can reclaim). While answering: about 6 cores at nice 10 for 4–8 s, RSS unchanged.

  • Latency (real chain, handle_chat, warm cache, compact prompt). Exact logo phrase 4.5 s, look edits 4–6 s, search 6 s, compare 6 s, checkout 4 s, cash meetup 4.5 s, prefilled listing form 7–8 s. Cold prefill of the 1.2k-token prompt: about 13 s, once per catalog change. The single slot is shared by demo and staging, so a staging test run makes a demo visitor wait for at most one other reply.

  • Model choice. Benchmarked on this box with the real prompt and catalog:

    model correct warm latency notes
    gemma3:4b (chosen) 18/18 (compact prompt), 15/15 (Ollama, long prompt) 4–8 s, cold about 18 s 2.9 GB RSS
    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. Ollama itself was dropped in favour of running llama-server directly: Ollama loaded the vision projector, a 16k context with 2 slots and an 8 GiB prompt cache, which grew to about 10 GB RSS.

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 model configured at all, clicks, forms and payments still work, and typed messages get the busy reply.

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 (a hosted model, then the local model; if both fail, a busy reply with a Retry button). 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 case to fallback_event if a click or form should open it deterministically.
  2. Component. Create it in web/src/components/, e.g.
    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

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 local model answers (or, without one, the busy reply).

Agent API quick start

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

python3 tests/e2e_http.py http://127.0.0.1:8795           # HTTP end-to-end incl. SPA shell + bundle (REQUIRE_LLM=1: typed turns must reach a model)
bash tests/llm_proxy_check.sh                             # fake LLM: 429 chain, cooldown, local model, busy reply (never rules), key handling, rate limit
python3 tests/nl_check.py http://127.0.0.1:8795           # real model (hosted or local): 6 natural-language prompts
python3 tests/test_theme.py                               # theme spec + customCss sanitiser
python3 tests/test_copy_defaults.py                       # page-text defaults in sync with the app, prompt shows PAGE_TEXT, no rule code left
python3 tests/test_agent_units.py                         # local-model normalisers (result focus, preset+reset, listing-form shape), primer restart detection
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, needs a model (16 checks)
NODE_PATH=/path/to/node_modules bash tests/fake_model_browser.sh   # React UI vs fake model: badges, busy reply + Retry, 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 hosted chain until OpenRouter's reset time, and the local model answers, labelled as such.
  • The mock payment provider is the only one. A real provider would need its own test keys and webhook secret.