From 70f5b0525ee0acf9ddbe3c660c6cf59eee212aa0 Mon Sep 17 00:00:00 2001 From: leora Date: Thu, 8 Oct 2026 22:37:57 +0800 Subject: [PATCH 1/2] Local model: compact prompt (1.2k tokens, same for every visitor) so typed replies take ~6 s instead of timing out Root cause of the 45 s timeouts: the local model got the full hosted prompt (~3.1k tokens with examples). Any prompt-cache miss meant ~40 s of CPU prefill on the single slot, and every request queued behind one paid it too. - LOCAL_PROMPT/_local_system_prompt: terse action list, one-line examples, default page text, one line per listing (L-refs, dollar prices). No per-visitor data in it, so the cache always hits; it only changes with the catalog. - _local_user: look overrides (if any) + keyword/budget MATCHING LISTINGS hint + 'VISITOR: '. - max_tokens 200 for the local model; over-budget card picks dropped when some fit (_respect_budget). - Buy vs sell examples separated (sell my own thing -> listing form, never checkout). - 18/18 correct on typical prompts, warm median 5.9 s, max 7.7 s; cold prefill ~13 s. --- README.md | 30 +++++--- market/agent.py | 143 ++++++++++++++++++++++++++---------- tests/mock_llm.py | 4 +- tests/test_agent_units.py | 14 +++- tests/test_copy_defaults.py | 4 +- 5 files changed, 137 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 08504a1..8aa5344 100644 --- a/README.md +++ b/README.md @@ -120,14 +120,19 @@ never get rule-based answers. The header badge shows the model that answered. ### Local model (last link of the chain) A small model runs on the box with llama.cpp's `llama-server` (the build that ships inside Ollama v0.40.1 under `~/ollama/lib/ollama/`; any llama.cpp build works). The server tries it after the hosted chain, through the same -OpenAI-compatible endpoint (`/v1/chat/completions`), with no key. For the local model the request differs in three ways: -- a GBNF grammar (`JSON_GRAMMAR` in `market/agent.py`) constrains the output to one JSON object; -- the system prompt adds a block of worked examples (`LOCAL_EXAMPLES`: text edits, hide sections, reset, light mode, - list layout, chat position, fonts, gradients, presets, a listing form). It sits before the catalog, so it's cached; -- the visitor's message is labelled (`VISITOR MESSAGE ...`), because Gemma's chat template folds the system prompt into the - first user turn. +OpenAI-compatible endpoint (`/v1/chat/completions`), with no key. -No chat history is sent to it by default (small models copy earlier answers). +**It gets its own compact prompt**, because CPU prefill costs about 13 ms per prompt token: +- `LOCAL_PROMPT` + `_local_system_prompt()`: a terse action list, a dozen one-line examples, the default page text and a + one-line-per-listing catalog with short refs (`L1`, `L2`, ...; small models miscopy 8-hex ids, the server maps refs + back) and dollar prices. About 1.2k tokens instead of about 3k, and it is the same for every visitor, so + llama-server's prompt cache always hits. It only changes when the catalog does (the primer re-warms it then). +- `_local_user()`: the per-visitor part (a few dozen tokens): the visitor's look overrides if any, a + `MATCHING LISTINGS` hint (listings sharing a word with the message, within any stated budget), and the message. +- A JSON GBNF grammar, `max_tokens` 200, no chat history and no tool round. Over-budget picks are dropped when some fit. + +Measured on the demo catalog (12 typical prompts, warm): 12/12 correct, median 5.9 s, max 7.7 s. A turn right +after a catalog change, before the primer runs, pays about 13 s of prefill once. | Variable | Meaning | |---|---| @@ -137,6 +142,7 @@ No chat history is sent to it by default (small models copy earlier answers). | `AGENT_MARKET_LLM_TOTAL_BUDGET` | hosted chain + local model per turn (60 s); the local model gets `min(45, max(25, 60 - time already spent))` | | `AGENT_MARKET_LOCAL_LLM_CONCURRENCY` | 2 = one running + one queued; more simultaneous turns get the busy reply instead of waiting | | `AGENT_MARKET_LOCAL_LLM_HISTORY` | chat turns sent to the local model (0) | +| `AGENT_MARKET_LOCAL_LLM_MAX_TOKENS` | output cap for the local model (200; replies are one sentence + one action) | | `AGENT_MARKET_LOCAL_LLM_PRIME` | 1 = warm the prompt cache in the background (see below) | `deploy.sh` loads these from `~/.config/agent-market/local-llm.env`. The behaviour: @@ -160,14 +166,16 @@ No chat history is sent to it by default (small models copy earlier answers). `~/llm/gemma3-4b-it-Q4_K_M.gguf`. - **Resources (measured on this box: 8 vCPU, no GPU, 16 GB RAM).** Idle: 0% CPU, 2.9 GB RSS, of which 0.6 GB is private and 2.4 GB is the mmapped model file (clean page cache the kernel can reclaim). While answering: about 6 cores at nice 10 - for 4–13 s, RSS unchanged. -- **Latency (real chain, handle_chat, warm cache).** Exact logo phrase 5 s, other text and look edits 4–6 s, search 9 s, - compare 10 s, prefilled listing form 13 s (10/10 correct). Cold (no primer yet): about 43 s for the first turn. + for 4–8 s, RSS unchanged. +- **Latency (real chain, handle_chat, warm cache, compact prompt).** Exact logo phrase 4.5 s, look edits 4–6 s, search + 6 s, compare 6 s, checkout 4 s, cash meetup 4.5 s, prefilled listing form 7–8 s. Cold prefill of the 1.2k-token + prompt: about 13 s, once per catalog change. The single slot is shared by demo and staging, so a staging test run + makes a demo visitor wait for at most one other reply. - **Model choice.** Benchmarked on this box with the real prompt and catalog: | model | correct | warm latency | notes | |---|---|---|---| - | **gemma3:4b** (chosen) | 15/15 (Ollama), 10/10 (llama-server + examples) | 4–13 s, cold 30–45 s | 2.9 GB RSS | + | **gemma3:4b** (chosen) | 18/18 (compact prompt), 15/15 (Ollama, long prompt) | 4–8 s, cold about 18 s | 2.9 GB RSS | | qwen2.5:3b | 7/15 | 3–8 s | dropped actions, invented ids, flat payloads | | phi4-mini | 4/5 | 10–25 s | missed the logo edit | | qwen3:4b (no think) | 4/5 | 12–22 s | slow, missed the logo edit | diff --git a/market/agent.py b/market/agent.py index cc90949..32041bd 100644 --- a/market/agent.py +++ b/market/agent.py @@ -412,7 +412,7 @@ 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"] = 700 + 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: @@ -483,30 +483,84 @@ def _page_text(look): "chips": [x.get("label") for x in c["chips"]], "bullets": c["bullets"]}, ensure_ascii=False) -LOCAL_EXAMPLES = """ -EXAMPLES (visitor message => your whole answer). Follow the pattern; use the visitor's own words and CATALOG ids. -"change the name in the top left to Bazaar Hub" => {"reply":"Done: the top-left logo now says Bazaar Hub.","actions":[{"type":"set_theme","payload":{"copy":{"logo":"Bazaar Hub"}}}]} -"swap Agent Market for Corner Shop in the header" => {"reply":"The header now says Corner Shop.","actions":[{"type":"set_theme","payload":{"copy":{"logo":"Corner Shop"}}}]} -"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 please, with an orange accent" => {"reply":"Light mode with an orange accent.","actions":[{"type":"set_theme","payload":{"mode":"light","palette":{"accent":"#f76b15"}}}]} -"one item per row instead of a grid" => {"reply":"Results now show as a list.","actions":[{"type":"set_theme","payload":{"layout":"list"}}]} -"put the chat on the left side" => {"reply":"Chat moved to the left.","actions":[{"type":"set_theme","payload":{"chat":{"position":"left"}}}]} -"use a mono font, slightly larger" => {"reply":"Mono font, a bit larger.","actions":[{"type":"set_theme","payload":{"font":"mono","fontScale":1.15}}]} -"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":[""]}}]} -"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). -""" +# ---------- 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 -# 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\)?:?") @@ -531,12 +585,9 @@ def _unalias(obj, 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 - 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] + +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)) @@ -571,12 +622,12 @@ def _prime_local_once(store): return False if _local_restarted(local): _PRIMED["key"] = None - sp = _system_prompt(store, "", local=True) + 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": _visitor("hi")}], + 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: @@ -606,19 +657,16 @@ def start_local_primer(store, every=30): threading.Thread(target=loop, name="local-llm-primer", daemon=True).start() -def _visitor(message): - # small models (and chat templates that fold the system prompt into the first user turn) need a clear boundary - return "\n\nVISITOR MESSAGE (answer this one with the JSON object):\n" + message - - def _llm_once(store, cfg, model, message, history, look="", timeout=None): local = cfg.get("local") is True - sys_prompt = _system_prompt(store, look, local=local) - msgs = [{"role": "system", "content": sys_prompt}] + 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": _visitor(message) if local else message}) + 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) @@ -673,6 +721,21 @@ def _focus_results(store, message, actions): 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 @@ -740,7 +803,7 @@ def llm_agent(store, message, history, cfg, look=""): 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"]) + 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) diff --git a/tests/mock_llm.py b/tests/mock_llm.py index 0c0aa04..7ec95c5 100755 --- a/tests/mock_llm.py +++ b/tests/mock_llm.py @@ -25,7 +25,7 @@ class H(BaseHTTPRequestHandler): SEEN["auth"][mdl] = SEEN["auth"].get(mdl, 0) + 1 if body.get("grammar"): SEEN["grammar"][mdl] = SEEN["grammar"].get(mdl, 0) + 1 - if "EXAMPLES" in body["messages"][0]["content"] and "VISITOR MESSAGE" in body["messages"][-1]["content"]: + if "EXAMPLES" in body["messages"][0]["content"] and "VISITOR: " in body["messages"][-1]["content"]: SEEN["local_prompt"][mdl] = SEEN["local_prompt"].get(mdl, 0) + 1 if body.get("model") == "daily-model": b = json.dumps({"error": {"message": "Rate limit exceeded: free-models-per-day.", "code": 429, @@ -36,7 +36,7 @@ class H(BaseHTTPRequestHandler): if "new internet" in last.lower(): # answers like a model that read the prompt: it only knows "top left" = copy.logo if the prompt says so sysmsg = body["messages"][0]["content"] - knows = "TOP-LEFT" in sysmsg and '"logo": "Agent Market"' in sysmsg + knows = "TOP-LEFT" in sysmsg and ('"logo": "Agent Market"' in sysmsg or 'logo "Agent Market"' in sysmsg) out = ({"reply": "Renamed the top-left logo to New Internet.", "actions": [{"type": "set_theme", "payload": {"copy": {"logo": "New Internet"}}}]} if knows else {"reply": "I can't change that text.", "actions": []}) b = json.dumps({"choices": [{"message": {"role": "assistant", "content": json.dumps(out)}}]}).encode() diff --git a/tests/test_agent_units.py b/tests/test_agent_units.py index 6999e26..30ea7f8 100644 --- a/tests/test_agent_units.py +++ b/tests/test_agent_units.py @@ -39,13 +39,21 @@ 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)) +sp = agent._local_system_prompt(st) +check("local prompt: compact, short refs + dollar prices, no hex ids, no per-visitor data", + "\nL1 | " in sp and not re.search(r"lst_[0-9a-f]{6}", sp) and "$" in sp.split("LISTINGS")[1] and "CURRENT LOOK" not in sp and len(sp) < 6000, len(sp)) +check("local prompt is the same for every visitor (prompt cache hits)", sp == agent._local_system_prompt(st)) +u = agent._local_user("show me bikes under $100", '{"mode":"light"}', st) +check("per-visitor part: look, budget-aware hints, message", u.startswith("CURRENT LOOK") and "MATCHING LISTINGS" in u and "within budget" in u and u.endswith("VISITOR: show me bikes under $100"), u) +check("no hint for a text edit", "MATCHING" not in agent._local_user("replace agent market to new internet on the top left corner", "", st)) 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)) +check("hosted prompt keeps real ids", al["L1"] in agent._system_prompt(st, "")) +cheap = min(st.all("listings"), key=lambda L: L["price"]); dear = max(st.all("listings"), key=lambda L: L["price"]) +b = agent._respect_budget(st, f"anything under ${cheap['price'] // 100 + 1}", [{"type": "show_cards", "payload": {"listing_ids": [dear["id"], cheap["id"]]}}]) +check("budget: over-budget picks dropped when some fit", b[0]["payload"]["listing_ids"] == [cheap["id"]], b) # primer: re-prime after the local server restarts (llama-server /metrics counter goes back down) or is down import http.server, threading diff --git a/tests/test_copy_defaults.py b/tests/test_copy_defaults.py index 1b1c115..ef2f18c 100644 --- a/tests/test_copy_defaults.py +++ b/tests/test_copy_defaults.py @@ -18,8 +18,8 @@ check("DEFAULT_COPY in sync with web/src/App.tsx", ok) check("model prompt explains top-left = logo and includes PAGE_TEXT", "TOP-LEFT" in agent.UI_SCHEMA and '"logo": "Agent Market"' in agent._page_text("{}")) check("PAGE_TEXT follows the visitor's overrides", '"logo": "New Internet"' in agent._page_text('{"copy":{"logo":"New Internet"}}')) check("fee in bullets follows FEE_BPS", current_copy({}, 250)["bullets"][0].startswith("2.5% ")) -check("local prompt carries examples (incl. a top-left text edit), hosted prompt doesn't", - "EXAMPLES" in agent.LOCAL_EXAMPLES and '"copy":{"logo"' in agent.LOCAL_EXAMPLES and "EXAMPLES (" not in agent.UI_SCHEMA) +check("compact local prompt maps TOP-LEFT = logo and has a logo example; hosted prompt has no examples block", + "TOP-LEFT" in agent.LOCAL_PROMPT and '"copy":{"logo"' in agent.LOCAL_PROMPT and "EXAMPLES" not in agent.UI_SCHEMA) gone = [n for n in ("fallback_agent", "_rules_check") if hasattr(agent, n)] import market.theme as th, market.copyedit as ce gone += [n for n in ("parse_look_command",) if hasattr(th, n)] + [n for n in ("parse_copy_edit",) if hasattr(ce, n)] From c8e1c162ee323b81d1cbe6f3a1d934c82bfcbc37 Mon Sep 17 00:00:00 2001 From: leora Date: Thu, 8 Oct 2026 22:43:54 +0800 Subject: [PATCH 2/2] e2e: agent API search with a higher limit (staging accumulates test listings) --- tests/e2e_http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e_http.py b/tests/e2e_http.py index 4f08739..f979bb2 100755 --- a/tests/e2e_http.py +++ b/tests/e2e_http.py @@ -124,7 +124,7 @@ B = {"X-Agent-Id": "agent:e2e-buyer-bot"} s, r = call("POST", "/api/actions", {"action": "create_listing", "args": {"title": "Test-only data labelling (agent service)", "kind": "service", "price": 1500, "description": "Fake service listed by a test agent"}}, A) al = r.get("listing", {}) check("agent API: agent lists a service", s == 201 and al.get("seller") == "agent:e2e-seller-bot" and al.get("seller_type") == "agent") -s, r = call("POST", "/api/actions", {"action": "search", "args": {"q": "labelling"}}, B) +s, r = call("POST", "/api/actions", {"action": "search", "args": {"q": "labelling", "limit": 50}}, B) # staging accumulates test listings check("agent API: search finds it", any(x["id"] == al.get("id") for x in r.get("listings", []))) s, r = call("POST", "/api/actions", {"action": "quote", "args": {"listing_id": al["id"]}}, B) check("agent API: quote online vs cash", r["online"]["fee_cents"] == round(1500 * fee_bps / 10000) and r["cash_in_person"]["fee_cents"] == 0)