From 16231871cb2e52c01dbef331be5d96a2f9a49f29 Mon Sep 17 00:00:00 2001 From: leora Date: Thu, 8 Oct 2026 19:11:53 +0800 Subject: [PATCH] Agent Market demo v1: agent-driven UI, test-mode payments with 5% fee, free cash deals, open agent API --- .gitignore | 6 + README.md | 94 ++++++- deploy.sh | 23 ++ market/__init__.py | 0 market/agent.py | 519 +++++++++++++++++++++++++++++++++++++++ market/payments.py | 38 +++ market/store.py | 87 +++++++ server.py | 336 +++++++++++++++++++++++++ static/app.js | 213 ++++++++++++++++ static/index.html | 58 +++++ static/styles.css | 46 ++++ tests/browser_e2e.js | 78 ++++++ tests/e2e_http.py | 111 +++++++++ tests/llm_proxy_check.sh | 20 ++ tests/mock_llm.py | 28 +++ 15 files changed, 1655 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100755 deploy.sh create mode 100644 market/__init__.py create mode 100644 market/agent.py create mode 100644 market/payments.py create mode 100644 market/store.py create mode 100755 server.py create mode 100644 static/app.js create mode 100644 static/index.html create mode 100644 static/styles.css create mode 100644 tests/browser_e2e.js create mode 100755 tests/e2e_http.py create mode 100755 tests/llm_proxy_check.sh create mode 100755 tests/mock_llm.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c96d75d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +data/ +__pycache__/ +*.pyc +node_modules/ +screenshots/ +.env diff --git a/README.md b/README.md index d406ccc..a711da1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,93 @@ -# agent-market +# Agent Market (demo) -Demo: agent-driven marketplace. An AI agent renders the UI; humans and agents list, buy, and arrange cash deals. Test mode only. \ No newline at end of file +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 (static HTML/JS) ──/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`. + +### 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 model has to be hosted. The server reads its config from the environment only, and the key never reaches the browser: + +| Variable | Meaning | +|---|---| +| `AGENT_MARKET_LLM_API_KEY` | key for any OpenAI-compatible `/chat/completions` endpoint | +| `AGENT_MARKET_LLM_BASE_URL` | e.g. `https://api.x.ai/v1` | +| `AGENT_MARKET_LLM_MODEL` | model name | +| `XAI_API_KEY` / `OPENAI_API_KEY` | shortcuts with default base URL and model | + +With no key set, the demo runs a **rule-based fallback agent**. The page labels this in the header badge and the +chat header. It emits the same UI actions, so the whole flow still works. + +## Run +```bash +AGENT_MARKET_PORT=8796 AGENT_MARKET_DATA=./data python3 server.py +``` +The first run seeds `data/store.json` with fake listings. Delete that file to reseed. +`deploy.sh ` checks out a ref and restarts the service on the box. See the script for details. + +## 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 (23 checks) +bash tests/llm_proxy_check.sh # LLM proxy path against a fake LLM +NODE_PATH=/path/to/node_modules node tests/browser_e2e.js http://127.0.0.1:8795 ./screenshots # headless Chrome +``` + +## 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 there's no rate limiting on `/api/chat`. +- The mock payment provider is the only one. A real provider would need its own test keys and webhook secret. diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..ee65d15 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Deploy Agent Market on this box from a git ref. +# ./deploy.sh staging [ref] -> port 8795, data in ~/agent-market/staging-data (test data only) +# ./deploy.sh demo -> port 8796, always origin/main (merged code only) +set -euo pipefail +ENVN=${1:?staging|demo} +REPO=http://127.0.0.1:3000/leora/agent-market.git +case "$ENVN" in + staging) PORT=8795; REF=${2:-main} ;; + demo) PORT=8796; REF=main ;; + *) echo "unknown env"; exit 1 ;; +esac +ROOT=$HOME/agent-market/$ENVN +mkdir -p "$ROOT" +if [ ! -d "$ROOT/app/.git" ]; then git clone -q "$REPO" "$ROOT/app"; fi +cd "$ROOT/app" && git fetch -q origin && git checkout -q --detach "origin/$REF" 2>/dev/null || git checkout -q --detach "$REF" +echo "deploying $ENVN @ $(git rev-parse --short HEAD) on :$PORT" +PID=$(ss -ltnp 2>/dev/null | grep ":$PORT " | grep -o 'pid=[0-9]*' | cut -d= -f2 || true) +[ -n "$PID" ] && kill "$PID" && sleep 0.5 +AGENT_MARKET_PORT=$PORT AGENT_MARKET_ENV=$ENVN AGENT_MARKET_DATA=$ROOT/data \ + nohup python3 server.py >> "$ROOT/server.log" 2>&1 & +sleep 1 +curl -sf "http://127.0.0.1:$PORT/healthz" >/dev/null && echo "ok: http://127.0.0.1:$PORT" diff --git a/market/__init__.py b/market/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/market/agent.py b/market/agent.py new file mode 100644 index 0000000..db737e4 --- /dev/null +++ b/market/agent.py @@ -0,0 +1,519 @@ +"""Market agent: LLM-driven when a key is available, otherwise a deterministic fallback. + +Returns {reply, actions} where actions is a list of UI action objects from a fixed +component set the frontend knows how to render. +""" +from __future__ import annotations +import json, os, re, urllib.request, urllib.error +from .store import new_id, now + +FEE_BPS = int(os.environ.get("AGENT_MARKET_FEE_BPS", "500")) # 5.00% +FEE_RATE = FEE_BPS / 10000.0 + +UI_SCHEMA = """ +You are Market Guide, the concierge for Agent Market (a TEST DEMO marketplace). +You control the page by returning JSON UI actions the frontend renders. +Never invent IDs. Only use listing ids from SEARCH_RESULTS or CREATE_LISTING results. +Currency amounts are integer cents. Always mention that payments are TEST MODE ONLY. +Online purchases add a facilitation fee of FEE_PERCENT. Cash-in-person deals have $0 fee. + +Reply ONLY with a single JSON object of this shape (no markdown fences): +{ + "reply": "short helpful message to the user", + "actions": [ /* 0 or more of the actions below */ ] +} + +Allowed actions (pick only from this set): +1. {"type":"show_hero","payload":{"title":"...","subtitle":"...","bullets":["..."]}} +2. {"type":"show_cards","payload":{"heading":"...","listing_ids":["lst_..."]}} +3. {"type":"show_listing_form","payload":{"defaults":{"title":"","kind":"product","price":0,"description":"","category":"","area":"","payment_options":["online","cash"]}}} +4. {"type":"show_compare","payload":{"listing_ids":["lst_...","lst_..."],"heading":"..."}} +5. {"type":"show_checkout","payload":{"listing_id":"lst_...","quantity":1}} +6. {"type":"show_cash_meetup","payload":{"listing_id":"lst_...","suggested_place":"...","suggested_time":"..."}} +7. {"type":"show_receipt","payload":{"order_id":"ord_..."}} +9. {"type":"clear_panel","payload":{}} +8. {"type":"toast","payload":{"level":"info|ok|warn","text":"..."}} + +You may also ask the server to run tools by including a "tools" array. The server +executes them and you get the results before the final reply. Tools: +- {"name":"search","args":{"q":"...","kind":"product|service|any","limit":6}} +- {"name":"get_listing","args":{"id":"lst_..."}} +- {"name":"create_listing","args":{"title":"...","kind":"product|service","price":cents,"description":"...","category":"...","area":"...","payment_options":["online","cash"],"seller":"demo-seller","seller_type":"human","emoji":"📦"}} +- {"name":"start_checkout","args":{"listing_id":"...","buyer":"demo-buyer","quantity":1}} +- {"name":"start_cash_deal","args":{"listing_id":"...","buyer":"demo-buyer","place":"...","time":"..."}} +- {"name":"complete_cash_deal","args":{"deal_id":"cash_..."}} + +Card details never reach you: the checkout panel posts them straight to the test payment +endpoint and then sends you a UI_EVENT {"type":"payment_result",...}. React with show_receipt. +UI_EVENT messages are clicks/forms from the page (JSON). Handle them: + view_listing -> show_cards with that id; buy_online -> start_checkout then show_checkout; + buy_cash -> start_cash_deal then show_cash_meetup; compare -> show_compare; + sell -> show_listing_form; submit_listing -> create_listing then show_cards with the new id; + payment_result -> show_receipt; cash_completed -> complete_cash_deal then show_receipt. + +Prefer one clear panel at a time. For "show me bikes" run search then show_cards. +For "I want to buy X" show_checkout. For "meet in person" show_cash_meetup. +For "I want to sell" show_listing_form. Keep reply under 80 words. +""".replace("FEE_PERCENT", f"{FEE_RATE*100:.1f}%") + + +ALLOWED_ACTIONS = {"show_hero", "show_cards", "show_listing_form", "show_compare", + "show_checkout", "show_cash_meetup", "show_receipt", "clear_panel", "toast"} + + +def money(cents: int) -> str: + return f"${cents/100:.2f}" + + +def fee_for(amount_cents: int) -> int: + return int(round(amount_cents * FEE_RATE)) + + +def run_tools(store, tools): + results = [] + for t in tools or []: + name = t.get("name") + args = t.get("args") or {} + try: + if name == "search": + q = (args.get("q") or "").lower().strip() + kind = args.get("kind") or "any" + limit = int(args.get("limit") or 8) + hits = [] + for L in store.all("listings"): + if L.get("status") != "active": + continue + if kind in ("product", "service") and L.get("kind") != kind: + continue + blob = " ".join([ + L.get("title", ""), L.get("description", ""), L.get("category", ""), + " ".join(L.get("tags") or []), L.get("area", ""), L.get("kind", ""), + ]).lower() + if not q or all(tok in blob for tok in q.split()): + hits.append(L) + hits = hits[:limit] + results.append({"name": name, "ok": True, "listings": hits}) + elif name == "get_listing": + L = store.get("listings", args.get("id")) + results.append({"name": name, "ok": bool(L), "listing": L}) + elif name == "create_listing": + required = ["title", "kind", "price", "description"] + if any(not args.get(k) and args.get(k) != 0 for k in required): + results.append({"name": name, "ok": False, "error": "missing_fields"}) + else: + lid = new_id("lst") + L = { + "id": lid, "title": str(args["title"])[:120], + "kind": "service" if args.get("kind") == "service" else "product", + "price": max(0, int(args["price"])), + "currency": "USD", "description": str(args.get("description") or "")[:800], + "category": str(args.get("category") or "misc")[:40], + "tags": list(args.get("tags") or [])[:12], + "emoji": str(args.get("emoji") or "📦")[:4], + "seller": str(args.get("seller") or "demo-seller")[:60], + "seller_type": args.get("seller_type") if args.get("seller_type") in ("human", "agent") else "human", + "area": str(args.get("area") or "Demo Town")[:60], + "payment_options": [p for p in (args.get("payment_options") or ["online", "cash"]) if p in ("online", "cash")] or ["cash"], + "status": "active", "test_data": True, "created_at": now(), + } + store.put("listings", L) + store.log("listing_created", {"id": lid}) + results.append({"name": name, "ok": True, "listing": L}) + elif name == "start_checkout": + L = store.get("listings", args.get("listing_id")) + if not L: + results.append({"name": name, "ok": False, "error": "not_found"}) + elif L.get("status") != "active": + results.append({"name": name, "ok": False, "error": "listing_not_active"}) + elif "online" not in (L.get("payment_options") or []): + results.append({"name": name, "ok": False, "error": "online_not_available"}) + else: + qty = max(1, int(args.get("quantity") or 1)) + sub = L["price"] * qty + fee = fee_for(sub) + intent = { + "id": new_id("pi"), "listing_id": L["id"], "title": L["title"], + "buyer": str(args.get("buyer") or "demo-buyer")[:60], + "quantity": qty, "subtotal_cents": sub, "fee_cents": fee, + "fee_bps": FEE_BPS, "total_cents": sub + fee, + "currency": "USD", "status": "requires_payment", + "mode": "test", "provider": "mock", "created_at": now(), + } + store.put("intents", intent) + store.log("checkout_started", {"id": intent["id"]}) + results.append({"name": name, "ok": True, "intent": intent, "listing": L}) + elif name == "start_cash_deal": + L = store.get("listings", args.get("listing_id")) + if not L: + results.append({"name": name, "ok": False, "error": "not_found"}) + elif L.get("status") != "active": + results.append({"name": name, "ok": False, "error": "listing_not_active"}) + elif "cash" not in (L.get("payment_options") or []): + results.append({"name": name, "ok": False, "error": "cash_not_available"}) + else: + deal = { + "id": new_id("cash"), "listing_id": L["id"], "title": L["title"], + "buyer": str(args.get("buyer") or "demo-buyer")[:60], + "seller": L.get("seller"), "price_cents": L["price"], + "fee_cents": 0, "fee_bps": 0, "currency": "USD", + "place": str(args.get("place") or L.get("area") or "Demo Town square")[:120], + "time": str(args.get("time") or "this weekend, afternoon")[:80], + "status": "arranged", "mode": "cash_in_person", "created_at": now(), + } + store.put("cash_deals", deal) + store.log("cash_deal", {"id": deal["id"]}) + results.append({"name": name, "ok": True, "deal": deal, "listing": L}) + elif name == "complete_cash_deal": + deal = store.get("cash_deals", args.get("deal_id")) + if not deal: + results.append({"name": name, "ok": False, "error": "not_found"}) + elif deal["status"] == "completed": + results.append({"name": name, "ok": True, "deal": deal, "order": store.get("orders", deal.get("order_id"))}) + else: + order = {"id": new_id("ord"), "listing_id": deal["listing_id"], "title": deal["title"], + "buyer": deal["buyer"], "seller": deal["seller"], "method": "cash_in_person", + "subtotal_cents": deal["price_cents"], "fee_cents": 0, "fee_bps": 0, + "total_cents": deal["price_cents"], "seller_receives_cents": deal["price_cents"], + "currency": "USD", "status": "completed", "mode": "test", "created_at": now()} + store.put("orders", order) + deal.update(status="completed", order_id=order["id"], completed_at=now()) + store.put("cash_deals", deal) + L = store.get("listings", deal["listing_id"]) + if L and L.get("kind") == "product": + L["status"] = "sold"; store.put("listings", L) + store.log("cash_deal_completed", {"id": deal["id"], "order": order["id"]}) + results.append({"name": name, "ok": True, "deal": deal, "order": order}) + else: + results.append({"name": name, "ok": False, "error": "unknown_tool"}) + except Exception as e: + results.append({"name": name, "ok": False, "error": str(e)[:200]}) + return results + + +# ---------- deterministic fallback (no LLM key) ---------- + +def _search_q(store, q, kind="any", limit=6): + return run_tools(store, [{"name": "search", "args": {"q": q, "kind": kind, "limit": limit}}])[0]["listings"] + + +def fallback_event(store, ev: dict, actor: str): + t = ev.get("type") + lid = ev.get("listing_id") + if t == "view_listing" and lid: + L = store.get("listings", lid) + if L: + return {"reply": f"{L['title']}: {money(L['price'])}. {L['description']}", + "actions": [{"type": "show_cards", "payload": {"heading": "Listing details", "listing_ids": [lid]}}]} + if t == "buy_online" and lid: + res = run_tools(store, [{"name": "start_checkout", "args": {"listing_id": lid, "buyer": actor}}])[0] + if not res["ok"]: + return {"reply": f"Online checkout isn't available for this one ({res['error']}). Try cash in person.", "actions": []} + it = res["intent"] + return {"reply": (f"Checkout ready. {money(it['subtotal_cents'])} + {FEE_RATE*100:.1f}% facilitation fee " + f"{money(it['fee_cents'])} = {money(it['total_cents'])}. TEST MODE: card 4242 4242 4242 4242."), + "actions": [{"type": "show_checkout", "payload": {"listing_id": lid, "intent_id": it["id"]}}]} + if t == "buy_cash" and lid: + res = run_tools(store, [{"name": "start_cash_deal", "args": {"listing_id": lid, "buyer": actor}}])[0] + if not res["ok"]: + return {"reply": f"Cash isn't offered for this one ({res['error']}). You can pay online instead.", "actions": []} + d = res["deal"] + return {"reply": f"Cash meetup arranged. No fee: you pay the seller {money(d['price_cents'])} in person.", + "actions": [{"type": "show_cash_meetup", "payload": {"listing_id": lid, "deal_id": d["id"]}}]} + if t == "compare": + ids = [i for i in (ev.get("listing_ids") or []) if store.get("listings", i)][:4] + if len(ids) >= 2: + return {"reply": f"Here's a side-by-side of {len(ids)} listings, including what each costs online vs cash.", + "actions": [{"type": "show_compare", "payload": {"listing_ids": ids, "heading": "Compare"}}]} + return {"reply": "Pick at least two listings to compare.", "actions": []} + if t == "sell": + return fallback_agent(store, "i want to sell", [], actor) + if t == "submit_listing": + f = ev.get("fields") or {} + try: + price = int(round(float(str(f.get("price", "0")).replace("$", "")) * 100)) + except ValueError: + price = -1 + if not f.get("title") or not f.get("description") or price < 0: + return {"reply": "The listing needs a title, a description and a price.", + "actions": [{"type": "show_listing_form", "payload": {"defaults": f}}]} + args = {**f, "price": price, "seller": actor, "seller_type": "human"} + res = run_tools(store, [{"name": "create_listing", "args": args}])[0] + if not res["ok"]: + return {"reply": f"Couldn't create it: {res.get('error')}", "actions": []} + L = res["listing"] + return {"reply": f"Published “{L['title']}” at {money(L['price'])} (test listing).", + "actions": [{"type": "toast", "payload": {"level": "ok", "text": "Listing published"}}, + {"type": "show_cards", "payload": {"heading": "Your new listing", "listing_ids": [L["id"]]}}]} + if t == "payment_result": + oid = ev.get("order_id") + if ev.get("status") == "succeeded" and oid and store.get("orders", oid): + o = store.get("orders", oid) + return {"reply": f"Test payment succeeded. You paid {money(o['total_cents'])} including a {money(o['fee_cents'])} fee.", + "actions": [{"type": "show_receipt", "payload": {"order_id": oid}}]} + return {"reply": "The test payment didn't go through. Use card 4242 4242 4242 4242, or choose cash in person.", + "actions": [{"type": "toast", "payload": {"level": "warn", "text": "Payment declined (test)"}}]} + if t == "cash_completed": + res = run_tools(store, [{"name": "complete_cash_deal", "args": {"deal_id": ev.get("deal_id")}}])[0] + if res["ok"]: + o = res["order"] + return {"reply": f"Cash deal marked complete. Fee $0.00; the seller keeps all {money(o['total_cents'])}.", + "actions": [{"type": "show_receipt", "payload": {"order_id": o["id"]}}]} + return {"reply": "Couldn't find that cash deal.", "actions": []} + return {"reply": "I didn't recognise that action.", "actions": []} + + +def fallback_agent(store, message: str, history, actor: str = "demo-buyer"): + m = (message or "").strip() + low = m.lower() + actions = [] + reply = "" + + # welcome / help + if not m or low in ("hi", "hello", "hey", "help", "start"): + reply = ("Welcome to Agent Market (TEST DEMO). I drive the page: ask me to search, " + "compare, list something for sale, check out online (test card 4242…), or arrange a cash meetup. " + f"Online fee is {FEE_RATE*100:.1f}%; cash in person is free.") + actions = [{"type": "show_hero", "payload": { + "title": "Agent Market", + "subtitle": "An AI agent runs this page. List, buy, or meet for cash — all demo data.", + "bullets": [ + "Chat to search, list, buy, or arrange a meetup", + f"Online payments: test mode + {FEE_RATE*100:.1f}% facilitation fee", + "Cash in person: $0 fee — we just connect you", + "Agents can buy and sell via the open JSON API", + ], + }}] + return {"reply": reply, "actions": actions, "agent": "fallback"} + + # sell / list + if any(w in low for w in ("sell", "list something", "create a listing", "post a listing", "i want to sell")): + reply = "Fill in the listing form. I'll publish it as test data. Online fee only applies when a buyer pays online." + actions = [{"type": "show_listing_form", "payload": {"defaults": { + "title": "", "kind": "product", "price": 2500, "description": "", + "category": "misc", "area": "Demo Town", "payment_options": ["online", "cash"], "emoji": "📦", + }}}] + return {"reply": reply, "actions": actions, "agent": "fallback"} + + # cash meetup + if "cash" in low or "meetup" in low or "meet in person" in low or "in person" in low: + listings = _search_q(store, "") + cashable = [L for L in listings if "cash" in (L.get("payment_options") or [])] + # try to pick by keyword + pick = None + for L in cashable: + if any(t in low for t in (L.get("tags") or []) + [L.get("title", "").lower().split()[0]]): + pick = L + break + if not pick and cashable: + pick = cashable[0] + if not pick: + return {"reply": "No cash-friendly listings right now. Try searching first.", "actions": [], "agent": "fallback"} + res = run_tools(store, [{"name": "start_cash_deal", "args": { + "listing_id": pick["id"], "buyer": actor, + "place": pick.get("area") or "Demo Town square", + "time": "Saturday afternoon", + }}])[0] + deal = res["deal"] + reply = (f"Cash deal arranged for “{pick['title']}” at {money(pick['price'])}. " + f"Fee: $0. Meet at {deal['place']} — {deal['time']}. (TEST DEMO, no real meetup.)") + actions = [{"type": "show_cash_meetup", "payload": { + "listing_id": pick["id"], "deal_id": deal["id"], + "suggested_place": deal["place"], "suggested_time": deal["time"], + "fee_cents": 0, "price_cents": pick["price"], + }}] + return {"reply": reply, "actions": actions, "agent": "fallback", "tools": res} + + # buy / checkout + if any(w in low for w in ("buy", "checkout", "purchase", "pay for", "i'll take")): + listings = store.all("listings") + pick = None + # explicit id + m_id = re.search(r"lst_[a-f0-9]+", low) + if m_id: + pick = store.get("listings", m_id.group(0)) + if not pick: + for L in listings: + title_words = [w for w in L.get("title", "").lower().split() if len(w) > 3] + if any(w in low for w in title_words[:3]) or any(t in low for t in (L.get("tags") or [])): + if "online" in (L.get("payment_options") or []): + pick = L + break + if not pick: + online = [L for L in listings if "online" in (L.get("payment_options") or []) and L.get("status") == "active"] + pick = online[0] if online else None + if not pick: + return {"reply": "I couldn't match a listing to buy. Try “show bikes” first.", "actions": [], "agent": "fallback"} + res = run_tools(store, [{"name": "start_checkout", "args": {"listing_id": pick["id"], "buyer": actor}}])[0] + if not res.get("ok"): + return {"reply": f"Can't start online checkout: {res.get('error')}. Try cash instead.", "actions": [], "agent": "fallback"} + intent = res["intent"] + reply = (f"Checkout for “{pick['title']}”. Subtotal {money(intent['subtotal_cents'])} + " + f"{FEE_RATE*100:.1f}% fee {money(intent['fee_cents'])} = {money(intent['total_cents'])}. " + "TEST MODE: use card 4242 4242 4242 4242.") + actions = [{"type": "show_checkout", "payload": { + "listing_id": pick["id"], "intent_id": intent["id"], "quantity": 1, + "subtotal_cents": intent["subtotal_cents"], "fee_cents": intent["fee_cents"], + "fee_bps": FEE_BPS, "total_cents": intent["total_cents"], + }}] + return {"reply": reply, "actions": actions, "agent": "fallback"} + + # compare + if "compare" in low: + q = re.sub(r"compare|vs|versus|and", " ", low) + hits = _search_q(store, q.strip() or "bike", limit=4) + if len(hits) < 2: + hits = _search_q(store, "bike", limit=3) or _search_q(store, "", limit=3) + ids = [h["id"] for h in hits[:3]] + reply = f"Comparing {len(ids)} listings." + actions = [{"type": "show_compare", "payload": {"listing_ids": ids, "heading": "Compare"}}] + return {"reply": reply, "actions": actions, "agent": "fallback"} + + # search / show + q = low + for prefix in ("show me ", "show ", "find ", "search for ", "search ", "looking for ", "list "): + if q.startswith(prefix): + q = q[len(prefix):] + break + kind = "any" + if "service" in q: + kind = "service" + q = q.replace("services", "").replace("service", "") + if "product" in q: + kind = "product" + q = q.replace("products", "").replace("product", "") + max_price = None + mp = re.search(r"(?:under|below|less than|max)\s*\$?(\d+(?:\.\d+)?)", q) + if mp: + max_price = int(float(mp.group(1)) * 100) + q = q[:mp.start()] + q[mp.end():] + stop = {"everything", "all", "anything", "me", "a", "an", "the", "some", "any", "for", "i", "want", "need", "find", "show", "please"} + q = " ".join(w for w in re.sub(r"[^a-z0-9 ]", " ", q).split() if w not in stop) + q = " ".join(w[:-1] if w.endswith("s") and len(w) > 4 and w not in ("glass",) else w for w in q.split()) + hits = _search_q(store, q.strip(), kind=kind, limit=8) + if not hits and q.strip(): + # broader: any token + hits = _search_q(store, "", kind=kind, limit=8) + hits = [h for h in hits if any(tok in (h.get("title","")+h.get("description","")).lower() for tok in q.split() if len(tok)>2)][:6] + if max_price is not None: + hits = [h for h in hits if h["price"] <= max_price] + if hits: + reply = f"Found {len(hits)} listing(s). Click a card, or say “buy the bike” / “cash meetup for the lamp”." + actions = [{"type": "show_cards", "payload": {"heading": f"Results for “{q.strip() or 'everything'}”", "listing_ids": [h["id"] for h in hits]}}] + else: + reply = "Nothing matched. Try “bikes”, “tutoring”, “lamp”, or “lawn”." + actions = [{"type": "toast", "payload": {"level": "warn", "text": "No matches"}}] + return {"reply": reply, "actions": actions, "agent": "fallback"} + + +# ---------- LLM path ---------- + +def _llm_config(): + """Hosted LLM config from env only (OpenAI-compatible chat completions). Never logs the key.""" + key_env = None + for env in ("AGENT_MARKET_LLM_API_KEY", "XAI_API_KEY", "OPENAI_API_KEY"): + if os.environ.get(env): + key_env = env + break + if not key_env: + return None + defaults = { + "XAI_API_KEY": ("https://api.x.ai/v1", "grok-3-mini"), + "OPENAI_API_KEY": ("https://api.openai.com/v1", "gpt-4o-mini"), + "AGENT_MARKET_LLM_API_KEY": ("", ""), + }[key_env] + base = os.environ.get("AGENT_MARKET_LLM_BASE_URL") or defaults[0] + model = os.environ.get("AGENT_MARKET_LLM_MODEL") or defaults[1] + if not base or not model: + return None + return {"provider": base.split("//")[-1].split("/")[0], "url": base.rstrip("/") + "/chat/completions", + "model": model, "env": key_env} + + +def _call_llm(cfg, messages): + key = os.environ[cfg["env"]] + body = json.dumps({ + "model": cfg["model"], + "messages": messages, + "temperature": 0.2, + "response_format": {"type": "json_object"}, + }).encode() + req = urllib.request.Request(cfg["url"], data=body, method="POST", headers={ + "Authorization": f"Bearer {key}", + "Content-Type": "application/json", + }) + with urllib.request.urlopen(req, timeout=45) as r: + return json.loads(r.read().decode()) + + +def _parse_agent_json(text: str): + text = (text or "").strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text) + text = re.sub(r"\s*```$", "", text) + return json.loads(text) + + +def llm_agent(store, message: str, history, cfg): + # Round 1: may request tools + catalog = [{"id": L["id"], "title": L["title"], "kind": L["kind"], "price": L["price"], + "tags": L.get("tags"), "payment_options": L.get("payment_options")} + for L in store.all("listings") if L.get("status") == "active"] + sys = UI_SCHEMA + "\n\nCURRENT_LISTINGS_SUMMARY:\n" + json.dumps(catalog)[:4000] + msgs = [{"role": "system", "content": sys}] + for h in (history or [])[-8:]: + if h.get("role") in ("user", "assistant") and h.get("content"): + msgs.append({"role": h["role"], "content": str(h["content"])[:1000]}) + msgs.append({"role": "user", "content": message}) + + raw1 = _call_llm(cfg, msgs) + content1 = raw1["choices"][0]["message"]["content"] + plan = _parse_agent_json(content1) + tools = plan.get("tools") or [] + tool_results = run_tools(store, tools) if tools else [] + + if tools: + msgs.append({"role": "assistant", "content": content1}) + msgs.append({"role": "user", "content": "TOOL_RESULTS:\n" + json.dumps(tool_results)[:6000] + + "\nNow return the final {reply, actions} JSON. Do not include tools."}) + raw2 = _call_llm(cfg, msgs) + content2 = raw2["choices"][0]["message"]["content"] + plan = _parse_agent_json(content2) + + actions = plan.get("actions") or [] + # sanitize action types + allowed = ALLOWED_ACTIONS + actions = [a for a in actions if isinstance(a, dict) and a.get("type") in allowed] + return { + "reply": str(plan.get("reply") or "Done.")[:500], + "actions": actions, + "agent": cfg["provider"], + "model": cfg["model"], + "tools": tool_results, + } + + +def handle_chat(store, message: str, history=None, event=None, actor="demo-buyer"): + actor = (actor or "demo-buyer")[:60] + cfg = _llm_config() + if cfg: + text = message + if event: + text = "UI_EVENT " + json.dumps(event)[:2000] + f"\n(actor/buyer id: {actor})" + try: + return llm_agent(store, text, history or [], cfg) + except Exception as e: + out = fallback_event(store, event, actor) if event else fallback_agent(store, message, history or [], actor) + out["agent"] = "fallback" + out["llm_error"] = type(e).__name__ + return out + out = fallback_event(store, event, actor) if event else fallback_agent(store, message, history or [], actor) + out["agent"] = "fallback" + return out + + +def agent_status(): + cfg = _llm_config() + if cfg: + return {"mode": "llm", "provider": cfg["provider"], "model": cfg["model"]} + return {"mode": "fallback", "provider": "deterministic", "model": None, + "note": "No hosted LLM key configured (set AGENT_MARKET_LLM_API_KEY + AGENT_MARKET_LLM_BASE_URL + AGENT_MARKET_LLM_MODEL, or XAI_API_KEY / OPENAI_API_KEY). Using the labelled rule-based fallback agent."} diff --git a/market/payments.py b/market/payments.py new file mode 100644 index 0000000..4abbb30 --- /dev/null +++ b/market/payments.py @@ -0,0 +1,38 @@ +"""Mock TEST-MODE payment provider. Never contacts a real processor, never takes real money. + +Only well-known test card numbers are accepted; any other number is rejected so a real +card can never be "charged" even by accident. Card numbers are not stored (last4 only). +""" +import hashlib, hmac, json, urllib.request +from .store import now, new_id + +TEST_CARDS = { + "4242424242424242": ("succeeded", None), + "4000000000000002": ("failed", "card_declined"), + "4000000000009995": ("failed", "insufficient_funds"), +} + + +def sign(secret: str, body: bytes) -> str: + return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() + + +def charge(intent: dict, card_number: str, webhook_url: str, secret: str) -> dict: + """Simulate the provider: decide outcome, then deliver a signed webhook to the platform.""" + digits = "".join(ch for ch in str(card_number or "") if ch.isdigit()) + if digits not in TEST_CARDS: + return {"status": "failed", "error": "not_a_test_card", + "message": "TEST MODE: only test cards are accepted (use 4242 4242 4242 4242)."} + status, err = TEST_CARDS[digits] + event = { + "id": new_id("evt"), "type": "payment_intent." + ("succeeded" if status == "succeeded" else "payment_failed"), + "created": now(), "livemode": False, + "data": {"intent_id": intent["id"], "amount_cents": intent["total_cents"], "fee_cents": intent["fee_cents"], + "card_last4": digits[-4:], "failure_code": err}, + } + body = json.dumps(event).encode() + req = urllib.request.Request(webhook_url, data=body, method="POST", headers={ + "Content-Type": "application/json", "X-Mock-Signature": sign(secret, body)}) + with urllib.request.urlopen(req, timeout=10) as r: + ack = json.loads(r.read().decode()) + return {"status": status, "error": err, "event_id": event["id"], "webhook_ack": ack} diff --git a/market/store.py b/market/store.py new file mode 100644 index 0000000..fdf439f --- /dev/null +++ b/market/store.py @@ -0,0 +1,87 @@ +"""JSON-file store for the demo. Test data only.""" +import json, os, threading, time, uuid, copy + +SEED_LISTINGS = [ + {"title": "Used road bike (54 cm frame)", "kind": "product", "price": 18000, "category": "bikes", + "description": "Aluminium road bike, 18 gears, new tyres last season. Some scratches on the top tube.", + "tags": ["bike", "bicycle", "cycling", "road"], "emoji": "🚲", "seller": "test-seller-01", "seller_type": "human", + "area": "Demo Town (north)", "payment_options": ["online", "cash"]}, + {"title": "Maths tutoring, 1 hour (high school)", "kind": "service", "price": 3500, "category": "tutoring", + "description": "One-on-one algebra, geometry or calculus help. Online or at a library.", + "tags": ["tutor", "tutoring", "math", "maths", "lesson", "school"], "emoji": "📐", "seller": "test-seller-02", + "seller_type": "human", "area": "Online / Demo Town", "payment_options": ["online", "cash"]}, + {"title": "Python coding tutoring, 1 hour", "kind": "service", "price": 4500, "category": "tutoring", + "description": "Beginner-friendly Python sessions: basics, small projects, debugging help.", + "tags": ["tutor", "tutoring", "coding", "python", "programming", "lesson"], "emoji": "🐍", + "seller": "agent:tutor-bot (test agent)", "seller_type": "agent", "area": "Online", "payment_options": ["online"]}, + {"title": "Handmade ceramic table lamp", "kind": "product", "price": 6500, "category": "home", + "description": "Wheel-thrown glazed ceramic base, linen shade, warm LED bulb included.", + "tags": ["lamp", "light", "handmade", "ceramic", "home", "decor"], "emoji": "🪔", "seller": "test-seller-03", + "seller_type": "human", "area": "Demo Town (east)", "payment_options": ["online", "cash"]}, + {"title": "Lawn mowing, small yard", "kind": "service", "price": 4000, "category": "garden", + "description": "Mow, edge and blow for yards up to about 300 m². Green waste taken away.", + "tags": ["lawn", "mowing", "garden", "yard", "grass"], "emoji": "🌿", "seller": "test-seller-04", + "seller_type": "human", "area": "Demo Town (south)", "payment_options": ["online", "cash"]}, + {"title": "Bike tune-up service", "kind": "service", "price": 3000, "category": "bikes", + "description": "Gears and brakes adjusted, chain cleaned and lubed, tyres checked.", + "tags": ["bike", "bicycle", "repair", "service", "tune"], "emoji": "🔧", + "seller": "agent:fixit-bot (test agent)", "seller_type": "agent", "area": "Demo Town (west)", + "payment_options": ["online", "cash"]}, + {"title": "Kids' bike, 16 inch", "kind": "product", "price": 5500, "category": "bikes", + "description": "Training wheels included. Fits ages 4 to 6.", + "tags": ["bike", "bicycle", "kids"], "emoji": "🚴", "seller": "test-seller-05", "seller_type": "human", + "area": "Demo Town (north)", "payment_options": ["cash"]}, +] + + +def now(): + return time.strftime("%Y-%m-%dT%H:%M:%S%z") + + +def new_id(prefix): + return f"{prefix}_{uuid.uuid4().hex[:8]}" + + +class Store: + def __init__(self, data_dir): + os.makedirs(data_dir, exist_ok=True) + self.path = os.path.join(data_dir, "store.json") + self.lock = threading.RLock() + if os.path.exists(self.path): + with open(self.path) as f: + self.db = json.load(f) + else: + self.db = {"listings": {}, "intents": {}, "orders": {}, "cash_deals": {}, "events": []} + for s in SEED_LISTINGS: + lid = new_id("lst") + self.db["listings"][lid] = {**s, "id": lid, "currency": "USD", "status": "active", + "test_data": True, "created_at": now()} + self.save() + + def save(self): + with self.lock: + tmp = self.path + ".tmp" + with open(tmp, "w") as f: + json.dump(self.db, f, indent=1) + os.replace(tmp, self.path) + + def get(self, table, key): + with self.lock: + v = self.db[table].get(key) + return copy.deepcopy(v) if v else None + + def put(self, table, obj): + with self.lock: + self.db[table][obj["id"]] = obj + self.save() + return copy.deepcopy(obj) + + def all(self, table): + with self.lock: + return copy.deepcopy(list(self.db[table].values())) + + def log(self, kind, data): + with self.lock: + self.db["events"].append({"at": now(), "kind": kind, **data}) + self.db["events"] = self.db["events"][-500:] + self.save() diff --git a/server.py b/server.py new file mode 100755 index 0000000..10231c5 --- /dev/null +++ b/server.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python3 +"""Agent Market demo server. Tiny on purpose: static files, an LLM proxy (key stays server-side), +test-mode payment endpoints, and a small JSON action API shared by the chat agent and outside agents.""" +import json, os, re, secrets, sys, urllib.parse +from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from market.store import Store, now, new_id +from market import agent as A +from market import payments + +HOST = os.environ.get("AGENT_MARKET_HOST", "127.0.0.1") +PORT = int(os.environ.get("AGENT_MARKET_PORT", "8796")) +DATA_DIR = os.environ.get("AGENT_MARKET_DATA", os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")) +ENV_LABEL = os.environ.get("AGENT_MARKET_ENV", "demo") +STATIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static") +WEBHOOK_SECRET = os.environ.get("AGENT_MARKET_WEBHOOK_SECRET") or secrets.token_hex(16) # per-process if unset +MAX_BODY = 64 * 1024 + +store = Store(DATA_DIR) + +ACTIONS = { + "search": "Search active listings. args: {q?, kind?: product|service|any, limit?}", + "get_listing": "Get one listing. args: {id}", + "create_listing": "Sell something. args: {title, kind: product|service, price (cents), description, category?, area?, payment_options?: [online,cash], emoji?}", + "quote": "Price breakdown online vs cash. args: {listing_id, quantity?}", + "start_checkout": "Start an online (TEST MODE) purchase; returns a payment intent with the fee. args: {listing_id, quantity?}", + "confirm_payment": "Pay a payment intent with a TEST card. args: {intent_id, card_number: '4242424242424242'}", + "start_cash_deal": "Arrange a cash-in-person deal (no fee). args: {listing_id, place?, time?}", + "complete_cash_deal": "Mark a cash deal as paid in person (fee stays $0). args: {deal_id}", + "list_orders": "Orders where you are buyer or seller. args: {}", +} + + +def quote(listing_id, qty=1): + L = store.get("listings", listing_id) + if not L: + return None + sub = L["price"] * max(1, int(qty or 1)) + fee = A.fee_for(sub) + return {"listing_id": L["id"], "title": L["title"], "currency": "USD", "quantity": max(1, int(qty or 1)), + "online": {"available": "online" in L.get("payment_options", []), "subtotal_cents": sub, "fee_cents": fee, + "fee_bps": A.FEE_BPS, "total_cents": sub + fee, "seller_receives_cents": sub, "mode": "test"}, + "cash_in_person": {"available": "cash" in L.get("payment_options", []), "subtotal_cents": sub, + "fee_cents": 0, "total_cents": sub, "seller_receives_cents": sub}} + + +def confirm_payment(intent_id, card_number): + intent = store.get("intents", intent_id) + if not intent: + return 404, {"error": "not_found"} + if intent["status"] == "succeeded": + return 200, {"status": "succeeded", "intent": intent, "order": store.get("orders", intent.get("order_id"))} + res = payments.charge(intent, card_number, f"http://127.0.0.1:{PORT}/api/payments/webhook", WEBHOOK_SECRET) + intent = store.get("intents", intent_id) + return 200, {"status": res["status"], "error": res.get("error"), "message": res.get("message"), + "intent": intent, "order": store.get("orders", intent.get("order_id")) if intent.get("order_id") else None, + "test_mode": True} + + +def run_action(action, args, actor): + args = dict(args or {}) + if action == "search": + return 200, {"listings": A.run_tools(store, [{"name": "search", "args": args}])[0]["listings"]} + if action == "get_listing": + L = store.get("listings", args.get("id")) + return (200, {"listing": L}) if L else (404, {"error": "not_found"}) + if action == "create_listing": + args.update(seller=actor, seller_type="agent" if actor.startswith("agent:") else "human") + r = A.run_tools(store, [{"name": "create_listing", "args": args}])[0] + return (201, {"listing": r["listing"]}) if r["ok"] else (400, {"error": r["error"]}) + if action == "quote": + q = quote(args.get("listing_id"), args.get("quantity")) + return (200, q) if q else (404, {"error": "not_found"}) + if action == "start_checkout": + args["buyer"] = actor + r = A.run_tools(store, [{"name": "start_checkout", "args": args}])[0] + return (201, {"intent": r["intent"], "next": {"action": "confirm_payment", "args": {"intent_id": r["intent"]["id"], "card_number": "4242424242424242"}}}) if r["ok"] else (400, {"error": r["error"]}) + if action == "confirm_payment": + return confirm_payment(args.get("intent_id"), args.get("card_number")) + if action == "start_cash_deal": + args["buyer"] = actor + r = A.run_tools(store, [{"name": "start_cash_deal", "args": args}])[0] + return (201, {"deal": r["deal"]}) if r["ok"] else (400, {"error": r["error"]}) + if action == "complete_cash_deal": + r = A.run_tools(store, [{"name": "complete_cash_deal", "args": args}])[0] + return (200, {"deal": r["deal"], "order": r["order"]}) if r["ok"] else (404, {"error": r["error"]}) + if action == "list_orders": + return 200, {"orders": [o for o in store.all("orders") if actor in (o.get("buyer"), o.get("seller"))]} + return 400, {"error": "unknown_action", "actions": list(ACTIONS)} + + +def hydrate(actions, actor): + out = [] + for a in actions or []: + if not isinstance(a, dict) or a.get("type") not in A.ALLOWED_ACTIONS: + continue + p = dict(a.get("payload") or {}) + t = a["type"] + if t in ("show_cards", "show_compare"): + ids = [i for i in (p.get("listing_ids") or []) if isinstance(i, str)][:12] + p["listings"] = [x for x in (store.get("listings", i) for i in ids) if x] + if t == "show_compare": + p["quotes"] = [quote(x["id"]) for x in p["listings"]] + if not p["listings"]: + continue + elif t == "show_checkout": + intent = store.get("intents", p.get("intent_id") or "") + if not intent: + r = A.run_tools(store, [{"name": "start_checkout", "args": {"listing_id": p.get("listing_id"), "buyer": actor, "quantity": p.get("quantity") or 1}}])[0] + if not r["ok"]: + out.append({"type": "toast", "payload": {"level": "warn", "text": f"Checkout unavailable: {r['error']}"}}) + continue + intent = r["intent"] + p.update(intent=intent, listing=store.get("listings", intent["listing_id"]), intent_id=intent["id"]) + elif t == "show_cash_meetup": + deal = store.get("cash_deals", p.get("deal_id") or "") + if not deal: + r = A.run_tools(store, [{"name": "start_cash_deal", "args": {"listing_id": p.get("listing_id"), "buyer": actor, "place": p.get("suggested_place"), "time": p.get("suggested_time")}}])[0] + if not r["ok"]: + out.append({"type": "toast", "payload": {"level": "warn", "text": f"Cash deal unavailable: {r['error']}"}}) + continue + deal = r["deal"] + p.update(deal=deal, listing=store.get("listings", deal["listing_id"]), deal_id=deal["id"]) + elif t == "show_receipt": + o = store.get("orders", p.get("order_id") or "") + if not o: + continue + p["order"] = o + out.append({"type": t, "payload": p}) + return out + + +def agent_manifest(base): + return { + "schema": "agent-market/agent.json v1", + "name": "Agent Market (demo)", + "description": "Demo marketplace where people and AI agents list, buy and sell products and services. TEST MODE ONLY: no real money moves.", + "test_mode": True, + "base_url": base, + "fees": {"online_payment_fee_bps": A.FEE_BPS, "online_payment_fee_percent": A.FEE_BPS / 100, + "cash_in_person_fee_bps": 0, "who_pays": "buyer pays the facilitation fee on top of the price; seller receives the full price"}, + "payments": {"provider": "mock (test mode)", "test_cards": {"success": "4242424242424242", "declined": "4000000000000002", "insufficient_funds": "4000000000009995"}}, + "identity": "Send header X-Agent-Id: agent:. Demo only: identity is not verified.", + "endpoint": {"method": "POST", "url": base + "/api/actions", "body": {"action": "", "args": {}}}, + "actions": ACTIONS, + "rest_aliases": ["GET /api/listings?q=", "GET /api/listings/{id}", "POST /api/listings", "GET /api/quote/{id}", + "POST /api/checkout", "POST /api/payments/{intent_id}/confirm", "POST /api/cash-deals", + "POST /api/cash-deals/{id}/complete", "GET /api/orders"], + "openapi": base + "/openapi.json", + "example_flow": [ + {"action": "search", "args": {"q": "bike"}}, + {"action": "start_checkout", "args": {"listing_id": "lst_..."}}, + {"action": "confirm_payment", "args": {"intent_id": "pi_...", "card_number": "4242424242424242"}}, + ], + } + + +def openapi(base): + act_enum = list(ACTIONS) + return { + "openapi": "3.0.3", + "info": {"title": "Agent Market demo API", "version": "0.1.0", + "description": "TEST MODE ONLY. Same actions the chat agent uses. Online fee %.2f%%, cash in person 0%%." % (A.FEE_BPS / 100)}, + "servers": [{"url": base}], + "paths": { + "/api/actions": {"post": {"summary": "Run a marketplace action", "parameters": [{"name": "X-Agent-Id", "in": "header", "schema": {"type": "string"}, "description": "agent:"}], + "requestBody": {"required": True, "content": {"application/json": {"schema": {"type": "object", "required": ["action"], "properties": {"action": {"type": "string", "enum": act_enum}, "args": {"type": "object"}}}}}}, + "responses": {"200": {"description": "Result"}, "201": {"description": "Created"}, "400": {"description": "Bad request"}, "404": {"description": "Not found"}}}}, + "/api/listings": {"get": {"summary": "Search listings", "parameters": [{"name": "q", "in": "query", "schema": {"type": "string"}}, {"name": "kind", "in": "query", "schema": {"type": "string", "enum": ["product", "service", "any"]}}], "responses": {"200": {"description": "Listings"}}}, + "post": {"summary": "Create a listing", "responses": {"201": {"description": "Created"}}}}, + "/api/listings/{id}": {"get": {"summary": "Get a listing", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "string"}}], "responses": {"200": {"description": "Listing"}}}}, + "/api/quote/{id}": {"get": {"summary": "Online vs cash price breakdown", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "string"}}], "responses": {"200": {"description": "Quote"}}}}, + "/api/checkout": {"post": {"summary": "Create a test-mode payment intent (adds fee)", "responses": {"201": {"description": "Intent"}}}}, + "/api/payments/{intent_id}/confirm": {"post": {"summary": "Pay with a TEST card", "parameters": [{"name": "intent_id", "in": "path", "required": True, "schema": {"type": "string"}}], "responses": {"200": {"description": "Payment result"}}}}, + "/api/cash-deals": {"post": {"summary": "Arrange cash in person (no fee)", "responses": {"201": {"description": "Deal"}}}}, + "/api/cash-deals/{id}/complete": {"post": {"summary": "Mark cash deal completed", "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "string"}}], "responses": {"200": {"description": "Order"}}}}, + "/api/orders": {"get": {"summary": "Your orders", "responses": {"200": {"description": "Orders"}}}}, + "/api/chat": {"post": {"summary": "Talk to the UI agent (returns reply + UI actions)", "responses": {"200": {"description": "Agent reply"}}}}, + }, + } + + +class H(BaseHTTPRequestHandler): + server_version = "AgentMarket/0.1" + + def log_message(self, fmt, *args): + sys.stderr.write("[%s] %s\n" % (now(), fmt % args)) + + def _base(self): + proto = self.headers.get("X-Forwarded-Proto") or "http" + host = self.headers.get("X-Forwarded-Host") or self.headers.get("Host") or f"127.0.0.1:{PORT}" + return f"{proto}://{host}" if "trycloudflare" not in host else f"https://{host}" + + def _actor(self, body=None): + a = self.headers.get("X-Agent-Id") or (body or {}).get("actor") or "guest" + return re.sub(r"[^A-Za-z0-9:_\-. ()]", "", str(a))[:60] or "guest" + + def _json(self, code, obj): + b = json.dumps(obj).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(b))) + self.send_header("Cache-Control", "no-store") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(b) + + def _body(self): + n = int(self.headers.get("Content-Length") or 0) + if n > MAX_BODY: + return None, b"" + raw = self.rfile.read(n) if n else b"" + try: + return (json.loads(raw) if raw else {}), raw + except Exception: + return None, raw + + def do_OPTIONS(self): + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Agent-Id") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.end_headers() + + def _static(self, rel): + path = os.path.normpath(os.path.join(STATIC, rel)) + if not path.startswith(STATIC) or not os.path.isfile(path): + return self._json(404, {"error": "not_found"}) + ctype = {".html": "text/html; charset=utf-8", ".js": "application/javascript", ".css": "text/css", + ".svg": "image/svg+xml", ".png": "image/png"}.get(os.path.splitext(path)[1], "application/octet-stream") + with open(path, "rb") as f: + b = f.read() + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(b))) + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.wfile.write(b) + + def do_GET(self): + u = urllib.parse.urlparse(self.path) + path, qs = u.path, dict(urllib.parse.parse_qsl(u.query)) + if path in ("/", "/index.html"): + return self._static("index.html") + if path.startswith("/static/"): + return self._static(path[len("/static/"):]) + if path in ("/agent.json", "/.well-known/agent.json"): + return self._json(200, agent_manifest(self._base())) + if path == "/openapi.json": + return self._json(200, openapi(self._base())) + if path == "/healthz": + return self._json(200, {"ok": True}) + if path == "/api/status": + return self._json(200, {"env": ENV_LABEL, "agent": A.agent_status(), "fee_bps": A.FEE_BPS, + "payments": {"mode": "test", "provider": "mock", "test_card": "4242 4242 4242 4242"}, + "counts": {k: len(store.all(k)) for k in ("listings", "intents", "orders", "cash_deals")}}) + if path == "/api/listings": + return self._json(*run_action("search", {"q": qs.get("q", ""), "kind": qs.get("kind", "any"), "limit": int(qs.get("limit", 50))}, self._actor())) + m = re.fullmatch(r"/api/listings/([a-z0-9_]+)", path) + if m: + return self._json(*run_action("get_listing", {"id": m.group(1)}, self._actor())) + m = re.fullmatch(r"/api/quote/([a-z0-9_]+)", path) + if m: + return self._json(*run_action("quote", {"listing_id": m.group(1), "quantity": qs.get("quantity", 1)}, self._actor())) + if path == "/api/orders": + return self._json(*run_action("list_orders", {}, self._actor())) + return self._json(404, {"error": "not_found"}) + + def do_POST(self): + path = urllib.parse.urlparse(self.path).path + body, raw = self._body() + if body is None: + return self._json(400, {"error": "bad_json_or_too_large"}) + if path == "/api/payments/webhook": + sig = self.headers.get("X-Mock-Signature") or "" + if not payments.hmac.compare_digest(sig, payments.sign(WEBHOOK_SECRET, raw)): + return self._json(401, {"error": "bad_signature"}) + return self._json(200, self._webhook(body)) + actor = self._actor(body) + if path == "/api/chat": + msg = str(body.get("message") or "")[:2000] + ev = body.get("event") if isinstance(body.get("event"), dict) else None + hist = body.get("history") if isinstance(body.get("history"), list) else [] + out = A.handle_chat(store, msg, hist, ev, actor) + out["actions"] = hydrate(out.get("actions"), actor) + out.pop("tools", None) + return self._json(200, out) + if path == "/api/actions": + return self._json(*run_action(str(body.get("action")), body.get("args") or {}, actor)) + if path == "/api/listings": + return self._json(*run_action("create_listing", body, actor)) + if path == "/api/checkout": + return self._json(*run_action("start_checkout", body, actor)) + m = re.fullmatch(r"/api/payments/([a-z0-9_]+)/confirm", path) + if m: + return self._json(*confirm_payment(m.group(1), body.get("card_number"))) + if path == "/api/cash-deals": + return self._json(*run_action("start_cash_deal", body, actor)) + m = re.fullmatch(r"/api/cash-deals/([a-z0-9_]+)/complete", path) + if m: + return self._json(*run_action("complete_cash_deal", {"deal_id": m.group(1)}, actor)) + return self._json(404, {"error": "not_found"}) + + def _webhook(self, ev): + d = ev.get("data") or {} + intent = store.get("intents", d.get("intent_id") or "") + if not intent: + return {"received": True, "ignored": "unknown_intent"} + if ev.get("type") == "payment_intent.succeeded" and intent["status"] != "succeeded": + L = store.get("listings", intent["listing_id"]) + order = {"id": new_id("ord"), "listing_id": intent["listing_id"], "title": intent["title"], + "buyer": intent["buyer"], "seller": (L or {}).get("seller"), "method": "online_test_card", + "subtotal_cents": intent["subtotal_cents"], "fee_cents": intent["fee_cents"], "fee_bps": intent["fee_bps"], + "total_cents": intent["total_cents"], "seller_receives_cents": intent["subtotal_cents"], + "card_last4": d.get("card_last4"), "currency": "USD", "status": "paid", "mode": "test", + "provider_event": ev.get("id"), "created_at": now()} + store.put("orders", order) + intent.update(status="succeeded", order_id=order["id"], paid_at=now()) + store.put("intents", intent) + if L and L.get("kind") == "product": + L["status"] = "sold" + store.put("listings", L) + store.log("payment_succeeded", {"intent": intent["id"], "order": order["id"]}) + elif ev.get("type") == "payment_intent.payment_failed": + intent.update(status="requires_payment", last_error=d.get("failure_code")) + store.put("intents", intent) + store.log("payment_failed", {"intent": intent["id"], "code": d.get("failure_code")}) + return {"received": True} + + +if __name__ == "__main__": + srv = ThreadingHTTPServer((HOST, PORT), H) + st = A.agent_status() + print(f"Agent Market [{ENV_LABEL}] on http://{HOST}:{PORT} agent={st['mode']} fee={A.FEE_BPS/100:.2f}% payments=TEST(mock)", flush=True) + srv.serve_forever() diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..f8fa44a --- /dev/null +++ b/static/app.js @@ -0,0 +1,213 @@ +// Agent Market frontend: renders UI actions chosen by the agent. Card data goes only to the test payment endpoint. +(() => { + const $ = (s) => document.querySelector(s); + const stage = $("#stage"), log = $("#log"); + const actor = localStorage.getItem("am_actor") || ("guest-" + Math.random().toString(36).slice(2, 7)); + localStorage.setItem("am_actor", actor); + const history = []; + let feeBps = 500; + const compareSet = new Set(); + + const money = (c) => "$" + (c / 100).toFixed(2); + function el(tag, attrs = {}, ...kids) { + const n = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === "class") n.className = v; + else if (k.startsWith("on")) n.addEventListener(k.slice(2), v); + else if (v !== undefined && v !== null) n.setAttribute(k, v); + } + for (const k of kids.flat()) if (k !== null && k !== undefined) n.append(k.nodeType ? k : document.createTextNode(String(k))); + return n; + } + function toast(text, level = "info") { + const t = el("div", { class: "toast " + level }, text); + $("#toasts").append(t); setTimeout(() => t.remove(), 3500); + } + function say(role, text) { + log.append(el("div", { class: "msg " + role }, text)); log.scrollTop = log.scrollHeight; + } + function openChat() { $("#chat").classList.remove("hidden"); $("#chatLauncher").style.display = "none"; } + + async function api(path, body) { + const r = await fetch(path, body ? { method: "POST", headers: { "Content-Type": "application/json", "X-Agent-Id": actor }, body: JSON.stringify(body) } : { headers: { "X-Agent-Id": actor } }); + return r.json(); + } + + async function talk({ message, event, echo }) { + openChat(); + if (echo) say("user", echo); + const thinking = el("div", { class: "msg sys" }, "agent is thinking…"); log.append(thinking); + try { + const out = await api("/api/chat", { message, event, history: history.slice(-8), actor }); + thinking.remove(); + if (message) history.push({ role: "user", content: message }); + if (event) history.push({ role: "user", content: "UI_EVENT " + JSON.stringify(event) }); + history.push({ role: "assistant", content: out.reply || "" }); + say("bot", out.reply || "(no reply)"); + $("#chatMode").textContent = out.agent === "fallback" ? "rule-based fallback agent (no LLM key)" : `LLM agent · ${out.model || out.agent}`; + render(out.actions || []); + } catch (e) { thinking.remove(); say("sys", "Agent error: " + e.message); } + } + + // ---------- component renderers ---------- + const R = { + show_hero(p) { + return el("section", { class: "panel hero" }, el("h2", {}, p.title || "Agent Market"), + el("p", {}, p.subtitle || ""), el("ul", {}, (p.bullets || []).map((b) => el("li", {}, b)))); + }, + show_cards(p) { + return el("section", { class: "panel", "data-component": "cards" }, el("h2", {}, p.heading || "Listings"), + el("div", { class: "by" }, "Rendered by the agent"), + el("div", { class: "grid" }, (p.listings || []).map(card))); + }, + show_compare(p) { + const L = p.listings || [], Q = p.quotes || []; + const row = (label, f) => el("tr", {}, el("th", {}, label), L.map((x, i) => el("td", {}, f(x, Q[i] || {})))); + return el("section", { class: "panel", "data-component": "compare" }, el("h2", {}, p.heading || "Compare"), + el("table", { class: "cmp" }, + el("tr", {}, el("th", {}, ""), L.map((x) => el("th", {}, x.emoji + " " + x.title))), + row("Type", (x) => x.kind), row("Price", (x) => money(x.price)), + row("Pay online (test)", (x, q) => q.online && q.online.available ? `${money(q.online.total_cents)} incl. ${money(q.online.fee_cents)} fee` : "not offered"), + row("Cash in person", (x, q) => q.cash_in_person && q.cash_in_person.available ? `${money(q.cash_in_person.total_cents)} · $0 fee` : "not offered"), + row("Seller", (x) => x.seller + (x.seller_type === "agent" ? " 🤖" : "")), + row("Area", (x) => x.area), row("", (x) => buyButtons(x)))); + }, + show_listing_form(p) { + const d = p.defaults || {}; + const f = el("form", { class: "form", "data-component": "listing-form" }, + el("label", { class: "full" }, "Title", el("input", { name: "title", required: "", value: d.title || "", placeholder: "e.g. Vintage desk chair" })), + el("label", {}, "Type", (() => { const s = el("select", { name: "kind" }, el("option", { value: "product" }, "Product"), el("option", { value: "service" }, "Service")); s.value = d.kind || "product"; return s; })()), + el("label", {}, "Price (USD)", el("input", { name: "price", type: "number", step: "0.01", min: "0", required: "", value: d.price ? (d.price / 100).toFixed(2) : "" })), + el("label", {}, "Category", el("input", { name: "category", value: d.category || "" })), + el("label", {}, "Area (approximate)", el("input", { name: "area", value: d.area || "Demo Town" })), + el("label", { class: "full" }, "Description", el("textarea", { name: "description", rows: "3", required: "" }, d.description || "")), + el("label", {}, el("span", {}, el("input", { type: "checkbox", name: "online", checked: "" }), ` Accept online payment (${feeBps / 100}% fee, paid by buyer)`)), + el("label", {}, el("span", {}, el("input", { type: "checkbox", name: "cash", checked: "" }), " Accept cash in person (free)")), + el("div", { class: "full" }, el("button", { class: "btn", type: "submit" }, "Publish test listing"))); + f.addEventListener("submit", (e) => { + e.preventDefault(); + const fd = new FormData(f); + const fields = { title: fd.get("title"), kind: fd.get("kind"), price: fd.get("price"), category: fd.get("category"), + area: fd.get("area"), description: fd.get("description"), + payment_options: [fd.get("online") && "online", fd.get("cash") && "cash"].filter(Boolean) }; + talk({ event: { type: "submit_listing", fields }, echo: `Publish “${fields.title}”` }); + }); + return el("section", { class: "panel" }, el("h2", {}, "List something"), el("div", { class: "by" }, "Form opened by the agent"), f); + }, + show_checkout(p) { + const it = p.intent, L = p.listing || {}; + const status = el("div", { class: "meta" }); + const card = el("input", { name: "card", value: "4242 4242 4242 4242", inputmode: "numeric", "aria-label": "Test card number" }); + const exp = el("input", { value: "12/34", "aria-label": "Expiry" }), cvc = el("input", { value: "123", "aria-label": "CVC" }); + const payBtn = el("button", { class: "btn", type: "submit" }, `Pay ${money(it.total_cents)} (test)`); + const form = el("form", {}, el("div", { class: "testcard" }, "TEST MODE: no real charge. Success: 4242 4242 4242 4242 · Decline: 4000 0000 0000 0002. Other numbers are refused."), + el("div", { class: "cardform" }, card, exp, cvc), el("div", { style: "margin-top:10px" }, payBtn), status); + form.addEventListener("submit", async (e) => { + e.preventDefault(); payBtn.disabled = true; status.textContent = "Contacting test payment provider…"; + const r = await api(`/api/payments/${it.id}/confirm`, { card_number: card.value }); + status.textContent = r.status === "succeeded" ? "Paid (test)." : "Declined: " + (r.error || "unknown"); + status.className = "meta " + (r.status === "succeeded" ? "ok" : "warn"); + payBtn.disabled = r.status === "succeeded"; + talk({ event: { type: "payment_result", intent_id: it.id, status: r.status, order_id: r.order && r.order.id, error: r.error } }); + }); + return el("section", { class: "panel", "data-component": "checkout" }, el("h2", {}, "Checkout"), + el("div", { class: "by" }, `${L.emoji || ""} ${it.title} · sold by ${L.seller || "?"}`), + el("table", { class: "breakdown" }, + el("tr", {}, el("td", {}, `Price${it.quantity > 1 ? " × " + it.quantity : ""}`), el("td", {}, money(it.subtotal_cents))), + el("tr", {}, el("td", {}, `Facilitation fee (${(it.fee_bps / 100).toFixed(1)}%, online payments only)`), el("td", { "data-testid": "fee" }, money(it.fee_cents))), + el("tr", { class: "total" }, el("td", {}, "You pay"), el("td", { "data-testid": "total" }, money(it.total_cents))), + el("tr", {}, el("td", { class: "meta" }, "Seller receives"), el("td", { class: "meta" }, money(it.subtotal_cents)))), + form, + "cash" in Object.fromEntries((L.payment_options || []).map((x) => [x, 1])) ? + el("p", { class: "meta" }, "Prefer to skip the fee? ", el("a", { href: "#", onclick: (e) => { e.preventDefault(); talk({ event: { type: "buy_cash", listing_id: L.id }, echo: "Pay cash in person instead" }); } }, "Meet and pay cash in person ($0 fee)")) : null); + }, + show_cash_meetup(p) { + const d = p.deal, L = p.listing || {}; + const done = el("button", { class: "btn cash" }, "Mark paid in cash"); + done.addEventListener("click", () => { done.disabled = true; talk({ event: { type: "cash_completed", deal_id: d.id }, echo: "We met and I paid cash" }); }); + return el("section", { class: "panel", "data-component": "cash-meetup" }, el("h2", {}, "Cash in person: no fee"), + el("div", { class: "by" }, `${L.emoji || ""} ${d.title} · seller ${d.seller}`), + el("table", { class: "breakdown" }, + el("tr", {}, el("td", {}, "Price (pay the seller directly)"), el("td", {}, money(d.price_cents))), + el("tr", {}, el("td", {}, "Platform fee"), el("td", { "data-testid": "cash-fee" }, money(d.fee_cents))), + el("tr", { class: "total" }, el("td", {}, "Total"), el("td", {}, money(d.price_cents)))), + el("p", {}, `Suggested meetup: ${d.place} · ${d.time}`), + el("p", { class: "meta" }, "We only connect buyer and seller. Meet somewhere public. (Demo: no real meetup or messaging.)"), + el("div", { class: "meta" }, "Deal " + d.id + " · status " + d.status), el("div", { style: "margin-top:10px" }, done)); + }, + show_receipt(p) { + const o = p.order; + return el("section", { class: "panel", "data-component": "receipt" }, el("h2", { class: "ok" }, o.method === "cash_in_person" ? "Cash deal completed" : "Payment confirmed (test)"), + el("table", { class: "breakdown" }, + el("tr", {}, el("td", {}, o.title), el("td", {}, money(o.subtotal_cents))), + el("tr", {}, el("td", {}, "Facilitation fee"), el("td", { "data-testid": "receipt-fee" }, money(o.fee_cents))), + el("tr", { class: "total" }, el("td", {}, "Total"), el("td", {}, money(o.total_cents))), + el("tr", {}, el("td", { class: "meta" }, "Seller receives"), el("td", { class: "meta" }, money(o.seller_receives_cents)))), + el("div", { class: "meta" }, `Order ${o.id} · ${o.method}${o.card_last4 ? " · test card •••• " + o.card_last4 : ""} · ${o.status}`)); + }, + clear_panel() { stage.innerHTML = ""; return null; }, + toast(p) { toast(p.text || "", p.level || "info"); return null; }, + }; + + function buyButtons(x) { + const opts = x.payment_options || []; + if (x.status !== "active") return el("span", { class: "meta" }, "Sold"); + return el("div", { class: "actions" }, + opts.includes("online") ? el("button", { class: "btn", onclick: () => talk({ event: { type: "buy_online", listing_id: x.id }, echo: `Buy “${x.title}” online` }) }, "Buy online") : null, + opts.includes("cash") ? el("button", { class: "btn cash", onclick: () => talk({ event: { type: "buy_cash", listing_id: x.id }, echo: `Meet & pay cash for “${x.title}”` }) }, "Cash meetup") : null); + } + function card(x) { + const cb = el("input", { type: "checkbox", "aria-label": "compare" }); + cb.checked = compareSet.has(x.id); + cb.addEventListener("change", () => { cb.checked ? compareSet.add(x.id) : compareSet.delete(x.id); updateCompareBar(); }); + return el("div", { class: "card" + (x.status !== "active" ? " sold" : ""), "data-listing": x.id }, + el("div", { class: "emoji" }, x.emoji || "📦"), el("h3", {}, x.title), + el("div", { class: "price" }, money(x.price), el("span", { class: "meta" }, x.kind === "service" ? " / session" : "")), + el("div", {}, el("span", { class: "tag" }, x.kind), x.seller_type === "agent" ? el("span", { class: "tag agent-tag" }, "sold by an agent") : null, + el("span", { class: "tag" }, "test listing")), + el("div", { class: "meta" }, x.area + " · " + x.seller), + el("label", { class: "meta" }, cb, " compare"), buyButtons(x)); + } + const cmpbar = el("div", { class: "cmpbar" }); document.body.append(cmpbar); + function updateCompareBar() { + cmpbar.innerHTML = ""; cmpbar.style.display = compareSet.size ? "flex" : "none"; + cmpbar.append(`${compareSet.size} selected`, el("button", { class: "btn", onclick: () => talk({ event: { type: "compare", listing_ids: [...compareSet] }, echo: "Compare selected" }) }, "Compare")); + } + + function render(actions) { + let first = true; + for (const a of actions) { + const fn = R[a.type]; if (!fn) continue; + const node = fn(a.payload || {}); + if (!node) continue; + if (first) { stage.innerHTML = ""; first = false; } + stage.append(node); + } + if (!first) stage.scrollIntoView({ behavior: "smooth", block: "start" }); + } + + // ---------- wiring ---------- + document.addEventListener("click", (e) => { + const b = e.target.closest("[data-say],[data-event]"); if (!b) return; + if (b.dataset.say) talk({ message: b.dataset.say, echo: b.dataset.say }); + else talk({ event: { type: b.dataset.event }, echo: b.textContent.trim() }); + }); + $("#chatLauncher").addEventListener("click", openChat); + $("#chatClose").addEventListener("click", () => { $("#chat").classList.add("hidden"); $("#chatLauncher").style.display = ""; }); + $("#chatForm").addEventListener("submit", (e) => { + e.preventDefault(); const v = $("#chatInput").value.trim(); if (!v) return; + $("#chatInput").value = ""; talk({ message: v, echo: v }); + }); + + api("/api/status").then((s) => { + feeBps = s.fee_bps; $("#feePct").textContent = (feeBps / 100).toFixed(feeBps % 100 ? 1 : 0) + "%"; + $("#agentBadge").textContent = s.agent.mode === "llm" ? `LLM: ${s.agent.model}` : "agent: rule-based fallback"; + $("#agentBadge").title = s.agent.note || ""; + }); + // Landing: show featured listings via the agent. + (async () => { + const out = await api("/api/chat", { message: "show me everything", actor }); + render(out.actions || []); + say("bot", "Hi! I run this page. Ask me to find, compare, sell, or buy. Payments are test mode only."); + })(); +})(); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..223946a --- /dev/null +++ b/static/index.html @@ -0,0 +1,58 @@ + + + + + +Agent Market (demo) + + + +
TEST DEMO: all listings are fake and payments run in test mode. No real money moves.
+
+
Agent Market
+ +
+ +
+
+
+

A marketplace you can just talk to.

+

Tell the agent what you want. It searches, compares, lists your stuff and runs the checkout, right here on the page.

+
+ + + + +
+
    +
  • 5% facilitation fee when you pay online (test mode)
  • +
  • $0 fee when you meet and pay cash in person
  • +
  • Agents welcome: other AI agents buy and sell through the open API
  • +
+
+
+ +
+
+ + + +
+ + + diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..67e1b27 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,46 @@ +:root{--bg:#0f1115;--panel:#171a21;--card:#1d212a;--ink:#e9ecf1;--mute:#9aa3b2;--acc:#7c9cff;--ok:#38c172;--warn:#f6ad55;--line:#2a2f3a} +*{box-sizing:border-box}body{margin:0;font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--ink)} +a{color:var(--acc)}button{font:inherit;cursor:pointer} +.testbar{background:#3b2f0b;color:#ffd970;text-align:center;font-size:13px;padding:6px} +.top{display:flex;justify-content:space-between;align-items:center;padding:14px 28px;border-bottom:1px solid var(--line)} +.brand{font-weight:700;font-size:18px}.logo{color:var(--acc)} +nav{display:flex;gap:14px;align-items:center}.link{background:none;border:0;color:var(--ink);text-decoration:none;padding:4px} +.badge{font-size:12px;border:1px solid var(--line);border-radius:999px;padding:3px 10px;color:var(--mute)} +main{max-width:1100px;margin:0 auto;padding:28px} +.landing{padding:28px 0 8px}.landing h1{font-size:40px;line-height:1.1;margin:0 0 10px}.landing p{color:var(--mute);max-width:640px} +.chips{display:flex;flex-wrap:wrap;gap:10px;margin:18px 0}.chip{background:var(--card);border:1px solid var(--line);color:var(--ink);padding:8px 14px;border-radius:999px} +.chip:hover{border-color:var(--acc)}.facts{color:var(--mute);padding-left:18px} +#stage{margin-top:22px;display:flex;flex-direction:column;gap:18px} +.panel{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px;animation:in .25s ease} +@keyframes in{from{opacity:0;transform:translateY(6px)}to{opacity:1}} +.panel h2{margin:0 0 12px;font-size:20px}.panel .by{font-size:12px;color:var(--mute);margin-bottom:10px} +.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:14px} +.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;display:flex;flex-direction:column;gap:6px} +.card .emoji{font-size:30px}.card h3{margin:0;font-size:16px}.price{font-weight:700;font-size:18px} +.meta{font-size:12px;color:var(--mute)}.tag{display:inline-block;font-size:11px;border:1px solid var(--line);border-radius:6px;padding:1px 6px;margin-right:4px} +.agent-tag{border-color:#5a4bd1;color:#b7adff}.sold{opacity:.55} +.actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:auto} +.btn{background:var(--acc);color:#0b0e16;border:0;border-radius:8px;padding:7px 11px;font-weight:600} +.btn.ghost{background:transparent;color:var(--ink);border:1px solid var(--line)}.btn.cash{background:var(--ok)} +.hero{background:linear-gradient(135deg,#1c2340,#171a21)}.hero ul{color:var(--mute)} +table.cmp{width:100%;border-collapse:collapse}table.cmp td,table.cmp th{border-bottom:1px solid var(--line);padding:8px;text-align:left;vertical-align:top} +.form{display:grid;grid-template-columns:1fr 1fr;gap:12px}.form label{display:flex;flex-direction:column;gap:4px;font-size:13px;color:var(--mute)} +.form .full{grid-column:1/-1}input,textarea,select{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:9px;font:inherit} +.breakdown{width:100%;max-width:440px;border-collapse:collapse;margin:8px 0 14px}.breakdown td{padding:5px 0}.breakdown td:last-child{text-align:right} +.breakdown tr.total td{border-top:1px solid var(--line);font-weight:700;padding-top:8px} +.testcard{background:#2a230c;border:1px dashed #a88a2a;color:#ffd970;border-radius:8px;padding:8px 10px;font-size:13px;margin-bottom:10px} +.cardform{display:grid;grid-template-columns:2fr 1fr 1fr;gap:8px;max-width:440px} +.ok{color:var(--ok)}.warn{color:var(--warn)} +.launcher{position:fixed;right:22px;bottom:22px;background:var(--acc);color:#0b0e16;border:0;border-radius:999px;padding:12px 18px;font-weight:700;box-shadow:0 8px 30px #0008} +.chat{position:fixed;right:22px;bottom:22px;width:370px;max-width:calc(100vw - 30px);height:520px;max-height:calc(100vh - 60px);background:var(--panel);border:1px solid var(--line);border-radius:16px;display:flex;flex-direction:column;box-shadow:0 12px 40px #000a} +.chat.hidden{display:none}.chat-head{display:flex;justify-content:space-between;align-items:center;padding:12px 14px;border-bottom:1px solid var(--line)} +.chat-head button{background:none;border:0;color:var(--mute);font-size:16px}.sub{font-size:11px;color:var(--mute)} +.log{flex:1;overflow:auto;padding:12px;display:flex;flex-direction:column;gap:8px} +.msg{padding:8px 11px;border-radius:12px;max-width:85%;white-space:pre-wrap}.msg.user{align-self:flex-end;background:var(--acc);color:#0b0e16} +.msg.bot{align-self:flex-start;background:var(--card)}.msg.sys{align-self:center;font-size:12px;color:var(--mute)} +.chat-form{display:flex;gap:8px;padding:10px;border-top:1px solid var(--line)}.chat-form input{flex:1} +.chat-form button{background:var(--acc);border:0;border-radius:8px;padding:0 14px;font-weight:600} +#toasts{position:fixed;left:50%;top:50px;transform:translateX(-50%);display:flex;flex-direction:column;gap:6px;z-index:9} +.toast{background:var(--card);border:1px solid var(--line);padding:8px 14px;border-radius:8px}.toast.ok{border-color:var(--ok)}.toast.warn{border-color:var(--warn)} +.cmpbar{position:fixed;left:22px;bottom:22px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:8px 12px;display:none;gap:8px;align-items:center} +@media(max-width:640px){.landing h1{font-size:28px}.form{grid-template-columns:1fr}.top{padding:12px}main{padding:16px}} diff --git a/tests/browser_e2e.js b/tests/browser_e2e.js new file mode 100644 index 0000000..4a08554 --- /dev/null +++ b/tests/browser_e2e.js @@ -0,0 +1,78 @@ +// Headless browser walkthrough. Usage: NODE_PATH= node tests/browser_e2e.js http://127.0.0.1:8795 [screenshot_dir] +const puppeteer = require("puppeteer-core"); +const BASE = process.argv[2] || "http://127.0.0.1:8795"; +const SHOTS = process.argv[3] || ""; +const results = []; +const check = (name, ok, d = "") => { results.push(ok); console.log((ok ? "PASS " : "FAIL ") + name + (d ? ` [${d}]` : "")); }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +(async () => { + const browser = await puppeteer.launch({ executablePath: process.env.CHROME || "/usr/bin/google-chrome", headless: "new", + args: ["--no-sandbox", "--disable-gpu", "--user-data-dir=/tmp/am-pptr-profile"] }); + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 860 }); + const errors = []; page.on("pageerror", (e) => errors.push(e.message)); + await page.goto(BASE + "/", { waitUntil: "networkidle0" }); + await page.waitForSelector(".card", { timeout: 15000 }); + check("landing renders agent-chosen cards", (await page.$$(".card")).length >= 4); + const badge = await page.$eval("#agentBadge", (n) => n.textContent); + check("agent mode badge shown", /fallback|LLM/.test(badge), badge); + + // chat search + await page.click("#chatLauncher"); + await page.type("#chatInput", "show me bikes"); + await page.keyboard.press("Enter"); + await page.waitForFunction(() => [...document.querySelectorAll("#stage h2")].some((h) => /bike/i.test(h.textContent)), { timeout: 15000 }); + const titles = await page.$$eval("#stage .card h3", (ns) => ns.map((n) => n.textContent)); + check("chat search renders bike cards", titles.length && titles.every((t) => /bike/i.test(t)), titles.join(" | ")); + if (SHOTS) { await sleep(600); } if (SHOTS) await page.screenshot({ path: SHOTS + "/1-landing-chat-search.png" }); + + // online checkout + const roadBtn = await page.evaluateHandle(() => [...document.querySelectorAll(".card")].find((c) => /road bike/i.test(c.textContent)).querySelector("button.btn:not(.cash)")); + await roadBtn.click(); + await page.waitForSelector("[data-component=checkout]", { timeout: 15000 }); + const fee = await page.$eval("[data-testid=fee]", (n) => n.textContent); + const total = await page.$eval("[data-testid=total]", (n) => n.textContent); + check("checkout shows 5% fee and total", fee === "$9.00" && total === "$189.00", `fee ${fee} total ${total}`); + if (SHOTS) { await sleep(600); } if (SHOTS) await page.screenshot({ path: SHOTS + "/2-checkout-fee.png" }); + await page.click("[data-component=checkout] button[type=submit]"); + await page.waitForSelector("[data-component=receipt]", { timeout: 15000 }); + const rfee = await page.$eval("[data-testid=receipt-fee]", (n) => n.textContent); + check("test payment confirms, receipt shows fee", rfee === "$9.00", rfee); + + // cash meetup + await page.type("#chatInput", "show lamp"); + await page.keyboard.press("Enter"); + await page.waitForFunction(() => [...document.querySelectorAll("#stage .card h3")].some((h) => /lamp/i.test(h.textContent)), { timeout: 15000 }); + const cashBtn = await page.evaluateHandle(() => [...document.querySelectorAll(".card")].find((c) => /ceramic/i.test(c.textContent)).querySelector("button.cash")); + await cashBtn.click(); + await page.waitForSelector("[data-component=cash-meetup]", { timeout: 15000 }); + const cfee = await page.$eval("[data-testid=cash-fee]", (n) => n.textContent); + check("cash meetup shows $0 fee", cfee === "$0.00", cfee); + if (SHOTS) { await sleep(600); } if (SHOTS) await page.screenshot({ path: SHOTS + "/3-cash-meetup.png" }); + await page.click("[data-component=cash-meetup] button.cash"); + await page.waitForSelector("[data-component=receipt]", { timeout: 15000 }); + check("cash deal completed with $0 fee", (await page.$eval("[data-testid=receipt-fee]", (n) => n.textContent)) === "$0.00"); + + // sell + await page.click("nav [data-event=sell]"); + await page.waitForSelector("[data-component=listing-form]", { timeout: 15000 }); + await page.type("[name=title]", "Test-only bookshelf"); + await page.type("[name=price]", "30"); + await page.type("[name=description]", "Fake listing created by the browser test"); + await page.click("[data-component=listing-form] button[type=submit]"); + await page.waitForFunction(() => [...document.querySelectorAll("#stage .card h3")].some((h) => h.textContent === "Test-only bookshelf"), { timeout: 15000 }); + check("listing form publishes a new card", true); + + // compare + await page.type("#chatInput", "compare tutoring"); + await page.keyboard.press("Enter"); + await page.waitForSelector("[data-component=compare]", { timeout: 15000 }); + check("compare view renders", (await page.$$("[data-component=compare] th")).length >= 3); + + check("no page JS errors", errors.length === 0, errors.join("; ")); + await browser.close(); + const passed = results.filter(Boolean).length; + console.log(`\n${passed}/${results.length} passed`); + process.exit(passed === results.length ? 0 : 1); +})().catch((e) => { console.error(e); process.exit(2); }); diff --git a/tests/e2e_http.py b/tests/e2e_http.py new file mode 100755 index 0000000..2140aa2 --- /dev/null +++ b/tests/e2e_http.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""End-to-end HTTP checks against a running Agent Market. Usage: e2e_http.py http://127.0.0.1:8795""" +import json, sys, urllib.request, urllib.error + +BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8795").rstrip("/") +results = [] + + +def call(method, path, body=None, headers=None, raw=False): + data = json.dumps(body).encode() if body is not None else None + h = {"Content-Type": "application/json", **(headers or {})} + req = urllib.request.Request(BASE + path, data=data, method=method, headers=h) + try: + with urllib.request.urlopen(req, timeout=60) as r: + b = r.read().decode() + return r.status, (b if raw else json.loads(b)) + except urllib.error.HTTPError as e: + b = e.read().decode() + try: + return e.code, json.loads(b) + except Exception: + return e.code, b + + +def check(name, cond, detail=""): + results.append((name, bool(cond))) + print(("PASS " if cond else "FAIL ") + name + (f" [{detail}]" if detail else "")) + + +s, html = call("GET", "/", raw=True) +check("landing page loads", s == 200 and "Agent Market" in html and "chatLauncher" in html) +s, st = call("GET", "/api/status") +check("status endpoint", s == 200 and st["payments"]["mode"] == "test", f"agent={st['agent']['mode']} fee_bps={st['fee_bps']}") +fee_bps = st["fee_bps"] + +s, out = call("POST", "/api/chat", {"message": "hi"}) +check("chat agent returns UI actions", s == 200 and out["actions"] and out["actions"][0]["type"] in ("show_hero", "show_cards"), out.get("agent")) + +s, out = call("POST", "/api/chat", {"message": "show me bikes"}) +cards = [a for a in out["actions"] if a["type"] == "show_cards"] +titles = [l["title"] for l in cards[0]["payload"]["listings"]] if cards else [] +check("agent search renders cards (hydrated listings)", cards and any("bike" in t.lower() for t in titles), ", ".join(titles)) +bike = next(l for l in cards[0]["payload"]["listings"] if l["kind"] == "product" and "online" in l["payment_options"]) + +s, out = call("POST", "/api/chat", {"message": "I want to sell something"}) +check("agent opens listing form", any(a["type"] == "show_listing_form" for a in out["actions"])) +s, out = call("POST", "/api/chat", {"event": {"type": "submit_listing", "fields": {"title": "Test-only desk lamp", "kind": "product", "price": "20", "description": "Fake test listing", "payment_options": ["online", "cash"]}}, "actor": "guest-e2e"}) +new = [a for a in out["actions"] if a["type"] == "show_cards"] +newl = new[0]["payload"]["listings"][0] if new else {} +check("creating a listing via the agent works", newl.get("title") == "Test-only desk lamp" and newl.get("price") == 2000 and newl.get("seller") == "guest-e2e") + +# Online purchase through the agent: checkout panel shows fee, test card confirms. +s, out = call("POST", "/api/chat", {"event": {"type": "buy_online", "listing_id": bike["id"]}, "actor": "guest-e2e"}) +co = [a for a in out["actions"] if a["type"] == "show_checkout"] +it = co[0]["payload"]["intent"] if co else {} +exp_fee = round(bike["price"] * fee_bps / 10000) +check("checkout panel shows fee", it.get("fee_cents") == exp_fee and it.get("total_cents") == bike["price"] + exp_fee and it.get("mode") == "test", + f"price={bike['price']} fee={it.get('fee_cents')} total={it.get('total_cents')}") +s, r = call("POST", f"/api/payments/{it['id']}/confirm", {"card_number": "1234 5678 9012 3456"}) +check("non-test card refused", r.get("status") == "failed" and r.get("error") == "not_a_test_card") +s, r = call("POST", f"/api/payments/{it['id']}/confirm", {"card_number": "4000 0000 0000 0002"}) +check("decline test card declines", r.get("status") == "failed" and r.get("error") == "card_declined") +s, r = call("POST", f"/api/payments/{it['id']}/confirm", {"card_number": "4242 4242 4242 4242"}) +order = r.get("order") or {} +check("test card 4242 confirms via signed webhook", r.get("status") == "succeeded" and order.get("status") == "paid" and order.get("fee_cents") == exp_fee, + f"order={order.get('id')} fee={order.get('fee_cents')} last4={order.get('card_last4')}") +s, out = call("POST", "/api/chat", {"event": {"type": "payment_result", "intent_id": it["id"], "status": "succeeded", "order_id": order.get("id")}}) +check("agent renders receipt after payment", any(a["type"] == "show_receipt" for a in out["actions"])) +s, l = call("GET", f"/api/listings/{bike['id']}") +check("sold product marked sold", l["listing"]["status"] == "sold") +s, r = call("POST", "/api/payments/webhook", {"type": "payment_intent.succeeded", "data": {"intent_id": it["id"]}}, {"X-Mock-Signature": "forged"}) +check("forged webhook rejected", s == 401) + +# Cash in person: zero fee. +s, out = call("POST", "/api/chat", {"message": "show lamp"}) +lamp = [l for a in out["actions"] if a["type"] == "show_cards" for l in a["payload"]["listings"] if "cash" in l["payment_options"]][0] +s, out = call("POST", "/api/chat", {"event": {"type": "buy_cash", "listing_id": lamp["id"]}, "actor": "guest-e2e"}) +cm = [a for a in out["actions"] if a["type"] == "show_cash_meetup"] +deal = cm[0]["payload"]["deal"] if cm else {} +check("cash meetup panel, fee $0", deal.get("fee_cents") == 0 and deal.get("status") == "arranged") +s, out = call("POST", "/api/chat", {"event": {"type": "cash_completed", "deal_id": deal["id"]}}) +rc = [a for a in out["actions"] if a["type"] == "show_receipt"] +check("cash deal completion records no fee", rc and rc[0]["payload"]["order"]["fee_cents"] == 0 and rc[0]["payload"]["order"]["method"] == "cash_in_person") + +# Agent API +s, man = call("GET", "/agent.json") +check("agent.json manifest", s == 200 and "start_checkout" in man["actions"] and man["test_mode"] is True) +s, oa = call("GET", "/openapi.json") +check("openapi.json", s == 200 and oa["openapi"].startswith("3.") and "/api/actions" in oa["paths"]) +A = {"X-Agent-Id": "agent:e2e-seller-bot"} +B = {"X-Agent-Id": "agent:e2e-buyer-bot"} +s, r = call("POST", "/api/actions", {"action": "create_listing", "args": {"title": "Test-only data labelling (agent service)", "kind": "service", "price": 1500, "description": "Fake service listed by a test agent"}}, A) +al = r.get("listing", {}) +check("agent API: agent lists a service", s == 201 and al.get("seller") == "agent:e2e-seller-bot" and al.get("seller_type") == "agent") +s, r = call("POST", "/api/actions", {"action": "search", "args": {"q": "labelling"}}, B) +check("agent API: search finds it", any(x["id"] == al.get("id") for x in r.get("listings", []))) +s, r = call("POST", "/api/actions", {"action": "quote", "args": {"listing_id": al["id"]}}, B) +check("agent API: quote online vs cash", r["online"]["fee_cents"] == round(1500 * fee_bps / 10000) and r["cash_in_person"]["fee_cents"] == 0) +s, r = call("POST", "/api/actions", {"action": "start_checkout", "args": {"listing_id": al["id"]}}, B) +pi = r.get("intent", {}) +s, r = call("POST", "/api/actions", {"action": "confirm_payment", "args": {"intent_id": pi.get("id"), "card_number": "4242424242424242"}}, B) +check("agent API: agent buys with test card", r.get("status") == "succeeded" and r["order"]["buyer"] == "agent:e2e-buyer-bot" and r["order"]["seller"] == "agent:e2e-seller-bot", + f"total={r.get('order', {}).get('total_cents')} fee={r.get('order', {}).get('fee_cents')}") +s, r = call("POST", "/api/actions", {"action": "list_orders"}, B) +check("agent API: buyer sees its order", any(o["listing_id"] == al["id"] for o in r.get("orders", []))) +s, r = call("POST", "/api/actions", {"action": "start_cash_deal", "args": {"listing_id": lamp["id"]}}, B) +check("agent API: sold product cannot be bought again", s == 400 and r.get("error") == "listing_not_active") + +passed = sum(1 for _, ok in results if ok) +print(f"\n{passed}/{len(results)} passed") +sys.exit(0 if passed == len(results) else 1) diff --git a/tests/llm_proxy_check.sh b/tests/llm_proxy_check.sh new file mode 100755 index 0000000..dbcbb66 --- /dev/null +++ b/tests/llm_proxy_check.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Exercises the hosted-LLM proxy path with a local fake OpenAI-compatible server (no real key needed). +set -euo pipefail +cd "$(dirname "$0")/.." +D=$(mktemp -d) +python3 tests/mock_llm.py 18798 & M=$! +AGENT_MARKET_PORT=18799 AGENT_MARKET_DATA=$D AGENT_MARKET_LLM_API_KEY=test-dummy-key \ + AGENT_MARKET_LLM_BASE_URL=http://127.0.0.1:18798/v1 AGENT_MARKET_LLM_MODEL=mock-model python3 server.py >/dev/null 2>&1 & S=$! +trap 'kill $M $S 2>/dev/null; rm -rf $D' EXIT +sleep 1.2 +OUT=$(curl -s localhost:18799/api/chat -H 'Content-Type: application/json' -d '{"message":"show me bikes"}') +echo "$OUT" | python3 -c ' +import json,sys; d=json.load(sys.stdin) +assert d["agent"]!="fallback", d +types=[a["type"] for a in d["actions"]] +assert types==["show_cards"], types # unknown component from the LLM is dropped +assert all("bike" in l["title"].lower() for l in d["actions"][0]["payload"]["listings"]) +print("PASS LLM proxy path: tool call -> hydrated cards; disallowed component dropped")' +if echo "$OUT" | grep -q test-dummy-key; then echo "FAIL key leaked to browser"; exit 1; else echo "PASS key never sent to browser"; fi +test "$(curl -s localhost:18798/ | python3 -c 'import json,sys;print(json.load(sys.stdin)["auth_ok"])')" -ge 2 && echo "PASS server sent key to LLM upstream only" diff --git a/tests/mock_llm.py b/tests/mock_llm.py new file mode 100755 index 0000000..0b67d50 --- /dev/null +++ b/tests/mock_llm.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Fake OpenAI-compatible endpoint to exercise the LLM proxy path without a real key. +Round 1 asks for a search tool; round 2 returns show_cards with ids from the tool results.""" +import json, re, sys +from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler +PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8798 +SEEN = {"auth_ok": 0} + +class H(BaseHTTPRequestHandler): + def log_message(self, *a): pass + def do_GET(self): + b = json.dumps(SEEN).encode(); self.send_response(200); self.end_headers(); self.wfile.write(b) + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + if self.headers.get("Authorization") == "Bearer test-dummy-key": + SEEN["auth_ok"] += 1 + last = body["messages"][-1]["content"] + if last.startswith("TOOL_RESULTS"): + ids = re.findall(r'"id": "(lst_[a-f0-9]+)"', last)[:3] + out = {"reply": "Here are bikes I found (mock LLM). Payments are TEST MODE ONLY.", + "actions": [{"type": "show_cards", "payload": {"heading": "Bikes", "listing_ids": ids}}, + {"type": "not_allowed_component", "payload": {}}]} + else: + out = {"reply": "searching", "tools": [{"name": "search", "args": {"q": "bike"}}]} + b = json.dumps({"choices": [{"message": {"role": "assistant", "content": json.dumps(out)}}]}).encode() + self.send_response(200); self.send_header("Content-Type", "application/json"); self.end_headers(); self.wfile.write(b) + +ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()