520 lines
28 KiB
Python
520 lines
28 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 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."}
|