agent-market/server.py

398 lines
23 KiB
Python
Executable File

#!/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, threading, time, 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
from market import theme as TH
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")
# Frontend: the React/Vite build in static/dist (built from web/ by deploy.sh; see README "Frontend").
STATIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
INDEX = os.path.join("dist", "index.html")
WEBHOOK_SECRET = os.environ.get("AGENT_MARKET_WEBHOOK_SECRET") or secrets.token_hex(16) # per-process if unset
MAX_BODY = 64 * 1024
CHAT_RPM = int(os.environ.get("AGENT_MARKET_CHAT_RPM", "12")) # per-IP chat requests per minute
CHAT_BURST = int(os.environ.get("AGENT_MARKET_CHAT_BURST", "6"))
_buckets = {} # ip -> [tokens, last_ts]
_bucket_lock = threading.Lock()
def rate_ok(ip):
"""Token bucket per client IP. Returns (allowed, retry_after_seconds)."""
if CHAT_RPM <= 0:
return True, 0
rate = CHAT_RPM / 60.0
with _bucket_lock:
now_t = time.time()
tokens, last = _buckets.get(ip, (float(CHAT_BURST), now_t))
tokens = min(float(CHAT_BURST), tokens + (now_t - last) * rate)
if tokens >= 1:
_buckets[ip] = (tokens - 1, now_t)
return True, 0
_buckets[ip] = (tokens, now_t)
if len(_buckets) > 5000:
_buckets.clear()
return False, int((1 - tokens) / rate) + 1
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_listing_form":
# small models sometimes send the defaults flat ({"title":..., "price":...}); accept both shapes
if not isinstance(p.get("defaults"), dict):
p = {"defaults": {k: v for k, v in p.items() if k in ("title", "kind", "price", "price_cents", "description", "category", "area", "payment_options")}}
d = p["defaults"]
if d.get("price") is None and isinstance(d.get("price_cents"), (int, float)):
d["price"] = int(d["price_cents"])
d.pop("price_cents", None)
elif t == "set_theme":
act = TH.build_theme_action(p)
if not act:
continue
p = act
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 _client_ip(self):
# Only trust proxy headers when the peer is the local tunnel (cloudflared) on loopback.
peer = self.client_address[0]
if peer in ("127.0.0.1", "::1"):
fwd = self.headers.get("CF-Connecting-IP") or (self.headers.get("X-Forwarded-For") or "").split(",")[0].strip()
if fwd:
return fwd
return peer
def _json(self, code, obj, extra_headers=None):
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", "*")
for k, v in (extra_headers or {}).items():
self.send_header(k, v)
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 + os.sep) or not os.path.isfile(path):
if rel == INDEX:
return self._json(503, {"error": "frontend_not_built", "hint": "cd web && npm ci && npm run build"})
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", ".json": "application/json",
".ico": "image/x-icon", ".woff2": "font/woff2"}.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)
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":
# Only free-text turns reach the LLM; deterministic UI events (clicks, payments) are not limited.
llm_bound = not isinstance(body.get("event"), dict) or A.LLM_EVENTS
ok, retry = rate_ok(self._client_ip()) if llm_bound else (True, 0)
if not ok:
return self._json(429, {"error": "rate_limited", "retry_after": retry, "agent": "rate-limit",
"reply": f"You're sending messages quickly. Please wait about {retry}s and try again.",
"actions": []}, {"Retry-After": str(retry)})
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 []
look = TH.compact_current(body.get("theme")) if isinstance(body.get("theme"), dict) else ""
out = A.handle_chat(store, msg, hist, ev, actor, look)
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()
A.start_local_primer(store)
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()