agent-market/market/agent.py

656 lines
37 KiB
Python

"""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 concurrent.futures, json, os, re, time, 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 AI agent that drives the page of Agent Market, a TEST DEMO marketplace
where people and AI agents buy and sell products and services. All listings are fake test data and
all payments are TEST MODE ONLY (no real money).
You control what the page shows by returning UI actions. Reply with ONE JSON object and nothing else
(no markdown, no prose outside JSON):
{"reply": "<short friendly message, max 60 words>", "actions": [ ... ]}
Allowed actions (use only these types; listing ids must come from CATALOG below, never invent them):
- {"type":"show_cards","payload":{"heading":"...","listing_ids":["lst_..."]}} list matching items
- {"type":"show_compare","payload":{"heading":"...","listing_ids":["lst_...","lst_..."]}} side-by-side, 2-4 ids
- {"type":"show_checkout","payload":{"listing_id":"lst_..."}} online purchase; the panel shows price + fee
- {"type":"show_cash_meetup","payload":{"listing_id":"lst_...","suggested_place":"...","suggested_time":"..."}} pay cash in person, $0 fee
- {"type":"show_listing_form","payload":{"defaults":{"title":"...","kind":"product|service","price":<integer cents>,"description":"...","category":"...","area":"Demo Town","payment_options":["online","cash"]}}}
use when the user wants to sell/list something; prefill everything you can infer, the user presses Publish
("cash only" => payment_options ["cash"]; "$120" => price 12000)
- {"type":"show_hero","payload":{"title":"...","subtitle":"...","bullets":["..."]}} welcome / help
- {"type":"toast","payload":{"level":"info|ok|warn","text":"..."}}
- {"type":"set_theme","payload":{...}} change how the page LOOKS (see LOOK & LAYOUT). Applies instantly, only for this visitor.
LOOK & LAYOUT: you control the entire look of the page. Never say you can't change something visual; use set_theme.
set_theme payload = any subset of this spec (it is merged into the current look):
{"preset":"retro|minimal|neon|pastel|high contrast|sunset|ocean", // starts from that preset, other keys override
"reset":true, // back to the default look
"mode":"dark|light",
"background":{"kind":"solid|gradient|pattern|image","colors":["#hex",...],"angle":0-360,
"pattern":"dots|grid|stripes|diagonal|checks|waves","url":"https://images.unsplash.com/... or picsum.photos"},
"palette":{"bg","surface","card","text","muted","accent","border"}, // CSS colours (#hex, rgb(), names)
"font":"FONT","headingFont":"FONT","fontScale":0.75-1.75 (1=default),
"radius":0-32 (0=sharp corners, 24+=pill-ish),"density":"compact|normal|comfy",
"cardStyle":"flat|outlined|elevated|glass","layout":"grid|list","columns":0-6 (0=auto),
"copy":{"logo","headline","subhead","footer","chips":[{"label","say"}],"bullets":["..."]},
"sections":{"order":["hero","chips","bullets","results","footer"],"hidden":["bullets",...]},
"chat":{"position":"left|right","width":280-600,"height":320-820,"bg","accent","text"},
"animations":true|false,
"customCss":"<plain CSS rules for anything else, e.g. .card{border-width:3px} h1{letter-spacing:4px}>"}
FONT = system | serif | mono | Inter | Poppins | Montserrat | Nunito | Space Grotesk | Lora | Merriweather |
Playfair Display | Roboto Slab | Fira Code | Space Mono | VT323 | Press Start 2P | Bebas Neue | Pacifico | Comic Neue
Relative requests use CURRENT_LOOK (e.g. "bigger text" => raise fontScale from its current value, default 1).
Light mode usually needs only {"mode":"light"} plus any colours asked for; "green accent" => palette.accent.
Out of bounds (say so briefly and offer the closest option): the TEST DEMO banner, the agent badge, checkout fee
lines/amounts and the payment form are fixed for safety; no scripts or HTML; images only from the hosts above.
For a purely visual request return ONLY set_theme (do not re-show listings), and confirm what changed in "reply".
Rules:
- Never put internal ids (lst_..., pi_..., cash_..., ord_...) in "reply"; refer to items by their title.
- Interpret needs, not just keywords: e.g. "get my kid to school" => a kids' bike; "garden" => lawn/yard services.
- Respect budgets (prices in CATALOG are integer cents: 5500 = $55.00).
- If nothing fits, say so and show the closest options with show_cards.
- Online payment adds a FEE_PERCENT facilitation fee paid by the buyer; cash in person has no fee. Mention this when relevant.
- Usually return exactly one main panel action. Keep "reply" short and do not repeat the whole catalog in text.
- Optional: if you truly need a server action first, add "tools":[{"name":"search","args":{"q":"..."}}] (also
create_listing, start_checkout, start_cash_deal); you will get TOOL_RESULTS and then answer. Prefer not to.
""".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", "set_theme"}
ID_RE = re.compile(r"\s*\(?\b(?:lst|pi|cash|ord|evt)_[0-9a-f]{6,}\b\)?")
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_theme(low):
"""Basic look commands for when the LLM is unavailable."""
from .theme import PRESETS, PRESET_ALIASES
if re.search(r"\breset\b.*\b(look|theme|style|design|page)\b|\bdefault (look|theme)\b", low):
return {"reply": "Done: the page is back to its default look.", "actions": [{"type": "set_theme", "payload": {"reset": True}}]}
for name in list(PRESETS) + list(PRESET_ALIASES):
if re.search(r"\b" + re.escape(name) + r"\b", low) and re.search(r"look|theme|style|make it|switch|mode|preset|vibe", low):
real = PRESET_ALIASES.get(name, name)
return {"reply": f"Applied the {real} look.", "actions": [{"type": "set_theme", "payload": {"preset": real}}]}
patch, said = {}, []
if re.search(r"\blight (mode|theme)\b", low):
patch["mode"] = "light"; said.append("light mode")
elif re.search(r"\bdark (mode|theme)\b", low):
patch["mode"] = "dark"; said.append("dark mode")
if re.search(r"as a list|list (view|layout)|not cards", low):
patch["layout"] = "list"; said.append("list layout")
elif re.search(r"grid (view|layout)|as cards", low):
patch["layout"] = "grid"; said.append("grid layout")
if re.search(r"chat.*\bleft\b", low):
patch["chat"] = {"position": "left"}; said.append("chat on the left")
elif re.search(r"chat.*\bright\b", low):
patch["chat"] = {"position": "right"}; said.append("chat on the right")
if re.search(r"(no|stop|turn off|disable) animation", low):
patch["animations"] = False; said.append("animations off")
if not patch:
return None
return {"reply": "Done: " + ", ".join(said) + ".", "actions": [{"type": "set_theme", "payload": patch}]}
def fallback_agent(store, message: str, history, actor: str = "demo-buyer"):
m = (message or "").strip()
low = m.lower()
actions = []
reply = ""
th = fallback_theme(low)
if th:
return th
# 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 ----------
LLM_TIMEOUT = float(os.environ.get("AGENT_MARKET_LLM_TIMEOUT", "12")) # hard cap per model attempt
LLM_BUDGET = float(os.environ.get("AGENT_MARKET_LLM_BUDGET", "28")) # hard cap across the whole chain
_pool = concurrent.futures.ThreadPoolExecutor(max_workers=8, thread_name_prefix="llm")
COOLDOWN_429 = float(os.environ.get("AGENT_MARKET_LLM_COOLDOWN", "60"))
_cooldown = {} # model -> unix time until which we skip it after a 429
LAST = {"model": None, "at": None, "error": None}
class LLMError(Exception):
def __init__(self, kind, detail=""):
super().__init__(f"{kind}: {detail}")
self.kind = kind
def _read_key():
"""Key from a file path (preferred) or env. Never logged, never returned to clients."""
path = os.environ.get("AGENT_MARKET_LLM_API_KEY_FILE")
if path:
try:
with open(os.path.expanduser(path)) as f:
k = f.read().strip()
if k:
return k
except OSError:
pass
for env in ("AGENT_MARKET_LLM_API_KEY", "XAI_API_KEY", "OPENAI_API_KEY"):
if os.environ.get(env):
return os.environ[env]
return None
def _llm_config():
"""Hosted LLM config (OpenAI-compatible chat completions) with an ordered model fallback chain."""
if not _read_key():
return None
base = os.environ.get("AGENT_MARKET_LLM_BASE_URL") or ""
if not base:
base = "https://api.x.ai/v1" if os.environ.get("XAI_API_KEY") else ("https://api.openai.com/v1" if os.environ.get("OPENAI_API_KEY") else "")
models = [m.strip() for m in (os.environ.get("AGENT_MARKET_LLM_MODELS") or os.environ.get("AGENT_MARKET_LLM_MODEL") or "").split(",") if m.strip()]
if not base or not models:
return None
return {"provider": base.split("//")[-1].split("/")[0], "url": base.rstrip("/") + "/chat/completions",
"models": models, "model": models[0]}
def _call_llm(cfg, model, messages, json_mode=True):
payload = {"model": model, "messages": messages, "temperature": 0.2, "max_tokens": 1100}
if json_mode:
payload["response_format"] = {"type": "json_object"}
req = urllib.request.Request(cfg["url"], data=json.dumps(payload).encode(), method="POST", headers={
"Authorization": "Bearer " + _read_key(),
"Content-Type": "application/json",
"X-Title": "Agent Market demo",
})
try:
with urllib.request.urlopen(req, timeout=LLM_TIMEOUT) as r:
data = json.loads(r.read().decode())
except urllib.error.HTTPError as e:
body = e.read().decode(errors="replace")[:300]
if e.code == 429:
raise LLMError("rate_limited", body)
if e.code == 400 and json_mode and "response_format" in body:
return _call_llm(cfg, model, messages, json_mode=False)
raise LLMError(f"http_{e.code}", body)
except (urllib.error.URLError, TimeoutError, OSError) as e:
raise LLMError("timeout_or_network", type(e).__name__)
if isinstance(data, dict) and data.get("error"):
err = data["error"]
code = err.get("code") if isinstance(err, dict) else None
raise LLMError("rate_limited" if code == 429 else "provider_error", str(err)[:200])
try:
return data["choices"][0]["message"].get("content") or ""
except (KeyError, IndexError, TypeError):
raise LLMError("bad_response", str(data)[:200])
def _parse_agent_json(text: str):
text = re.sub(r"<think>.*?</think>", "", text or "", flags=re.S).strip()
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text).strip()
try:
obj = json.loads(text)
except ValueError:
m = re.search(r"\{.*\}", text, re.S)
if not m:
raise LLMError("not_json", text[:120])
try:
obj = json.loads(m.group(0))
except ValueError:
raise LLMError("not_json", text[:120])
if not isinstance(obj, dict):
raise LLMError("not_json", "top-level not an object")
return obj
def _catalog(store):
return [{"id": L["id"], "title": L["title"], "kind": L["kind"], "price_cents": L["price"],
"category": L.get("category"), "about": (L.get("description") or "")[:110],
"seller_type": L.get("seller_type"), "pay": L.get("payment_options")}
for L in store.all("listings") if L.get("status") == "active"][:60]
def _llm_once(store, cfg, model, message, history, look=""):
sys_prompt = (UI_SCHEMA + "\nCATALOG (all active listings):\n" + json.dumps(_catalog(store))[:9000] +
"\nCURRENT_LOOK (this visitor's theme overrides; {} = default dark look):\n" + (look or "{}"))
msgs = [{"role": "system", "content": sys_prompt}]
for h in (history or [])[-6:]:
if h.get("role") in ("user", "assistant") and h.get("content"):
msgs.append({"role": h["role"], "content": str(h["content"])[:600]})
msgs.append({"role": "user", "content": message})
content = _call_llm(cfg, model, msgs)
plan = _parse_agent_json(content)
tool_results = []
tools = [t for t in (plan.get("tools") or []) if isinstance(t, dict)][:3]
if tools:
tool_results = run_tools(store, tools)
msgs += [{"role": "assistant", "content": content},
{"role": "user", "content": "TOOL_RESULTS:\n" + json.dumps(tool_results)[:6000] +
"\nNow return the final {\"reply\",\"actions\"} JSON only."}]
plan = _parse_agent_json(_call_llm(cfg, model, msgs))
actions = [a for a in (plan.get("actions") or []) if isinstance(a, dict) and a.get("type") in ALLOWED_ACTIONS]
reply = str(plan.get("reply") or "").strip()
if not reply and not actions:
raise LLMError("empty_plan", "")
reply = ID_RE.sub("", reply).strip()
return {"reply": (reply or "Done.")[:600], "actions": actions, "tools": tool_results}
def llm_agent(store, message, history, cfg, look=""):
"""Try each model in the chain; skip models cooling down after a 429. Raises if all fail."""
errors = []
now_t = time.time()
chain = [m for m in cfg["models"] if _cooldown.get(m, 0) <= now_t] or cfg["models"][-1:]
start = time.time()
for model in chain:
t0 = time.time()
remaining = LLM_BUDGET - (t0 - start)
if remaining < 3:
errors.append({"model": model, "error": "skipped_budget", "ms": 0})
continue
try:
fut = _pool.submit(_llm_once, store, cfg, model, message, history, look)
try:
out = fut.result(timeout=min(LLM_TIMEOUT, remaining))
except concurrent.futures.TimeoutError:
raise LLMError("timeout", f">{min(LLM_TIMEOUT, remaining):.0f}s")
out.update(agent="llm", provider=cfg["provider"], model=model,
latency_ms=int((time.time() - t0) * 1000), fallbacks=errors)
LAST.update(model=model, at=time.time(), error=None)
return out
except LLMError as e:
if e.kind == "rate_limited":
_cooldown[model] = time.time() + COOLDOWN_429
errors.append({"model": model, "error": e.kind, "ms": int((time.time() - t0) * 1000)})
except Exception as e: # never let the page break
errors.append({"model": model, "error": type(e).__name__, "ms": int((time.time() - t0) * 1000)})
LAST.update(error=errors[-1]["error"] if errors else "no_model", at=time.time())
raise LLMError("all_models_failed", json.dumps(errors))
# Structured UI events (button clicks, form submits, payment results) have one obvious outcome,
# so they are handled deterministically: fast, reliable, and they don't spend free-tier LLM quota.
# Free-text messages go to the LLM. Set AGENT_MARKET_LLM_EVENTS=1 to route events to the LLM too.
LLM_EVENTS = os.environ.get("AGENT_MARKET_LLM_EVENTS") == "1"
def handle_chat(store, message: str, history=None, event=None, actor="demo-buyer", look=""):
actor = (actor or "demo-buyer")[:60]
t0 = time.time()
if event and (event.get("type") == "landing" or not LLM_EVENTS):
if event.get("type") == "landing":
out = fallback_agent(store, "show me everything", [], actor)
else:
out = fallback_event(store, event, actor)
out.update(agent="ui-event", latency_ms=int((time.time() - t0) * 1000))
return out
cfg = _llm_config()
if cfg:
text = message if not event else "UI_EVENT " + json.dumps(event)[:2000] + f"\n(actor/buyer id: {actor})"
try:
return llm_agent(store, text, history or [], cfg, look)
except Exception as e:
out = fallback_event(store, event, actor) if event else fallback_agent(store, message, history or [], actor)
out.update(agent="fallback", llm_error=getattr(e, "kind", type(e).__name__),
fallbacks=json.loads(str(e).split(": ", 1)[1]) if getattr(e, "kind", "") == "all_models_failed" else [],
latency_ms=int((time.time() - t0) * 1000))
out["reply"] = "(The AI model is busy right now, so the simple built-in agent answered.) " + out["reply"]
return out
out = fallback_event(store, event, actor) if event else fallback_agent(store, message, history or [], actor)
out.update(agent="fallback", latency_ms=int((time.time() - t0) * 1000))
return out
def agent_status():
cfg = _llm_config()
if cfg:
now_t = time.time()
return {"mode": "llm", "provider": cfg["provider"], "model": cfg["models"][0], "chain": cfg["models"],
"cooling_down": [m for m in cfg["models"] if _cooldown.get(m, 0) > now_t],
"last_model": LAST["model"], "last_error": LAST["error"], "ui_events": "llm" if LLM_EVENTS else "deterministic"}
return {"mode": "fallback", "provider": "deterministic", "model": None,
"note": "No hosted LLM configured (set AGENT_MARKET_LLM_API_KEY_FILE or AGENT_MARKET_LLM_API_KEY, plus AGENT_MARKET_LLM_BASE_URL and AGENT_MARKET_LLM_MODELS). Using the labelled rule-based fallback agent."}