diff --git a/market/agent.py b/market/agent.py index 0c922bf..cc90949 100644 --- a/market/agent.py +++ b/market/agent.py @@ -497,14 +497,46 @@ EXAMPLES (visitor message => your whole answer). Follow the pattern; use the vis "a teal to purple gradient behind everything" => {"reply":"Teal to purple gradient background.","actions":[{"type":"set_theme","payload":{"background":{"kind":"gradient","colors":["#12a594","#8e4ec6"],"angle":135}}}]} "make it neon" => {"reply":"Neon look applied.","actions":[{"type":"set_theme","payload":{"preset":"neon"}}]} "I'd like to sell my old sofa for $60, cash only" => {"reply":"I've filled in the listing form. Check it and press Publish.","actions":[{"type":"show_listing_form","payload":{"defaults":{"title":"Old sofa","kind":"product","price":6000,"description":"Used sofa in good condition.","category":"home","area":"Demo Town","payment_options":["cash"]}}}]} -"" => {"reply":"","actions":[{"type":"show_cards","payload":{"heading":"","listing_ids":[""]}}]} +"" => {"reply":"","actions":[{"type":"show_cards","payload":{"heading":"","listing_ids":[""]}}]} +"I'll buy and pay online" => {"reply":"Here's the checkout (price + fee).","actions":[{"type":"show_checkout","payload":{"listing_id":""}}]} +"can I pay cash for in person?" => {"reply":"Cash meetup set up, no fee.","actions":[{"type":"show_cash_meetup","payload":{"listing_id":"","suggested_place":"Demo Town square","suggested_time":"Saturday 10am"}}]} Only change what the visitor asked for. A visual or text request returns set_theme only (no listings). """ +# Small models miscopy 8-hex ids (lst_ffe42107 vs lst_29d7f320), so the local model sees short catalog refs (L1, L2, ...) +# that are mapped back to real ids before anything runs. Unknown refs are dropped like any invented id. +LOCAL_SCHEMA = UI_SCHEMA.replace('"lst_..."', '"L7"').replace("internal ids (lst_...,", "internal ids (L7, lst_...,") +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="", local=False): # stable parts first (schema, examples, catalog) so a local model's prompt cache can reuse them across turns - return (UI_SCHEMA + (LOCAL_EXAMPLES if local else "") + "\nCATALOG (all active listings):\n" + json.dumps(_catalog(store))[:9000] + + cat = _catalog(store) + if local: + cat = [{**c, "id": f"L{i}"} for i, c in enumerate(cat, 1)] + return ((LOCAL_SCHEMA + LOCAL_EXAMPLES if local else UI_SCHEMA) + "\nCATALOG (all active listings):\n" + json.dumps(cat)[: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)) @@ -588,24 +620,30 @@ def _llm_once(store, cfg, model, message, history, look="", timeout=None): msgs.append({"role": h["role"], "content": str(h["content"])[:600]}) msgs.append({"role": "user", "content": _visitor(message) 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 = _parse_agent_json(content) + 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" + json.dumps(tool_results)[:6000] + + {"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 = _parse_agent_json(_call_llm(cfg, model, msgs, timeout=left)) + 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} diff --git a/tests/mock_llm.py b/tests/mock_llm.py index 406faaf..0c0aa04 100755 --- a/tests/mock_llm.py +++ b/tests/mock_llm.py @@ -51,7 +51,7 @@ class H(BaseHTTPRequestHandler): self.send_response(200); self.send_header("Content-Type", "application/json"); self.end_headers(); self.wfile.write(b) return if last.startswith("TOOL_RESULTS"): - ids = re.findall(r'"id": "(lst_[a-f0-9]+)"', last)[:3] + ids = re.findall(r'"id": "(lst_[a-f0-9]+|L\d+)"', last)[:3] # local model sees short refs (L1, L2, ...) out = {"reply": "Here are bikes I found (mock LLM). Payments are TEST MODE ONLY.", "actions": [{"type": "show_cards", "payload": {"heading": "Bikes", "listing_ids": ids}}, {"type": "not_allowed_component", "payload": {}}]} diff --git a/tests/test_agent_units.py b/tests/test_agent_units.py index d34051b..6999e26 100644 --- a/tests/test_agent_units.py +++ b/tests/test_agent_units.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """Unit checks for the model-output normalisers used with the local backup model (no server, no model).""" -import os, sys, tempfile +import os, re, sys, tempfile sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) os.environ["AGENT_MARKET_DATA"] = tempfile.mkdtemp() for k in ("AGENT_MARKET_LLM_API_KEY_FILE", "AGENT_MARKET_LOCAL_LLM_URL"): @@ -37,6 +37,16 @@ check("no hosted key and no local model -> no chain (typed messages get the busy os.environ.update(AGENT_MARKET_LOCAL_LLM_URL="http://127.0.0.1:9/v1", AGENT_MARKET_LOCAL_LLM_MODEL="m") cfg = agent._llm_config() check("local model alone is a valid chain", cfg and cfg["models"] == [] and cfg["local"]["model"] == "m" and cfg["local"]["url"].endswith("/v1/chat/completions")) +# local model: short catalog refs in the prompt, mapped back to real ids; unknown refs left for hydrate() to drop +al = agent._aliases(st) +sp = agent._system_prompt(st, "", local=True) +check("local prompt uses short refs, not hex ids", '"id": "L1"' in sp and not re.search(r"lst_[0-9a-f]{6}", sp.split("CATALOG (all active listings)")[1]) and '"L7"' in sp, len(al)) +plan = agent._unalias({"actions": [{"type": "show_cards", "payload": {"listing_ids": ["L1", "L999"]}}, + {"type": "show_checkout", "payload": {"listing_id": "L2"}}]}, al) +check("refs mapped back (unknown kept for hydrate to drop)", plan["actions"][0]["payload"]["listing_ids"] == [al["L1"], "L999"] and + plan["actions"][1]["payload"]["listing_id"] == al["L2"]) +check("hosted prompt keeps real ids", al["L1"] in agent._system_prompt(st, "", local=False)) + # primer: re-prime after the local server restarts (llama-server /metrics counter goes back down) or is down import http.server, threading M = {"n": 500}