"""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": "", "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":,"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":""} 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 handlers for clicks, forms and payments (typed messages always go to a model) ---------- 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 featured_listings(store, limit=8): """Landing view: the first active listings, no model involved.""" hits = _search_q(store, "", limit=limit) if not hits: return {"reply": "No listings yet. Say what you want to sell and I'll set up the form.", "actions": []} return {"reply": f"Here are {len(hits)} listings. Tell me what you're after, or click a card.", "actions": [{"type": "show_cards", "payload": {"heading": "Featured listings", "listing_ids": [h["id"] for h in hits]}}]} def sell_form(): """The "Sell something" button: an empty listing form (the user fills it in and presses Publish).""" return {"reply": "Fill in the listing form. I'll publish it as test data. The 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": "📦"}}}]} BUSY_REPLY = "The assistant is busy, please try again in a moment." 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 sell_form() 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": []} # ---------- 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 model (llama.cpp llama-server on this box, OpenAI-compatible): the last link of the chain, after the hosted models. 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 # GBNF for "exactly one JSON object" (llama.cpp grammar syntax). Used instead of response_format for the local model: # it is cheap to sample with and the object ends at the closing brace (no trailing whitespace run). JSON_GRAMMAR = r'''root ::= "{" ws ( string ":" ws value ("," ws string ":" ws value)* )? "}" value ::= object | array | string | number | ("true" | "false" | "null") ws object ::= "{" ws ( string ":" ws value ("," ws string ":" ws value)* )? "}" ws array ::= "[" ws ( value ("," ws value)* )? "]" ws string ::= "\"" ( [^"\\\x7F\x00-\x1F] | "\\" (["\\bfnrt] | "u" [0-9a-fA-F]{4}) )* "\"" ws number ::= ("-"? ([0-9] | [1-9] [0-9]{0,15})) ("." [0-9]+)? ([eE] [-+]? [0-9]{1,3})? ws ws ::= | " " | "\n" [ ]{0,12} ''' def _call_llm(cfg, model, messages, json_mode=True, timeout=None): payload = {"model": model, "messages": messages, "temperature": 0.2, "max_tokens": 1100} headers = {"Content-Type": "application/json", "X-Title": "Agent Market demo"} if cfg.get("local") is True: # the local server itself (a hosted cfg carries its local backup as a dict) payload["max_tokens"] = int(os.environ.get("AGENT_MARKET_LOCAL_LLM_MAX_TOKENS", "200")) # replies are short if json_mode: payload["grammar"] = JSON_GRAMMAR # llama-server: constrain the output to one JSON object else: if json_mode: payload["response_format"] = {"type": "json_object"} 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 or "grammar" in body or "sampler" 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".*?", "", 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) # ---------- local model: compact prompt ---------- # CPU prefill costs ~13 ms per prompt token, so the local model gets its own short prompt (~900 tokens instead of ~3k): # a terse action list, a few examples, the default page text and a one-line-per-listing catalog with short refs # (L1, L2, ...; small models miscopy 8-hex ids) and dollar prices. That system message only changes when the catalog # does, so llama-server's prompt cache always hits; the per-visitor part (current look + message) is a few dozen tokens. LOCAL_PROMPT = """You run the page of Agent Market, a TEST DEMO marketplace (fake listings, test-mode payments). Answer the visitor with ONE JSON object: {"reply":"","actions":[]} ACTIONS show_cards {"heading":"...","listing_ids":["L1"]} listings that fit the request (respect budgets) show_compare {"heading":"...","listing_ids":["L1","L2"]} side by side show_checkout {"listing_id":"L1"} visitor BUYS a listing and pays online (+FEE_PERCENT fee) show_cash_meetup {"listing_id":"L1"} visitor BUYS a listing and pays cash in person (no fee) show_listing_form {"defaults":{"title":"...","kind":"product|service","price":,"payment_options":["online","cash"]}} visitor SELLS / LISTS their OWN thing (never a checkout): "$120" => 12000, "cash only" => ["cash"] set_theme {...} change how the page LOOKS or what its TEXT says. Payload keys: copy {"logo"|"headline"|"subhead"|"footer": "new text"} logo = site name in the TOP-LEFT corner, headline = big title preset "retro|minimal|neon|pastel|high contrast|sunset|ocean"; reset true = default look; mode "dark|light" palette {"accent"|"bg"|"text": "#hex"}; background {"kind":"gradient","colors":["#hex","#hex"]} font "serif|mono|Inter|Poppins|Lora|VT323|Comic Neue"; fontScale 0.75-1.75; radius 0-32; layout "grid|list" sections {"hidden":["hero"|"chips"|"bullets"|"results"|"footer"]}; chat {"position":"left|right"} RULES: listing ids only from LISTINGS. No ids in reply. A look or text request returns only set_theme. Never refuse a visual change. EXAMPLES change the name in the top left to Bazaar Hub => {"reply":"The logo now says Bazaar Hub.","actions":[{"type":"set_theme","payload":{"copy":{"logo":"Bazaar Hub"}}}]} make the big title say Hello neighbours => {"reply":"Headline updated.","actions":[{"type":"set_theme","payload":{"copy":{"headline":"Hello neighbours"}}}]} get rid of the bullet points => {"reply":"Bullet points hidden.","actions":[{"type":"set_theme","payload":{"sections":{"hidden":["bullets"]}}}]} go back to the original look => {"reply":"Back to the default look.","actions":[{"type":"set_theme","payload":{"reset":true}}]} light theme with an orange accent => {"reply":"Light mode, orange accent.","actions":[{"type":"set_theme","payload":{"mode":"light","palette":{"accent":"#f76b15"}}}]} one item per row => {"reply":"Results shown as a list.","actions":[{"type":"set_theme","payload":{"layout":"list"}}]} put the chat on the left => {"reply":"Chat moved left.","actions":[{"type":"set_theme","payload":{"chat":{"position":"left"}}}]} teal to purple gradient background => {"reply":"Gradient applied.","actions":[{"type":"set_theme","payload":{"background":{"kind":"gradient","colors":["#12a594","#8e4ec6"]}}}]} I want to sell my sofa for $60, cash only => {"reply":"Listing form ready, press Publish.","actions":[{"type":"show_listing_form","payload":{"defaults":{"title":"Sofa","kind":"product","price":6000,"payment_options":["cash"]}}}]} I'll buy the and pay online => {"reply":"Here's the checkout.","actions":[{"type":"show_checkout","payload":{"listing_id":""}}]} can I pay cash for the ? => {"reply":"Cash meetup, no fee.","actions":[{"type":"show_cash_meetup","payload":{"listing_id":""}}]} => {"reply":"Here's what fits.","actions":[{"type":"show_cards","payload":{"heading":"","listing_ids":[""]}}]} """.replace("FEE_PERCENT", f"{FEE_RATE*100:g}%") def _local_system_prompt(store): """Stable per catalog (no per-visitor data), so the prompt cache always hits.""" from .copyedit import current_copy c = current_copy({}, FEE_BPS) rows = [f"L{i} | {x['title']} | ${x['price_cents'] / 100:.2f} | {x['kind']} | {' '.join(x.get('pay') or [])}" for i, x in enumerate(_catalog(store), 1)] return (LOCAL_PROMPT + f'PAGE TEXT (defaults): logo "{c["logo"]}"; headline "{c["headline"]}"; footer "{c["footer"]}"\n' "LISTINGS (id | title | price | kind | pay)\n" + "\n".join(rows)) _BUDGET_RE = re.compile(r"(?:under|below|less than|max(?:imum)?|up to|cheaper than|<)\s*\$?\s*(\d+(?:\.\d+)?)", re.I) def _local_hints(store, message): """Retrieval help for the small model: the listings whose title/category/tags share a word with the message, within any budget it states ("under $100"). The model still decides what to show; semantic asks with no word in common ("something for my garden") get no hint and use the full LISTINGS.""" 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 "" m = _BUDGET_RE.search(message or "") cap = int(float(m.group(1)) * 100) if m else None hits = [] for i, c in enumerate(_catalog(store), 1): L = store.get("listings", c["id"]) or {} toks = {t.rstrip("s") for t in re.findall(r"[a-z]{3,}", " ".join([c["title"], c.get("category") or "", " ".join(L.get("tags") or [])]).lower())} if words & toks and (cap is None or c["price_cents"] <= cap): hits.append(f"L{i} {c['title']} ${c['price_cents'] / 100:.2f}") return ("MATCHING LISTINGS (same words" + (", within budget" if cap is not None else "") + "): " + "; ".join(hits[:6]) + "\n") if hits else "" def _local_user(message, look="", store=None): """Per-visitor part (a few dozen tokens): their look overrides (if any), listing hints, and the message.""" head = "" if look and look.strip() not in ("", "{}"): head = "CURRENT LOOK (this visitor's overrides): " + look.strip()[:400] + "\n" if store is not None: head += _local_hints(store, message) return head + "VISITOR: " + message ALIAS_RE = re.compile(r"\s*\(?\bL\d{1,3}\b\)?:?") def _aliases(store): """ref -> real listing id, in catalog order (stable while the catalog is unchanged, so the prompt stays cacheable).""" return {f"L{i}": c["id"] for i, c in enumerate(_catalog(store), 1)} def _unalias(obj, alias): """Map L-refs back to real listing ids in action payloads / tool args (in place).""" if isinstance(obj, dict): for k, v in list(obj.items()): if k == "listing_id" and isinstance(v, str): obj[k] = alias.get(v.strip(), v) elif k == "listing_ids" and isinstance(v, list): obj[k] = [alias.get(x.strip(), x) if isinstance(x, str) else x for x in v] else: _unalias(v, alias) elif isinstance(obj, list): for x in obj: _unalias(x, alias) return obj def _system_prompt(store, look=""): """Hosted models: the full schema, the catalog with real ids, the visitor's look and page text.""" 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, "decodes": None} def _local_restarted(local): """True when the local server is down or was restarted since we last looked (its llama-server /metrics counter went back down), so its prompt cache is cold again and must be re-primed. Unknown servers (no /metrics) -> False.""" root = local["url"].split("/v1/")[0] try: with urllib.request.urlopen(root + "/metrics", timeout=2) as r: m = re.search(r"^llamacpp:n_decode_total (\d+)", r.read().decode(errors="replace"), re.M) except urllib.error.HTTPError: return False except Exception: _PRIMED["decodes"] = None return True if not m: return False n, last = int(m.group(1)), _PRIMED["decodes"] _PRIMED["decodes"] = n return last is not None and n < last 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 llama-server's prompt cache instead of paying ~30 s of CPU prompt processing.""" local = _local_config() if not local: return False if _local_restarted(local): _PRIMED["key"] = None sp = _local_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": _local_user("hi")}], "max_tokens": 1, "temperature": 0} 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 _local_restarted(local) # remember the counter after our own call return True except Exception: return False finally: _local_slots.release() def start_local_primer(store, every=30): """Background thread: prime at start-up, whenever the catalog (and so the prompt) changes, and after the local server restarts. Between those it only reads /metrics every 30 s, so an idle server stays idle.""" 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): local = cfg.get("local") is True if local: msgs = [{"role": "system", "content": _local_system_prompt(store)}] else: msgs = [{"role": "system", "content": _system_prompt(store, look)}] 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": _local_user(message, look, store) if local else message}) t_end = time.time() + (timeout or LLM_TIMEOUT) alias = _aliases(store) if local else {} content = _call_llm(cfg, model, msgs, timeout=timeout) plan = _unalias(_parse_agent_json(content), alias) tool_results = [] tools = [t for t in (plan.get("tools") or []) if isinstance(t, dict)][:3] if tools: tool_results = run_tools(store, tools) tr = json.dumps(tool_results) for ref, real in alias.items(): tr = tr.replace(real, ref) msgs += [{"role": "assistant", "content": content}, {"role": "user", "content": "TOOL_RESULTS:\n" + tr[: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 = _unalias(_parse_agent_json(_call_llm(cfg, model, msgs, timeout=left)), alias) 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() if local: reply = ALIAS_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 def _respect_budget(store, message, actions): """Visitor said "under $X": drop listed picks above it when some within budget remain (small models overshoot).""" m = _BUDGET_RE.search(message or "") if not m: return actions cap = int(float(m.group(1)) * 100) for a in actions: if a.get("type") == "show_cards" and isinstance(a.get("payload"), dict): ids = [i for i in (a["payload"].get("listing_ids") or []) if isinstance(i, str)] keep = [i for i in ids if (store.get("listings", i) or {}).get("price", cap + 1) <= cap] 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 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 model with its own timeout. Raises if everything fails (the visitor gets BUSY_REPLY).""" 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"] = _respect_budget(store, message, _focus_results(store, message, out["actions"])) 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 busy_reply(errors=None, err="no_model", ms=0): """Every model failed (or none is configured): say so honestly. Typed messages never get rule-based answers.""" return {"reply": BUSY_REPLY, "actions": [], "agent": "unavailable", "busy": True, "retry": True, "llm_error": err, "fallbacks": errors or [], "latency_ms": ms} def handle_chat(store, message: str, history=None, event=None, actor="demo-buyer", look=""): actor = (actor or "demo-buyer")[:60] t0 = time.time() if event and (event.get("type") == "landing" or not LLM_EVENTS): out = featured_listings(store) if event.get("type") == "landing" else fallback_event(store, event, actor) out.update(agent="ui-event", latency_ms=int((time.time() - t0) * 1000)) return out cfg = _llm_config() if not cfg: if event: # AGENT_MARKET_LLM_EVENTS=1 but no model: clicks still work deterministically out = fallback_event(store, event, actor) out.update(agent="ui-event", latency_ms=int((time.time() - t0) * 1000)) return out return busy_reply(err="no_model_configured", ms=int((time.time() - t0) * 1000)) 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: kind = getattr(e, "kind", type(e).__name__) errors = json.loads(str(e).split(": ", 1)[1]) if kind == "all_models_failed" else [] if event: out = fallback_event(store, event, actor) out.update(agent="ui-event", llm_error=kind, fallbacks=errors, latency_ms=int((time.time() - t0) * 1000)) return out return busy_reply(errors, kind, int((time.time() - t0) * 1000)) _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": "unavailable", "provider": None, "model": None, "chain": [], "note": "No model configured (set AGENT_MARKET_LLM_API_KEY_FILE + AGENT_MARKET_LLM_BASE_URL + AGENT_MARKET_LLM_MODELS " "and/or AGENT_MARKET_LOCAL_LLM_URL + AGENT_MARKET_LOCAL_LLM_MODEL). Typed messages get the busy reply; " "clicks, forms and payments still work."}