876 lines
48 KiB
Python
876 lines
48 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
|
|
import threading, 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":["..."]}, // page TEXT, see PAGE_TEXT
|
|
"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".
|
|
PAGE TEXT is editable with set_theme copy (current values are in PAGE_TEXT below). Where things are:
|
|
copy.logo = the brand/site name in the TOP-LEFT corner of the header (a.k.a. logo, brand, site name, header text, "top left").
|
|
copy.headline = the big title, copy.subhead = the line under it (subtitle/tagline), copy.footer = the bottom line,
|
|
copy.chips = the quick-action buttons, copy.bullets = the bullet points (send the whole list when changing one item).
|
|
"replace/change/rename A to/with B": find A in PAGE_TEXT and change that slot (only the words A if A is part of it).
|
|
Keep the user's capitalisation for B (if they typed it all lowercase, match the style of the text it replaces).
|
|
e.g. "replace agent market to new internet on the top left corner" => {"type":"set_theme","payload":{"copy":{"logo":"New Internet"}}}
|
|
|
|
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_agent(store, message: str, history, actor: str = "demo-buyer", look=None):
|
|
m = (message or "").strip()
|
|
low = m.lower()
|
|
actions = []
|
|
reply = ""
|
|
|
|
from .theme import parse_look_command
|
|
th = parse_look_command(m, look if isinstance(look, dict) else {})
|
|
if th:
|
|
# payload None = understood a copy edit but couldn't apply it; the reply says why (no listings, no silent no-op)
|
|
return {"reply": th[0], "actions": [{"type": "set_theme", "payload": th[1]}] if th[1] else []}
|
|
|
|
# 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, "local": False}
|
|
QUOTA = {"until": 0}
|
|
# Local backup model (e.g. Ollama on this box, OpenAI-compatible): tried after the hosted chain, before the keyword rules.
|
|
LOCAL_TIMEOUT = float(os.environ.get("AGENT_MARKET_LOCAL_LLM_TIMEOUT", "45")) # its own cap (CPU inference is slower)
|
|
LOCAL_MIN = float(os.environ.get("AGENT_MARKET_LOCAL_LLM_MIN", "25")) # always give the local model at least this long
|
|
TOTAL_BUDGET = float(os.environ.get("AGENT_MARKET_LLM_TOTAL_BUDGET", "60")) # hosted chain + local model, per turn
|
|
_local_slots = threading.Semaphore(int(os.environ.get("AGENT_MARKET_LOCAL_LLM_CONCURRENCY", "2"))) # 1 running + 1 queued
|
|
_QUOTA_FILE = os.path.join(os.environ["AGENT_MARKET_DATA"], "llm_quota.json") if os.environ.get("AGENT_MARKET_DATA") else None
|
|
|
|
|
|
def _load_quota():
|
|
"""Remember a hosted daily-quota stop across restarts, so a redeploy doesn't spend a call to rediscover it."""
|
|
try:
|
|
with open(_QUOTA_FILE) as f:
|
|
d = json.load(f)
|
|
until = min(float(d.get("until") or 0), time.time() + 36 * 3600) # a daily cap never lasts longer than this
|
|
if until > time.time() and d.get("base") == os.environ.get("AGENT_MARKET_LLM_BASE_URL", ""):
|
|
QUOTA["until"] = until
|
|
except (OSError, ValueError, TypeError, AttributeError):
|
|
pass
|
|
|
|
|
|
def _save_quota():
|
|
if not _QUOTA_FILE:
|
|
return
|
|
try:
|
|
with open(_QUOTA_FILE, "w") as f:
|
|
json.dump({"until": QUOTA["until"], "base": os.environ.get("AGENT_MARKET_LLM_BASE_URL", "")}, f)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
_load_quota()
|
|
|
|
|
|
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 _local_config():
|
|
"""Local OpenAI-compatible server (no key), e.g. AGENT_MARKET_LOCAL_LLM_URL=http://127.0.0.1:11434/v1."""
|
|
base = (os.environ.get("AGENT_MARKET_LOCAL_LLM_URL") or "").strip()
|
|
model = (os.environ.get("AGENT_MARKET_LOCAL_LLM_MODEL") or "").strip()
|
|
if not base or not model:
|
|
return None
|
|
return {"provider": "local", "url": base.rstrip("/") + "/chat/completions", "model": model, "models": [model], "local": True}
|
|
|
|
|
|
def _llm_config():
|
|
"""Hosted LLM config (OpenAI-compatible chat completions) with an ordered model fallback chain,
|
|
plus an optional local backup model. None when neither is configured."""
|
|
remote = None
|
|
if _read_key():
|
|
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 base and models:
|
|
remote = {"provider": base.split("//")[-1].split("/")[0], "url": base.rstrip("/") + "/chat/completions",
|
|
"models": models, "model": models[0]}
|
|
local = _local_config()
|
|
if not remote and not local:
|
|
return None
|
|
cfg = remote or {"provider": "local", "url": None, "models": [], "model": local["model"]}
|
|
cfg["local"] = local
|
|
return cfg
|
|
|
|
|
|
def _call_llm(cfg, model, messages, json_mode=True, timeout=None):
|
|
payload = {"model": model, "messages": messages, "temperature": 0.2, "max_tokens": 1100}
|
|
if json_mode:
|
|
payload["response_format"] = {"type": "json_object"} # Ollama maps this to JSON mode
|
|
headers = {"Content-Type": "application/json", "X-Title": "Agent Market demo"}
|
|
if cfg.get("local"):
|
|
payload["max_tokens"] = 700
|
|
payload["keep_alive"] = -1 # Ollama: keep the model loaded (the server default is set too)
|
|
else:
|
|
headers["Authorization"] = "Bearer " + _read_key()
|
|
req = urllib.request.Request(cfg["url"], data=json.dumps(payload).encode(), method="POST", headers=headers)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout or LLM_TIMEOUT) as r:
|
|
data = json.loads(r.read().decode())
|
|
except urllib.error.HTTPError as e:
|
|
body = e.read().decode(errors="replace")[:2000]
|
|
if e.code == 429:
|
|
m = re.search(r'"X-RateLimit-Reset":\s*"?(\d{12,})', body)
|
|
if "per-day" in body or "daily" in body:
|
|
raise LLMError("daily_quota", m.group(1) if m else "")
|
|
raise LLMError("rate_limited", body[:300])
|
|
if e.code == 400 and json_mode and "response_format" in body:
|
|
return _call_llm(cfg, model, messages, json_mode=False, timeout=timeout)
|
|
raise LLMError(f"http_{e.code}", body[:300])
|
|
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
|
if isinstance(e, TimeoutError) or "timed out" in str(e):
|
|
raise LLMError("timeout", type(e).__name__)
|
|
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 _page_text(look):
|
|
from .copyedit import current_copy
|
|
try:
|
|
t = json.loads(look) if look else {}
|
|
except Exception:
|
|
t = {}
|
|
c = current_copy(t if isinstance(t, dict) else {}, FEE_BPS)
|
|
return json.dumps({"logo": c["logo"], "headline": c["headline"], "subhead": c["subhead"][:120], "footer": c["footer"],
|
|
"chips": [x.get("label") for x in c["chips"]], "bullets": c["bullets"]}, ensure_ascii=False)
|
|
|
|
|
|
def _system_prompt(store, look=""):
|
|
# stable parts first (schema, catalog) so a local model's prompt cache can reuse them across turns
|
|
return (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 "{}") +
|
|
"\nPAGE_TEXT (what the editable text slots show right now):\n" + _page_text(look))
|
|
|
|
|
|
_PRIMED = {"key": None}
|
|
|
|
|
|
def _prime_local_once(store):
|
|
"""Process the current system prompt on the local model in the background (1 output token), so the next visitor
|
|
turn reuses Ollama's prompt cache instead of paying ~30 s of CPU prompt processing."""
|
|
local = _local_config()
|
|
if not local:
|
|
return False
|
|
sp = _system_prompt(store, "")
|
|
key = hash(sp)
|
|
if key == _PRIMED["key"] or not _local_slots.acquire(blocking=False):
|
|
return False
|
|
try:
|
|
body = {"model": local["model"], "messages": [{"role": "system", "content": sp}, {"role": "user", "content": "hi"}],
|
|
"max_tokens": 1, "temperature": 0, "keep_alive": -1}
|
|
req = urllib.request.Request(local["url"], data=json.dumps(body).encode(), headers={"Content-Type": "application/json"})
|
|
with urllib.request.urlopen(req, timeout=180) as r:
|
|
r.read()
|
|
_PRIMED["key"] = key
|
|
return True
|
|
except Exception:
|
|
return False
|
|
finally:
|
|
_local_slots.release()
|
|
|
|
|
|
def start_local_primer(store, every=30):
|
|
"""Background thread: prime at start-up and whenever the catalog (and so the prompt) changes."""
|
|
if not _local_config() or os.environ.get("AGENT_MARKET_LOCAL_LLM_PRIME", "1") != "1":
|
|
return
|
|
|
|
def loop():
|
|
while True:
|
|
try:
|
|
_prime_local_once(store)
|
|
except Exception:
|
|
pass
|
|
time.sleep(every)
|
|
threading.Thread(target=loop, name="local-llm-primer", daemon=True).start()
|
|
|
|
|
|
def _llm_once(store, cfg, model, message, history, look="", timeout=None):
|
|
sys_prompt = _system_prompt(store, look)
|
|
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})
|
|
t_end = time.time() + (timeout or LLM_TIMEOUT)
|
|
content = _call_llm(cfg, model, msgs, timeout=timeout)
|
|
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."}]
|
|
left = t_end - time.time()
|
|
if left < 2:
|
|
raise LLMError("timeout", "no time left for the tool round")
|
|
plan = _parse_agent_json(_call_llm(cfg, model, msgs, timeout=left))
|
|
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}
|
|
|
|
|
|
_STOP = set("show me find search look looking for want need some any the a an and or with under over below above cheap "
|
|
"please can could you i my our get buy see list all everything something anything thing things stuff near "
|
|
"options option there here have has what which that this from about like".split())
|
|
|
|
|
|
def _focus_results(store, message, actions):
|
|
"""Small local models sometimes pad show_cards with unrelated listings. If some chosen listings share a keyword with
|
|
the request and others share none, keep only the matching ones (semantic picks with no keyword overlap stay)."""
|
|
words = {w.rstrip("s") for w in re.findall(r"[a-z]{3,}", (message or "").lower()) if w not in _STOP}
|
|
if not words:
|
|
return actions
|
|
for a in actions:
|
|
if a.get("type") != "show_cards" or not isinstance(a.get("payload"), dict):
|
|
continue
|
|
ids = [i for i in (a["payload"].get("listing_ids") or []) if isinstance(i, str)]
|
|
def hit(i):
|
|
L = store.get("listings", i) or {}
|
|
text = " ".join([L.get("title", ""), L.get("category") or "", " ".join(L.get("tags") or []), L.get("description", "")]).lower()
|
|
toks = {t.rstrip("s") for t in re.findall(r"[a-z]{3,}", text)}
|
|
return bool(words & toks)
|
|
keep = [i for i in ids if hit(i)]
|
|
if keep and len(keep) < len(ids):
|
|
a["payload"]["listing_ids"] = keep
|
|
return actions
|
|
|
|
|
|
LOCAL_HISTORY = int(os.environ.get("AGENT_MARKET_LOCAL_LLM_HISTORY", "0")) # small models copy earlier turns; 0 = no history
|
|
|
|
|
|
def _rules_check(message, look, out):
|
|
"""Look/copy commands are where a ~4B model is least reliable (it copies presets, misplaces keys, or replies without
|
|
the action). When the deterministic parser recognises the message as a look or text edit, its validated patch
|
|
wins and the answer is marked rules_checked (the chat header says so)."""
|
|
try:
|
|
cur = json.loads(look) if look else {}
|
|
except ValueError:
|
|
cur = {}
|
|
from .theme import parse_look_command
|
|
r = parse_look_command(message, cur if isinstance(cur, dict) else {})
|
|
if not r:
|
|
return out
|
|
reply, payload = r
|
|
if payload is None: # a text edit it couldn't place: keep the model's answer if it did something
|
|
if not out["actions"]:
|
|
out["reply"], out["rules_checked"] = reply, True
|
|
return out
|
|
out["actions"] = [{"type": "set_theme", "payload": payload}]
|
|
out["reply"], out["rules_checked"] = reply, True
|
|
return out
|
|
|
|
|
|
def llm_agent(store, message, history, cfg, look=""):
|
|
"""Hosted models in order (skipping ones cooling down after a 429, and all of them while the daily free quota is
|
|
used up), then the local backup model with its own timeout. Raises if everything fails (keyword rules answer then)."""
|
|
errors = []
|
|
now_t = time.time()
|
|
start = now_t
|
|
local = cfg.get("local")
|
|
if QUOTA["until"] > now_t:
|
|
chain = []
|
|
errors.append({"model": "*", "error": "daily_quota", "ms": 0})
|
|
else:
|
|
chain = [m for m in cfg["models"] if _cooldown.get(m, 0) <= now_t]
|
|
if not chain and not local:
|
|
chain = cfg["models"][-1:]
|
|
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, local=False)
|
|
return out
|
|
except LLMError as e:
|
|
if e.kind == "rate_limited":
|
|
_cooldown[model] = time.time() + COOLDOWN_429
|
|
elif e.kind == "daily_quota":
|
|
# the free-tier daily cap is per key: park every model until the reset (or 15 min if unknown)
|
|
d = str(e).split(": ", 1)[-1]
|
|
until = int(d) / 1000 if d.isdigit() else time.time() + 900
|
|
for mm in cfg["models"]:
|
|
_cooldown[mm] = max(_cooldown.get(mm, 0), until)
|
|
QUOTA["until"] = until
|
|
_save_quota()
|
|
errors.append({"model": model, "error": e.kind, "ms": int((time.time() - t0) * 1000)})
|
|
break
|
|
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)})
|
|
if local:
|
|
t0 = time.time()
|
|
cap = min(LOCAL_TIMEOUT, max(LOCAL_MIN, TOTAL_BUDGET - (t0 - start)))
|
|
if not _local_slots.acquire(blocking=False):
|
|
errors.append({"model": local["model"], "error": "local_busy", "ms": 0})
|
|
else:
|
|
try:
|
|
hist = (history or [])[-LOCAL_HISTORY:] if LOCAL_HISTORY > 0 else []
|
|
fut = _pool.submit(_llm_once, store, local, local["model"], message, hist, look, cap)
|
|
except Exception:
|
|
_local_slots.release()
|
|
raise
|
|
fut.add_done_callback(lambda f: _local_slots.release()) # free the slot when the call really ends
|
|
try:
|
|
try:
|
|
out = fut.result(timeout=cap + 1)
|
|
except concurrent.futures.TimeoutError:
|
|
raise LLMError("timeout", f">{cap:.0f}s")
|
|
out["actions"] = _focus_results(store, message, out["actions"])
|
|
if not message.startswith("UI_EVENT "):
|
|
out = _rules_check(message, look, out)
|
|
out.update(agent="llm", provider="local", local=True, model=local["model"],
|
|
latency_ms=int((time.time() - t0) * 1000), fallbacks=errors)
|
|
LAST.update(model=local["model"], at=time.time(), error=None, local=True)
|
|
return out
|
|
except LLMError as e:
|
|
errors.append({"model": local["model"], "error": e.kind, "ms": int((time.time() - t0) * 1000)})
|
|
except Exception as e:
|
|
errors.append({"model": local["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()
|
|
try:
|
|
look_d = json.loads(look) if look else {}
|
|
except ValueError:
|
|
look_d = {}
|
|
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, look_d)
|
|
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"] = "(AI model unavailable right now; the built-in agent answered.) " + out["reply"]
|
|
return out
|
|
out = fallback_event(store, event, actor) if event else fallback_agent(store, message, history or [], actor, look_d)
|
|
out.update(agent="fallback", latency_ms=int((time.time() - t0) * 1000))
|
|
return out
|
|
|
|
|
|
_LOCAL_PING = {"at": 0, "ok": None}
|
|
|
|
|
|
def _local_up(local):
|
|
"""Is the local model server answering? (cached 15 s; loopback only)"""
|
|
if time.time() - _LOCAL_PING["at"] > 15:
|
|
try:
|
|
with urllib.request.urlopen(local["url"].rsplit("/chat/completions", 1)[0] + "/models", timeout=1.5) as r:
|
|
_LOCAL_PING["ok"] = r.status == 200
|
|
except Exception:
|
|
_LOCAL_PING["ok"] = False
|
|
_LOCAL_PING["at"] = time.time()
|
|
return _LOCAL_PING["ok"]
|
|
|
|
|
|
def agent_status():
|
|
cfg = _llm_config()
|
|
if cfg:
|
|
now_t = time.time()
|
|
local = cfg.get("local")
|
|
return {"mode": "llm", "provider": cfg["provider"], "model": (cfg["models"] or [None])[0] or (local or {}).get("model"),
|
|
"chain": cfg["models"] + ([f"local:{local['model']}"] if local else []),
|
|
"local_model": local["model"] if local else None, "local_ok": _local_up(local) if local else None,
|
|
"cooling_down": [m for m in cfg["models"] if _cooldown.get(m, 0) > now_t],
|
|
"last_model": LAST["model"], "last_local": LAST["local"], "last_error": LAST["error"],
|
|
"ui_events": "llm" if LLM_EVENTS else "deterministic",
|
|
"daily_quota_exhausted_until_utc": time.strftime("%Y-%m-%dT%H:%MZ", time.gmtime(QUOTA["until"])) if QUOTA["until"] > now_t else None}
|
|
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."}
|