Compare commits
3 Commits
afc7ea47a0
...
6e60dfd6f9
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e60dfd6f9 | |||
| 7277a88bba | |||
| 16231871cb |
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
data/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
node_modules/
|
||||
screenshots/
|
||||
.env
|
||||
94
README.md
94
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.
|
||||
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
23
deploy.sh
Executable 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
0
market/__init__.py
Normal file
519
market/agent.py
Normal file
519
market/agent.py
Normal 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
38
market/payments.py
Normal 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
87
market/store.py
Normal 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
336
server.py
Executable 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
213
static/app.js
Normal 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
58
static/index.html
Normal 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
46
static/styles.css
Normal 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
86
tests/browser_e2e.js
Normal 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
111
tests/e2e_http.py
Executable 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
20
tests/llm_proxy_check.sh
Executable 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
28
tests/mock_llm.py
Executable 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()
|
||||
Loading…
x
Reference in New Issue
Block a user