Compare commits

..

3 Commits

15 changed files with 1663 additions and 2 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
data/
__pycache__/
*.pyc
node_modules/
screenshots/
.env

View File

@ -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. 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 <staging|demo>` 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.

23
deploy.sh Executable file
View File

@ -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=/gitea/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"

0
market/__init__.py Normal file
View File

519
market/agent.py Normal file
View File

@ -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."}

38
market/payments.py Normal file
View File

@ -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}

87
market/store.py Normal file
View File

@ -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()

336
server.py Executable file
View File

@ -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:<your-name>. Demo only: identity is not verified.",
"endpoint": {"method": "POST", "url": base + "/api/actions", "body": {"action": "<name>", "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:<name>"}],
"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()

213
static/app.js Normal file
View File

@ -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.");
})();
})();

58
static/index.html Normal file
View File

@ -0,0 +1,58 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Agent Market (demo)</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<div class="testbar">TEST DEMO: all listings are fake and payments run in test mode. No real money moves.</div>
<header class="top">
<div class="brand"><span class="logo">◆</span> Agent Market</div>
<nav>
<button class="link" data-say="show me everything">Browse</button>
<button class="link" data-event="sell">Sell</button>
<a class="link" href="/agent.json" target="_blank">For agents</a>
<span id="agentBadge" class="badge">agent: …</span>
</nav>
</header>
<main>
<section class="landing" id="landing">
<div class="landing-copy">
<h1>A marketplace you can just talk to.</h1>
<p>Tell the agent what you want. It searches, compares, lists your stuff and runs the checkout, right here on the page.</p>
<div class="chips">
<button class="chip" data-say="show me bikes">🚲 Show me bikes</button>
<button class="chip" data-say="find tutoring services">📐 Tutoring</button>
<button class="chip" data-say="compare tutoring">⚖️ Compare tutoring</button>
<button class="chip" data-event="sell">➕ Sell something</button>
</div>
<ul class="facts">
<li><b id="feePct">5%</b> facilitation fee when you pay online (test mode)</li>
<li><b>$0</b> fee when you meet and pay cash in person</li>
<li><b>Agents welcome:</b> other AI agents buy and sell through the <a href="/openapi.json" target="_blank">open API</a></li>
</ul>
</div>
</section>
<section id="stage" aria-live="polite"></section>
</main>
<button id="chatLauncher" class="launcher" aria-label="Open the market agent">💬 Ask the agent</button>
<aside id="chat" class="chat hidden" aria-label="Market agent chat">
<div class="chat-head">
<div><b>Market Guide</b><div class="sub" id="chatMode">AI agent</div></div>
<button id="chatClose" aria-label="Close">✕</button>
</div>
<div id="log" class="log"></div>
<form id="chatForm" class="chat-form">
<input id="chatInput" autocomplete="off" placeholder="e.g. find a bike under $200">
<button>Send</button>
</form>
</aside>
<div id="toasts"></div>
<script src="/static/app.js"></script>
</body>
</html>

46
static/styles.css Normal file
View File

@ -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}}

86
tests/browser_e2e.js Normal file
View File

@ -0,0 +1,86 @@
// Headless browser walkthrough. Usage: NODE_PATH=<dir with puppeteer-core> 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 > 0 && titles.every((t) => /bike/i.test(t)), titles.join(" | "));
if (SHOTS) { await sleep(600); await page.screenshot({ path: SHOTS + "/1-landing-chat-search.png" }); }
const feeBps = (await (await fetch(BASE + "/api/status")).json()).fee_bps;
const money = (c) => "$" + (c / 100).toFixed(2);
// online checkout: first card that offers online payment
const pick = await page.evaluate(() => {
const c = [...document.querySelectorAll("#stage .card")].find((c) => c.querySelector("button.btn:not(.cash)"));
return c && { id: c.dataset.listing, price: c.querySelector(".price").childNodes[0].textContent };
});
check("an online-payable listing is visible", !!pick, pick && pick.price);
const priceC = Math.round(parseFloat(pick.price.slice(1)) * 100), feeC = Math.round(priceC * feeBps / 10000);
await page.click(`.card[data-listing="${pick.id}"] button.btn:not(.cash)`);
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 ${feeBps / 100}% fee and total`, fee === money(feeC) && total === money(priceC + feeC), `price ${pick.price} fee ${fee} total ${total}`);
if (SHOTS) { await sleep(600); 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 === money(feeC), rfee);
// cash meetup: first card offering cash
await page.type("#chatInput", "show me everything");
await page.keyboard.press("Enter");
await page.waitForFunction(() => /everything/.test((document.querySelector("#stage h2") || {}).textContent || ""), { timeout: 15000 });
const cashId = await page.evaluate(() => { const c = [...document.querySelectorAll("#stage .card")].find((c) => c.querySelector("button.cash")); return c && c.dataset.listing; });
await page.click(`.card[data-listing="${cashId}"] button.cash`);
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); 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); });

111
tests/e2e_http.py Executable file
View File

@ -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)

20
tests/llm_proxy_check.sh Executable file
View File

@ -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"

28
tests/mock_llm.py Executable file
View File

@ -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()