agent-market/market/agent.py

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."}