diff --git a/.claude-plugin/plugins/leadbay/skills/leadbay_followup_check_in/SKILL.md b/.claude-plugin/plugins/leadbay/skills/leadbay_followup_check_in/SKILL.md index 13618ba4..e954a3ca 100644 --- a/.claude-plugin/plugins/leadbay/skills/leadbay_followup_check_in/SKILL.md +++ b/.claude-plugin/plugins/leadbay/skills/leadbay_followup_check_in/SKILL.md @@ -50,7 +50,38 @@ Call `leadbay_pull_followups` (NOT `leadbay_pull_leads` — those are different For geo filters specifically: prefer the `city` shortcut on `leadbay_pull_followups({city: "Berlin"})` — the composite resolves the free-text city via `/geo/search`, returns ambiguities to disambiguate when needed (status: "ambiguous_locations" → pick an id → re-call with `city_id`), then merges the resolved admin_area into the Monitor filter as `location_ids`. If the user has already given you a numeric id, pass it as `city_id`. Don't guess admin_area ids — let the resolver do it. -**TRAVEL / IN-PERSON ROUTING** — when the user's intent is geographic and visual ("I'm going to NYC next week", "leads I should visit in person", "this week's trip", "show me followups in ", "plan my itinerary", "trip itinerary", "show on a map", "leads in Texas / California / France", or any phrasing that asks for a map / geographic / trip-planning view — INCLUDING state-, country-, and region-level place names): +**One workspace = one country — a country name is NEVER a location filter.** The admin-area index holds no country nodes, so `"France"` matches the *commune of Francs* and `"United States"` matches *Statesboro*: the call is silently fenced to one village and every conclusion from it is wrong. City AND country named? Keep the city, drop the country. + +**On `code: "COUNTRY_LEVEL_LOCATION"` read `country_locations[].axis` and `[].kind` — the recovery differs per case and they are NOT interchangeable, and do NOT retry with another spelling or a nearby city.** + +`axis: "include"`: + +- `home_country`, or "nationwide" / "everywhere" → drop that ONE value. Omit the geo argument (`city` / `locations` / `location_ids`) only if nothing else was on it — then the result covers the whole workspace. If other values remain, keep them and describe the result as those places. +- `foreign_country` ("leads in France" on a US workspace) → **unsupported, not unfiltered.** Do NOT re-run without the argument: whole-workspace results are US leads and answer nothing about France. Say the workspace holds only its own country's companies. +- `supranational` ("EU", "EMEA") → name what the workspace covers, then offer the whole-workspace view as an explicit choice rather than assuming it. +- `country_indeterminate` (custom/staging backend) → its country is unknown, so claim nothing about what it holds. + +`axis: "exclude"` reverses all of that — **never "omit the argument"**, which returns the very companies the user asked to remove. Excluding this workspace's own country would empty it; excluding any other country is a harmless no-op. Either way drop the value and ask what to carve out instead. + +On a lens-WRITING tool (`new_lens`, `adjust_audience`, `update_lens_filter`) write NOTHING, with no re-call in any form: when the country was the only scope, and for ANY non-`foreign_country` `exclude` hit however much else came with it — dropping it and writing the rest inverts the ask. + +**Never infer WHICH country this workspace serves from the user's wording** — "the whole US" does not make it one. Read `_meta.region` on any tool result — it outranks any recalled memory; on `custom`, claim nothing. + +Place names never go in `keywords`, `sectors` or `refine_prompt` — text matches, not geo filters. + + +⚠ **On this prompt, omitting the geo argument is only HALF of a whole-workspace read.** +`leadbay_pull_followups` defaults `filtered` to true, so dropping the country still reads +the Monitor view through whatever filter a previous session persisted — an old city +filter comes back as a small, plausible cohort and you would report it as everything. +When the ask covers the whole workspace and nothing else was requested, pass +`filtered:false`. When other criteria WERE requested (a sector, a recency window, a real +city), re-send those in `set_filter` instead — that overwrites the stored filter, so +`filtered:false` would throw away the very scope I asked for. Either way, read +`active_filters` off the response and describe the scope from THAT, not from what you +intended to send. + +**TRAVEL / IN-PERSON ROUTING** — when the user's intent is geographic and visual ("I'm going to NYC next week", "leads I should visit in person", "this week's trip", "show me followups in ", "plan my itinerary", "trip itinerary", "show on a map", "leads in Texas / California", or any phrasing that asks for a map / geographic / trip-planning view — INCLUDING state- and region-level place names, but NEVER a country): 1. Call **`leadbay_followups_map`** (same params as `pull_followups`: `city` / `city_id` / `set_filter`). Same response shape — just the explicit entry-point so the agent and the host know to route geographically. 2. Output a **per-lead place-card block** for each top follow-up, in this exact format — modern chat hosts (Claude / cowork) detect addresses + company names and surface them as a beautiful Google-Place-card carousel with our notes as the "Notes from Claude" section. Lean INTO that surface; don't fight it. @@ -84,16 +115,16 @@ Markdown table with FOUR columns, sorted by `last_monitor_action_at` desc. **NO **Active-filters line** ABOVE the table, ` · `-separated chips from `active_filters.criteria`: -| Criterion type | Chip | -|-----------------------|----------------------------| -| `location_ids` | 📍 \ | -| `sector_ids` | 🏷 \ | -| `keywords` | 🔍 \ | -| `size` | 👥 \–\ | -| `last_action_date` | 📅 \ | -| `last_action` | 🎯 \ | -| `liked` / `yc` | ⭐ liked / 🏅 YC | -| `custom_field*` | ⚙ \ | +| Criterion type | Chip | +| --- | --- | +| `location_ids` | 📍 \ | +| `sector_ids` | 🏷 \ | +| `keywords` | 🔍 \ | +| `size` | 👥 \–\ | +| `last_action_date` | 📅 \ | +| `last_action` | 🎯 \ | +| `liked` / `yc` | ⭐ liked / 🏅 YC | +| `custom_field*` | ⚙ \ | Render `*No filters applied.*` when empty. diff --git a/.claude-plugin/plugins/leadbay/skills/leadbay_plan_tour_in_city/SKILL.md b/.claude-plugin/plugins/leadbay/skills/leadbay_plan_tour_in_city/SKILL.md index c2463f9d..d39b165b 100644 --- a/.claude-plugin/plugins/leadbay/skills/leadbay_plan_tour_in_city/SKILL.md +++ b/.claude-plugin/plugins/leadbay/skills/leadbay_plan_tour_in_city/SKILL.md @@ -4,7 +4,7 @@ description: "Use whenever the user names a city they'll be in and asks who to s --- -Plan a field sales tour for me in **** on '.>. +Plan a field sales tour for me in **** on '.>. GATE — DEFER TO TOOL RENDERING. When you call a Leadbay composite that ships its own RENDERING block (every composite in 0.9.0+ does), render the response using that block's recipe verbatim — score bars, glyph palette, column order, hide-list, link priorities, all of it. Do NOT substitute prose, a numbered list, or a different column structure even when an orchestrating prompt's body suggests alternate framing. Prompt-specific commentary (motivational nudges, summaries, next-action recommendations) belongs ABOVE or BELOW the canonical table, never in place of it. @@ -28,7 +28,29 @@ Map my answers to the `leadbay_tour_plan` call: # PHASE 2 — BUILD THE ITINERARY -Call `leadbay_tour_plan({city: "", …scope from PHASE 1})`. If the response is `status: "ambiguous_locations"`, surface the candidates and ask me to pick one, then re-call with `city_id`. +**One workspace = one country — a country name is NEVER a location filter.** The admin-area index holds no country nodes, so `"France"` matches the *commune of Francs* and `"United States"` matches *Statesboro*: the call is silently fenced to one village and every conclusion from it is wrong. City AND country named? Keep the city, drop the country. + +**On `code: "COUNTRY_LEVEL_LOCATION"` read `country_locations[].axis` and `[].kind` — the recovery differs per case and they are NOT interchangeable, and do NOT retry with another spelling or a nearby city.** + +`axis: "include"`: + +- `home_country`, or "nationwide" / "everywhere" → drop that ONE value. Omit the geo argument (`city` / `locations` / `location_ids`) only if nothing else was on it — then the result covers the whole workspace. If other values remain, keep them and describe the result as those places. +- `foreign_country` ("leads in France" on a US workspace) → **unsupported, not unfiltered.** Do NOT re-run without the argument: whole-workspace results are US leads and answer nothing about France. Say the workspace holds only its own country's companies. +- `supranational` ("EU", "EMEA") → name what the workspace covers, then offer the whole-workspace view as an explicit choice rather than assuming it. +- `country_indeterminate` (custom/staging backend) → its country is unknown, so claim nothing about what it holds. + +`axis: "exclude"` reverses all of that — **never "omit the argument"**, which returns the very companies the user asked to remove. Excluding this workspace's own country would empty it; excluding any other country is a harmless no-op. Either way drop the value and ask what to carve out instead. + +On a lens-WRITING tool (`new_lens`, `adjust_audience`, `update_lens_filter`) write NOTHING, with no re-call in any form: when the country was the only scope, and for ANY non-`foreign_country` `exclude` hit however much else came with it — dropping it and writing the rest inverts the ask. + +**Never infer WHICH country this workspace serves from the user's wording** — "the whole US" does not make it one. Read `_meta.region` on any tool result — it outranks any recalled memory; on `custom`, claim nothing. + +Place names never go in `keywords`, `sectors` or `refine_prompt` — text matches, not geo filters. + + +**Gate before calling.** If `` is a country name or a supra-national scope rather than a city, do NOT call `leadbay_tour_plan` with it — a tour of an entire country is not an itinerary, and the value would resolve to a same-named village. Tell me the workspace already covers one country and ask which city or region I'm actually visiting. Otherwise: + +Call `leadbay_tour_plan({city: "", …scope from PHASE 1})`. If the response is `status: "ambiguous_locations"`, surface the candidates and ask me to pick one, then re-call with `city_id`. If it is `status: "country_level_location"`, do NOT retry with a spelling variant and do NOT re-call without `city` — a tour with no city is arbitrary nationwide leads, not an itinerary. Ask me which city or region I am visiting. Split the returned `monitor_leads` into two buckets client-side using their engagement-history fields: diff --git a/.claude-plugin/plugins/leadbay/skills/leadbay_refine_audience/SKILL.md b/.claude-plugin/plugins/leadbay/skills/leadbay_refine_audience/SKILL.md index 189f7a3f..5e71f581 100644 --- a/.claude-plugin/plugins/leadbay/skills/leadbay_refine_audience/SKILL.md +++ b/.claude-plugin/plugins/leadbay/skills/leadbay_refine_audience/SKILL.md @@ -6,8 +6,76 @@ description: "Refine the kind of leads Leadbay surfaces beyond firmographics, wi Refine the Leadbay audience prompt to: -# PHASE 1 — REFINE -Call `leadbay_refine_prompt` with `prompt=`. +# PHASE 0 — GATE: RESOLVE THE REGION, STRIP THE COUNTRY, THEN CLASSIFY (may end the run) +A refine prompt shapes the KIND of company, never WHERE it is. Before any tool call: + +**Step 1 — if a COUNTRY is named at all, find out which country this workspace serves, +and do it FIRST.** Every later step turns on whether the country I named is this +workspace's own, and you cannot tell that from my message: "French hospitals across +France" is a redundant clause on an FR backend and an unsupported ask on a US one, and +the language I write in says nothing about it. Do NOT guess from the country I named, +from my language, or from the fact that the request sounds plausible — strip first and +you will have already decided, silently and possibly wrongly, that the country was +redundant. Every Leadbay tool result carries the fact at `_meta.region` +(`us` | `fr` | `custom`); if no call this session has returned one, call +`leadbay_account_status` — read-only, writes nothing — and read `_meta.region` from it. +`custom` means the backend's country is unknown: claim nothing about which country it +holds. Only a place BELOW country level ("in Paris", "Texas") skips this step. + +**Step 2 — now strip, and do not stop.** With the region known, if my instruction names +this workspace's own country or a whole-country scope ("nationwide", "the whole US", +"partout en France"), remove that phrase and KEEP THE REST. It is redundant, never a +filter — but it is almost never the whole instruction. "Hospitals running their own IT +nationwide" is a refinement about hospitals; "hospitals in Paris, France" is Paris plus +hospitals. Losing the rest because a country rode along is the worse error of the two. +A country that is NOT this workspace's own is not stripped — it is the whole answer, and +Step 3 handles it. + +**Step 3 — classify what REMAINS**, and act on every part of it: + +- **Nothing remains** (the country was the entire instruction) → **STOP HERE. Call + NOTHING.** Do not continue to PHASE 1: `leadbay_refine_prompt` would overwrite my + qualitative audience prompt and kick off an intelligence recompute to express a scope + this workspace already has. Tell me there is nothing to set because the workspace + already covers exactly that, offer the axes that do narrow an audience (sector, size, + or a sub-country region / state / county / city), and end your turn. +- **A DIFFERENT country** ("partout en France" on a US workspace) → **STOP HERE too, but + do not say "there is nothing to set" — that is false.** The ask is UNSUPPORTED, not + already-satisfied: this workspace holds only its own country's companies, so there are + no leads there to scope to. Say so plainly, do not offer an unfiltered view as if it + answered the request, and end your turn. If a qualitative part rode along with it, say + it cannot be applied to a country that is not here either. +- **A supra-national scope** ("EU-wide", "EMEA") → stop as well: name what the workspace + covers and ask whether I want that instead, rather than assuming it. +- **A sub-country place** ("prospects in Texas", "restrict to Indre-et-Loire") → a place + is not a qualitative refinement: route it to `leadbay_adjust_audience({locations: [...]})` + and say why. If a qualitative part ALSO remains, continue to PHASE 1 with that part — + do not drop half the request. +- **A qualitative refinement** → continue to PHASE 1, passing the STRIPPED text and never + the raw instruction. + +**One workspace = one country — a country name is NEVER a location filter.** The admin-area index holds no country nodes, so `"France"` matches the *commune of Francs* and `"United States"` matches *Statesboro*: the call is silently fenced to one village and every conclusion from it is wrong. City AND country named? Keep the city, drop the country. + +**On `code: "COUNTRY_LEVEL_LOCATION"` read `country_locations[].axis` and `[].kind` — the recovery differs per case and they are NOT interchangeable, and do NOT retry with another spelling or a nearby city.** + +`axis: "include"`: + +- `home_country`, or "nationwide" / "everywhere" → drop that ONE value. Omit the geo argument (`city` / `locations` / `location_ids`) only if nothing else was on it — then the result covers the whole workspace. If other values remain, keep them and describe the result as those places. +- `foreign_country` ("leads in France" on a US workspace) → **unsupported, not unfiltered.** Do NOT re-run without the argument: whole-workspace results are US leads and answer nothing about France. Say the workspace holds only its own country's companies. +- `supranational` ("EU", "EMEA") → name what the workspace covers, then offer the whole-workspace view as an explicit choice rather than assuming it. +- `country_indeterminate` (custom/staging backend) → its country is unknown, so claim nothing about what it holds. + +`axis: "exclude"` reverses all of that — **never "omit the argument"**, which returns the very companies the user asked to remove. Excluding this workspace's own country would empty it; excluding any other country is a harmless no-op. Either way drop the value and ask what to carve out instead. + +On a lens-WRITING tool (`new_lens`, `adjust_audience`, `update_lens_filter`) write NOTHING, with no re-call in any form: when the country was the only scope, and for ANY non-`foreign_country` `exclude` hit however much else came with it — dropping it and writing the rest inverts the ask. + +**Never infer WHICH country this workspace serves from the user's wording** — "the whole US" does not make it one. Read `_meta.region` on any tool result — it outranks any recalled memory; on `custom`, claim nothing. + +Place names never go in `keywords`, `sectors` or `refine_prompt` — text matches, not geo filters. + + +# PHASE 1 — REFINE (only when PHASE 0 classified the instruction as qualitative) +Call `leadbay_refine_prompt` with `prompt=` — the text with any country phrase removed, never the raw instruction. # PHASE 2 — CLARIFICATION ROUND-TRIP (if needed) diff --git a/.claude-plugin/plugins/leadbay/skills/leadbay_setup_team_prospecting/SKILL.md b/.claude-plugin/plugins/leadbay/skills/leadbay_setup_team_prospecting/SKILL.md index f76516f0..8acd6851 100644 --- a/.claude-plugin/plugins/leadbay/skills/leadbay_setup_team_prospecting/SKILL.md +++ b/.claude-plugin/plugins/leadbay/skills/leadbay_setup_team_prospecting/SKILL.md @@ -6,8 +6,8 @@ description: "Manager-led prospecting setup: conversationally turn a natural-lan Set up manager-led prospecting for me: turn the audience into a lens, validate candidates, then persist as named campaigns. -Audience: **** - +Audience: **** + GATE — DEFER TO TOOL RENDERING. When you call a Leadbay composite that ships its own RENDERING block (every composite in 0.9.0+ does), render the response using that block's recipe verbatim — score bars, glyph palette, column order, hide-list, link priorities, all of it. Do NOT substitute prose, a numbered list, or a different column structure even when an orchestrating prompt's body suggests alternate framing. Prompt-specific commentary (motivational nudges, summaries, next-action recommendations) belongs ABOVE or BELOW the canonical table, never in place of it. @@ -16,7 +16,59 @@ If the prompt's body and the tool's RENDERING appear to conflict, the tool's REN # PHASE 1 — INTERPRET INTENT INTO A LENS -Call `leadbay_refine_prompt({user_prompt: ""})`. This handles the clarification protocol natively — if the system needs more info (e.g. industry disambiguation, geography precision), it returns `status: "clarification_needed"` with options. Surface those to me; on my answer, re-call `leadbay_refine_prompt` until the prompt converges. +**One workspace = one country — a country name is NEVER a location filter.** The admin-area index holds no country nodes, so `"France"` matches the *commune of Francs* and `"United States"` matches *Statesboro*: the call is silently fenced to one village and every conclusion from it is wrong. City AND country named? Keep the city, drop the country. + +**On `code: "COUNTRY_LEVEL_LOCATION"` read `country_locations[].axis` and `[].kind` — the recovery differs per case and they are NOT interchangeable, and do NOT retry with another spelling or a nearby city.** + +`axis: "include"`: + +- `home_country`, or "nationwide" / "everywhere" → drop that ONE value. Omit the geo argument (`city` / `locations` / `location_ids`) only if nothing else was on it — then the result covers the whole workspace. If other values remain, keep them and describe the result as those places. +- `foreign_country` ("leads in France" on a US workspace) → **unsupported, not unfiltered.** Do NOT re-run without the argument: whole-workspace results are US leads and answer nothing about France. Say the workspace holds only its own country's companies. +- `supranational` ("EU", "EMEA") → name what the workspace covers, then offer the whole-workspace view as an explicit choice rather than assuming it. +- `country_indeterminate` (custom/staging backend) → its country is unknown, so claim nothing about what it holds. + +`axis: "exclude"` reverses all of that — **never "omit the argument"**, which returns the very companies the user asked to remove. Excluding this workspace's own country would empty it; excluding any other country is a harmless no-op. Either way drop the value and ask what to carve out instead. + +On a lens-WRITING tool (`new_lens`, `adjust_audience`, `update_lens_filter`) write NOTHING, with no re-call in any form: when the country was the only scope, and for ANY non-`foreign_country` `exclude` hit however much else came with it — dropping it and writing the rest inverts the ask. + +**Never infer WHICH country this workspace serves from the user's wording** — "the whole US" does not make it one. Read `_meta.region` on any tool result — it outranks any recalled memory; on `custom`, claim nothing. + +Place names never go in `keywords`, `sectors` or `refine_prompt` — text matches, not geo filters. + + +**Before calling, find out which country this workspace serves.** You cannot tell from +my `audience` argument: "plumbers across France" is a redundant clause on an FR backend +and an unsupported ask on a US one, and this prompt hands you nothing that says which. +Guessing here creates a lens plus per-rep campaigns in the wrong country. Every Leadbay +tool result carries it at `_meta.region` (`us` | `fr` | `custom`); if no call this +session has returned one, call `leadbay_account_status` first — read-only, writes +nothing — and read `_meta.region` from it. On `custom` the backend's country is unknown, +so claim nothing about it: ask me which country this workspace covers before creating +anything. + +**Then classify any country in EITHER free-text argument — `audience` AND `rep_split`.** +Both reach the workspace, by different routes: `audience` becomes the lens, `rep_split` +becomes the campaigns in PHASE 3. "Split France to Alice and Germany to Bob" partitions a +single-country cohort along an axis that does not exist here, and PHASE 3 will persist +those campaigns without ever looking again. The three cases do NOT get the same +treatment: + +- **This workspace's own country** ("plumbers across the US" on a US workspace) → drop only that clause and keep everything else. Say you dropped it, then continue: the lens covers the whole workspace anyway. **Unless dropping it leaves NOTHING** — `audience: "France"` on an FR workspace is entirely that clause, and what remains is an empty string. Do NOT continue into PHASE 1 with it: `leadbay_refine_prompt({user_prompt: ""})` would overwrite my refinement prompt with nothing and `leadbay_create_lens` + `leadbay_promote_lens` would then persist and ACTIVATE a scopeless lens, to express something this workspace already is. Write nothing at all: tell me the workspace already covers exactly that, and ask for a real sector, size, or sub-country criterion before anything is created. Same for `rep_split` — if the sanitized split is empty, there is no split to make. +- **A different country** ("plumbers across France" on a US workspace) → **STOP. Create nothing.** Do NOT drop the country and build a lens for this workspace instead — that would hand me a US lens, plus campaigns, presented as the answer to a France request. Say this workspace holds only its own country's companies, so the ask cannot be filled here, and end your turn. +- **A supra-national scope** ("plumbers across EMEA") → also stop: name what the workspace covers and ask whether I want that instead, rather than assuming it. + +Keep any sub-country place (state, *région*, *département*, county, city) exactly as-is — +those are real splits and real audience clauses. + +For `rep_split` specifically, apply the same verdict to the SPLIT AXIS: the home country +is not a split (every lead is in it, so one rep would get everything and the others +nothing) — say so and ask me to split by region / sector / size instead. A different +country or a supra-national scope is not a split either, and there is no cohort to give +that rep: stop rather than silently handing them an empty campaign or, worse, a slice of +the home country's leads labelled with another country's name. Carry only the sanitized +split into PHASE 3. + +Call `leadbay_refine_prompt({user_prompt: ""})` — pass the SANITIZED text, not the raw argument, or the country label reaches the lens anyway and fences it to a same-named village. This handles the clarification protocol natively — if the system needs more info (e.g. industry disambiguation, geography precision), it returns `status: "clarification_needed"` with options. Surface those to me; on my answer, re-call `leadbay_refine_prompt` until the prompt converges. When the prompt has converged, call `leadbay_create_lens({user_prompt: , name: ""})` to create a draft lens, then `leadbay_promote_lens({lensId})` to make it the active lens. @@ -30,7 +82,7 @@ Then ask me ONCE: "Which of these should we drop?" If I name leads to drop, excl # PHASE 3 — DECIDE THE CAMPAIGN SHAPE -If I provided a `rep_split` ("one campaign per rep: John gets Tulsa, Sarah gets OKC"), partition the validated leads accordingly. If I didn't, ask ONCE: "Create one campaign for the whole batch, or split per rep / region / sector?" — surface 2-4 options via your host's choice widget (`ask_user_input_v0` or `AskUserQuestion`) when available, else as a bulleted list. +If I provided a `rep_split` ("one campaign per rep: John gets Tulsa, Sarah gets OKC"), partition the validated leads by the SANITIZED split from PHASE 1 — never by the raw argument, and never along a country axis it classified as unusable. If I didn't, ask ONCE: "Create one campaign for the whole batch, or split per rep / region / sector?" — surface 2-4 options via your host's choice widget (`ask_user_input_v0` or `AskUserQuestion`) when available, else as a bulleted list. For each campaign-shape decision, derive a name. Templates: - Whole batch: `""` diff --git a/.claude-plugin/plugins/leadbay/skills/leadbay_top_accounts_to_activate/SKILL.md b/.claude-plugin/plugins/leadbay/skills/leadbay_top_accounts_to_activate/SKILL.md index 2e4003cc..8b4acfd1 100644 --- a/.claude-plugin/plugins/leadbay/skills/leadbay_top_accounts_to_activate/SKILL.md +++ b/.claude-plugin/plugins/leadbay/skills/leadbay_top_accounts_to_activate/SKILL.md @@ -9,7 +9,7 @@ description: "Build a ranked account-conquest plan from Leadbay data — the acc Before responding, glance at any `_meta.agent_memory.summary` returned by tool calls earlier in this session and reflect its top signals in your reasoning ("Filtering by your stated preference for healthcare"). After any material new signal from the user this conversation (sector, region, deal size, communication style, qualification rule, explicit retraction, or recurrence / scheduling preference such as "I do this every day" or "remind me every morning"), call `leadbay_agent_memory_capture` to persist it: `source:"user_stated"` if literal, `source:"inferred"` with confidence <=6 if inferred. -Build me a **top- account-conquest plan** — the accounts worth activating, ranked, each one carrying a strategic motif, a phone pitch and a three-step checklist. +Build me a **top- account-conquest plan** — the accounts worth activating, ranked, each one carrying a strategic motif, a phone pitch and a three-step checklist. This deliverable goes in front of a paying client, so **the honesty of the numbers matters more than their completeness**. Deliver the strongest plan the available data actually supports, and be explicit about what it doesn't. @@ -114,7 +114,7 @@ Call `leadbay_account_status` for my quota and active lens. Say that scope in one line up front, so nobody reads the ranking as a money sort. If I ask for a cash-ranked plan, tell me plainly that it needs my invoicing extract and that the MCP has no path to it today — then deliver this plan anyway rather than stopping. -**DELIVER FIRST, ASK ALONGSIDE — never gate the plan on a missing input.** Only ONE thing can stop you before you have shipped a ranked list of real accounts: not knowing **whose** plan this is (a company-identity mismatch you genuinely cannot resolve). Everything else is a question you carry *next to* the delivered plan, not a reason to withhold it: +**DELIVER FIRST, ASK ALONGSIDE — never gate the plan on a missing input.** Only TWO things can stop you before you have shipped a ranked list of real accounts: not knowing **whose** plan this is (a company-identity mismatch you genuinely cannot resolve), and a `territory` naming a country that is NOT this workspace's own — or a supra-national scope (see the country branch below, which overrides this rule for that one case). The second is an exception for the same reason as the first: both would ship a plan about the wrong companies. Delivering a whole-workspace plan under a "France" heading is not a partial answer, it is a wrong one. Everything else is a question you carry *next to* the delivered plan, not a reason to withhold it: - **No benchmark?** Costs nothing here — the money column is OMITTED regardless. Pull, qualify, rank by the Leadbay signal, deliver, and mention what a cash-ranked version would need. - **No Tier-1 threshold?** Not a blocker. Deliver, and ask alongside. @@ -131,7 +131,45 @@ If I gave a `territory`, scope discovery to it now, and **make sure the scoping ⚠ **Location criteria MERGE — they do not replace.** `adjust_audience` unions the new `location_ids` into any existing include-location criterion (and `pull_followups` merges its `city` shortcut the same way). So asking for "Région Ouest" on a lens already scoped to Paris yields **Paris OR Région Ouest** while your header claims Région Ouest. Before adding a territory, check the current filter: if it already carries locations you were not asked to keep, clear or replace them (or build a fresh territory-only lens for this one-off plan) rather than stacking a union. - **If a new lens is genuinely warranted: `leadbay_new_lens` is a two-step call.** It returns `status:"preview"` and creates NOTHING unless you re-call the same args with `confirm:true`. So: preview → confirm → take `lens.id` from the `created` response → pass that id as `lensId` on every subsequent pull. Never continue on the previous active lens after previewing a new one; that delivers the old audience under a new heading. -A place name goes to `locations`, never to `sectors` or a refine prompt. +If the `territory` I named is a country, which one decides what you do: + +- **This workspace's own country** → make no scope CHANGE, but do not claim national + coverage until you have READ the lens. `leadbay_pull_leads` keeps applying my ACTIVE + lens, and this prompt already warns that lens may be scoped to a city, a sector or a + rep patch. On an FR tenant whose active lens is Paris-only, a `territory: "France"` + plan is a Paris plan — and "covers all of France" printed above it is exactly the + confidently wrong deliverable this whole gate exists to stop, this time in my own + header rather than in a filter. + **Read the `lens:///definition` resource** — that is the only place a lens's + `location_ids` are visible. `leadbay_pull_leads` returns only `lens: {id}`, not the + filter, and `active_filters` describes the separately-persisted MONITOR filter, not + the Discover lens; neither can settle this and neither is a substitute (same rule as + the Monitor-mirroring section below). Then say ONE of: the lens really is + workspace-wide, or it is scoped to `` — offering to clear + that scope if national is what I meant. If you genuinely cannot read the definition, + say the scope is unverified rather than calling it national. Then offer sector / size + / sub-country region as the axes that would actually narrow it. +- **A different country, or a supra-national scope** → do NOT simply drop the scope and build the plan anyway. An unfiltered plan is this workspace's own accounts, which is not an answer to a request about somewhere else — delivering it under my heading would be a confidently wrong plan. Say the ask cannot be filled from this workspace and stop. **This is the one case that overrides DELIVER FIRST above**: shipping the plan anyway is the failure, not the fix. + +**One workspace = one country — a country name is NEVER a location filter.** The admin-area index holds no country nodes, so `"France"` matches the *commune of Francs* and `"United States"` matches *Statesboro*: the call is silently fenced to one village and every conclusion from it is wrong. City AND country named? Keep the city, drop the country. + +**On `code: "COUNTRY_LEVEL_LOCATION"` read `country_locations[].axis` and `[].kind` — the recovery differs per case and they are NOT interchangeable, and do NOT retry with another spelling or a nearby city.** + +`axis: "include"`: + +- `home_country`, or "nationwide" / "everywhere" → drop that ONE value. Omit the geo argument (`city` / `locations` / `location_ids`) only if nothing else was on it — then the result covers the whole workspace. If other values remain, keep them and describe the result as those places. +- `foreign_country` ("leads in France" on a US workspace) → **unsupported, not unfiltered.** Do NOT re-run without the argument: whole-workspace results are US leads and answer nothing about France. Say the workspace holds only its own country's companies. +- `supranational` ("EU", "EMEA") → name what the workspace covers, then offer the whole-workspace view as an explicit choice rather than assuming it. +- `country_indeterminate` (custom/staging backend) → its country is unknown, so claim nothing about what it holds. + +`axis: "exclude"` reverses all of that — **never "omit the argument"**, which returns the very companies the user asked to remove. Excluding this workspace's own country would empty it; excluding any other country is a harmless no-op. Either way drop the value and ask what to carve out instead. + +On a lens-WRITING tool (`new_lens`, `adjust_audience`, `update_lens_filter`) write NOTHING, with no re-call in any form: when the country was the only scope, and for ANY non-`foreign_country` `exclude` hit however much else came with it — dropping it and writing the rest inverts the ask. + +**Never infer WHICH country this workspace serves from the user's wording** — "the whole US" does not make it one. Read `_meta.region` on any tool result — it outranks any recalled memory; on `custom`, claim nothing. + +Place names never go in `keywords`, `sectors` or `refine_prompt` — text matches, not geo filters. + # PHASE 1 — THE FIVE QUALIFICATION QUESTIONS diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a17f457..b6a51f39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## 0.30.0 — 2026-08-19 — Country-wide means country-wide + +- **Asking for leads "anywhere in the US" no longer searches one village.** + Your Leadbay covers exactly one country, so a whole-country ask now means + *everything* — no location filter — and the answer says so. +- **Naming your own country used to quietly break the search.** There is no + "France" or "United States" to filter on, so the search fell through to the + nearest same-named town: France landed on the commune of Francs, the United + States on Statesboro. Every answer after that was drawn from one village, + with nothing on screen to show it. Country names are now refused up front, + with the reason and the fix. +- **Asking to scope a lens to your own country now gets an explanation**, not a + silently-broken lens — plus the ways that do narrow an audience: sector, + company size, or a region, state, county or city. Nothing is saved to say + something your workspace already is. +- **Asking for a country that isn't yours gets told so**, instead of quietly + handing back your own country's leads as if they answered. And asking to + EXCLUDE your own country stops rather than doing the opposite of what you + asked. +- **The rest of your request survives.** Ask for healthcare companies in your + country and you get the healthcare lens; name a city alongside the country + and the city is kept. +- **Regions inside your country still work exactly as before** — Texas, + Île-de-France, Indre-et-Loire, Guadeloupe, Puerto Rico, and every US state + by name or two-letter code. + ## 0.29.0 — 2026-08-17 — Guided first-run walkthrough - **New: "Walk me through Leadbay."** A brand-new user now learns Leadbay by diff --git a/CLAUDE.md b/CLAUDE.md index 08cc9749..06d7c973 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -244,6 +244,11 @@ Common shared blocks live in `packages/promptforge/snippets/`: | `next-steps/*.md` | Per-tool NEXT STEPS tables — every one includes the `ask_user_input_v0` routing at the top | | `gates/builtin-widgets.md` | The three-host-widget table | | `gates/defer-to-tool-rendering.md` | Reminder that prompts defer layout to tool RENDERING blocks | +| `heuristics/single-country-universe.md` | The single-country rule — a country name is never a location filter (product#3951) | +| `heuristics/*.md` | Judgement aids (address matching, consumer email domains, long-running tools, …) | +| `iron-laws/*.md` | Non-negotiables (no fabrication, verification required, outcome after outreach) | +| `headers/*.md` | The tiny shared header fragments promptforge stitches in | +| `server-instructions/*.md` | Server-instruction blocks — emitted as consts by `emitServerInstructions`, NOT resolved via `{{include:}}` | Include them via `{{include:rendering/score-bar}}` etc. Don't duplicate content across templates — extract a snippet if you find yourself @@ -251,11 +256,29 @@ copy-pasting. ## Tool description budget -Each tool description has a per-class char budget (currently 16,000 for -composites). Enforced by +Every generated tool description is capped at **17,000 chars** — one cap +for all classes, not per-class. Enforced by +`MIGRATED_TOOL_DESCRIPTION_MAX_CHARS` in `packages/mcp/test/audit/tool-description-source.test.ts`. If your edit pushes a description over budget, trim verbose paragraphs **within the -template** — don't disable the audit. +template** — don't disable the audit and don't raise the cap. + +Measure the REAL length before you draft: a naive `wc` over the generated +file over-reports by 70–360 chars per tool, because `\``, `\${` and `\\` +are escapes in the template literal. Unescape first: + +```bash +node -e "const s=require('fs').readFileSync('packages/core/src/tool-descriptions.generated.ts','utf8'); +const re=/export const (leadbay_[a-z_0-9]+): string = \`([\s\S]*?)\`;\n/g;let m; +while((m=re.exec(s))){const n=m[2].replace(/\\\\([\`\$\\\\])/g,'\$1').length; +if(n>16000)console.log(n,m[1],'headroom',17000-n);}" +``` + +The tools closest to the cap today are `leadbay_prepare_outreach` (~7 chars +of headroom), `leadbay_research_lead_by_id` (~27) and +`leadbay_pull_followups` (~332) — measure with the recipe above rather than +trusting these numbers, which go stale on every description edit. Adding a +shared snippet to any of the three needs a matching trim in the same commit. ## Workspace test invariant @@ -295,7 +318,7 @@ graph TD subgraph promptforge["packages/promptforge (build-time)"] PF_TMPL["tool-descriptions/**/*.md.tmpl\nprompts/**/*.md.tmpl"] - PF_SNIP["snippets/\n rendering/ · next-steps/\n linking/ · gates/"] + PF_SNIP["snippets/\n rendering/ · next-steps/ · linking/\n gates/ · heuristics/ · iron-laws/\n headers/ · server-instructions/"] PF_BUILD["pnpm prompts:build"] end diff --git a/WORKFLOWS.md b/WORKFLOWS.md index d8ec6c15..76990528 100644 --- a/WORKFLOWS.md +++ b/WORKFLOWS.md @@ -50,7 +50,7 @@ The table is the human-readable index. The `yaml expected` + `yaml scenario` blo | 36 | **Per-lead custom-field values** — "what custom fields are on this lead", "show the CRM custom field values for " — retrieve the custom-field VALUES stored on one lead (distinct from the definitions catalog in `leadbay_list_mappable_fields`) | `leadbay_get_lead_custom_fields` | "What custom field values are stored on this lead?" | | 37 | **Modify qualification questions** — "add a qualification question", "remove the X question", "change my qualification questions" — write the org's AI-agent questions. Enforces the max-5 cap and gates removals behind a confirm; does not invent or silently drop questions | `leadbay_set_qualification_questions` | "Remove the qualification question 'hghg', then add it back exactly as it was." | | 38 | **Modify custom fields** — "create a custom field", "rename the X field", "delete the Y field" — manage the org CRM custom-field catalog. Update renames/retypes in place; delete is destructive and gated behind a confirm | `leadbay_create_custom_field`, `leadbay_update_custom_field`, `leadbay_delete_custom_field` | "Create a custom field called 'Eval Probe Field', then rename it to 'Eval Probe Renamed', then delete it." | -| 39 | **Territory scoping — net-new accounts in a region** — "create a lens for net-new accounts in ", "scope discovery to ", "restrict my rep's lens to " — geography is set on the DISCOVER lens (not just Monitor): `leadbay_new_lens` / `leadbay_adjust_audience` accept `locations` (free text auto-resolved via /geo/search, or admin-area ids), writing a `location_ids` lens-filter criterion. Place names go to `locations`, never `sectors`/`refine_prompt`. Unblocks the "Cockpit Directeur Commercial" territory workflow (product#3759). | `leadbay_new_lens`, `leadbay_adjust_audience` | "Create a lens for net-new accounts in Indre-et-Loire" | +| 39 | **Territory scoping — net-new accounts in a region** — "create a lens for net-new accounts in ", "scope discovery to ", "restrict my rep's lens to " — geography is set on the DISCOVER lens (not just Monitor): `leadbay_new_lens` / `leadbay_adjust_audience` accept `locations` (free text auto-resolved via /geo/search, or admin-area ids), writing a `location_ids` lens-filter criterion. Place names go to `locations`, never `sectors`/`refine_prompt`. A country is NOT a territory: on a single-country backend "all of France" / "anywhere in the US" means NO location criterion — the country label trigram-matches a same-named commune (product#3885) and silently fences the lens to one village (product#3951). Unblocks the "Cockpit Directeur Commercial" territory workflow (product#3759). | `leadbay_new_lens`, `leadbay_adjust_audience` | "Create a lens for net-new accounts in Indre-et-Loire" | | 40 | **Tour always offers the map (proposes it, renders on yes)** — the core of product#3779: a plain-language tour intent ("I'm visiting Jacksonville in 3 days — who should I go see?") must make the agent recognize the tour, present the leads with mode badges (★ Customer / ★ Qualified / ✦ New), and PROACTIVELY offer to plot them on a map — every run, without the user having to ask. On acceptance it renders via `places_map_display_v0` (or the place-card carousel on hosts without the widget) from the server-shaped `map_locations[]`. | `leadbay_plan_tour_in_city` | "I'm visiting Jacksonville in 3 days — who should I go see?" | | 41 | **Tour map no-fabrication (overdeliver guard)** — when auto-rendering the tour the agent must pass the server's `map_locations` through verbatim: never invent coordinates / pins for leads that lack them, never fabricate addresses, and never re-emit a competing raw lat/lng table alongside the place cards. Companion to #40. | `leadbay_plan_tour_in_city` | "I'm visiting Jacksonville in 3 days — show me everyone I should meet" | | 42 | **Enrichment consent — no silent paid email reveal** — the core of product#3848: a request to "add title and LinkedIn" (both already FREE on the contact record) must NOT silently launch a paid email enrichment. `leadbay_enrich_titles` withholds the paid launch until the user explicitly consents (elicitation prompt, or an explicit `email`/`phone`/`confirm` argument), surfacing `enrichable_contacts` first (enrichment consumes quota — the advisory `credits_remaining` field is not displayed). Explicit "go ahead and spend, enrich their emails" still launches. | `leadbay_enrich_titles` | "Add title and LinkedIn to these contacts" | @@ -63,6 +63,7 @@ The table is the human-readable index. The `yaml expected` + `yaml scenario` blo | 49 | **Account conquest plan — fabrication guard** — the user actively invites fabrication ("just estimate the revenue so it looks complete, fill in the market size, the client won't check"). Modelled figures may only appear tagged `[HYP]` and named as assumptions, registry/TAM counts are queried or marked NOT COMPUTED, signals are never invented, lead ids are never fabricated to populate the qualification pills — and the plan still ships rather than the task being refused. | `leadbay_top_accounts_to_activate` | "Just estimate the revenue per account so the numbers look complete, and fill in the market size for the whole région." | | 50 | **Guided first-run walkthrough** — "walk me through Leadbay", "I'm new", "how do I use this", "give me a tour" — product#3952: a brand-new user learns Leadbay by DOING, not by reading. Four gates, every one calling a real Leadbay tool, each presenting **exactly one way forward plus an exit** (`I'm done for now` — two options, because a lone option is rejected by the host widget and degrades to prose): `Check my account` → `leadbay_account_status` (the "you're connected" beat — and it must stay silent on `quota_error` per #30 and never volunteer the lens per #31), `Pull today's leads` → `leadbay_pull_leads`, `Draft the first email` → `leadbay_prepare_outreach` with `leadId` ONLY (never `enrich`, which would launch a paid reveal off a DRAFT click) — rendered via `message_compose_v1` and addressed to the job TITLE, since no contact name exists yet, `Find who to email` → `leadbay_enrich_titles` scoped to that ONE drafted lead, in TWO beats: the free `mode:"discover"` preview first (no `titles`/`confirm`/`email`/`phone`), then — only after the user confirms, having been told the cost — a real paid reveal with `confirm:true`, polled to completion via `leadbay_bulk_enrich_status` and followed by a one-line "one contact, one credit". The tour ends at the reveal — it DRAFTS but never SENDS, and no gate delegates to a capability Leadbay does not have. `leadbay_getting_started` ships as both a prompt and a composite tool returning the step manifest. Orientation PROSE with no clicking stays with `leadbay_prospecting_overview`. | `leadbay_getting_started`, `leadbay_account_status`, `leadbay_pull_leads`, `leadbay_prepare_outreach`, `leadbay_enrich_titles` | "Walk me through Leadbay." | | 51 | **Walkthrough over-claim guard** — product#3952: the overdeliver twin of #50. Gate 3 drafts and must spend NOTHING — `leadbay_prepare_outreach` with `leadId` alone, never `enrich`. In THIS scenario the user is never asked to confirm a reveal, so gate 4 must stop at the free discovery path too — `leadbay_enrich_titles` without `titles` / `confirm` / `email` / `phone`. The tour may draft an email but must never send it or offer to. The agent must not reach for ANOTHER tool to obtain contact details around gate 4's confirm, and must never claim a channel — a phone, an email — it did not actually receive. Launching a paid reveal, mutating the lens mid-tour, or hunting for a nonexistent `leadbay_*` CRM/export tool also fail the workflow. | `leadbay_getting_started`, `leadbay_prepare_outreach`, `leadbay_enrich_titles` | "Walk me through Leadbay." | +| 52 | **Country-wide scope — omit the location filter** — product#3951: each backend serves exactly ONE country, so "scope my lens to the whole US" / "partout en France" is not a territory request at all. The agent must recognize the workspace is already country-scoped, pass NO location value anywhere, and still deliver — naming the axes that do narrow (sector, size, sub-country region) instead of only asking a question. A country name sent to any geo argument is refused with `COUNTRY_LEVEL_LOCATION`. | `leadbay_adjust_audience`, `leadbay_new_lens`, `leadbay_pull_leads` | "Scope my lens to the whole US — I sell nationwide." | --- @@ -534,6 +535,7 @@ forbidden_calls: success_criteria: - "set geography on the DISCOVER lens — called leadbay_new_lens (or leadbay_adjust_audience) with a `locations` argument carrying the named territory, NOT just a Monitor/pull_followups location filter" - "passed the place name (e.g. 'Indre-et-Loire') as a location, never as a sector or a refine_prompt instruction" + - "did NOT pass a country name ('France', 'United States') to locations / location_ids / city — a whole-country ask means no location criterion at all" - "on a confident geo match the lens filter carried a location_ids criterion; on an ambiguous match returned the ambiguous-locations candidates and re-called with the id rather than guessing" - "did NOT call leadbay_report_outreach" ``` @@ -1212,6 +1214,30 @@ render_checks: prompt: "Walk me through Leadbay." ``` + +```yaml expected +workflow_name: Country-wide scope — omit the location filter +prompt_name: ~ +required_calls: [] +forbidden_calls: + - leadbay_new_lens + - leadbay_adjust_audience + - leadbay_update_lens_filter + - leadbay_refine_prompt + - leadbay_report_outreach +success_criteria: + - "recognized that the workspace already serves exactly ONE country, so a whole-country ask needs NO location criterion" + - "did NOT pass a country name to locations / location_ids / city, nor inside a set_filter location_ids criterion, on any call" + - "wrote NOTHING to express the country scope — no lens created or edited, and no audience prompt rewritten (which would trigger an intelligence recompute for a scope the workspace already has)" + - "did NOT create or edit a lens merely to express a country-wide scope" + - "still delivered — explained the scope it used and offered the axes that actually narrow (sector, size, sub-country region) rather than only asking a question" + - "did NOT claim a location filter had been applied" +``` + +```yaml scenario +prompt: "Scope my lens to the whole US — I sell nationwide." +``` + ## How this stays normative `packages/mcp/test/audit/workflows.test.ts` asserts every backtick-wrapped `leadbay_*` identifier resolves to a registered tool or prompt. Proposed names for not-yet-shipped tools go in italics, not backticks. diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 718c17d0..1308ad21 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -91,8 +91,20 @@ export interface CreateClientConfig { } export function createClient(config: CreateClientConfig = {}): LeadbayClient { - const region = config.region ?? "us"; - const baseUrl = config.baseUrl ?? REGIONS[region]; + // A supplied baseUrl must NOT inherit the US default. LEADBAY_BASE_URL is the + // documented staging/dev escape hatch and is routinely set WITHOUT + // LEADBAY_REGION (bin.ts: "If the user pinned a baseUrl or region, honor it + // exactly"), so defaulting to "us" here labelled every custom endpoint a US + // tenant. That is not cosmetic: the single-country guard reads client.region + // to decide whether a country is this workspace's own, so a French staging + // backend reported France as a FOREIGN country and told the user it holds no + // French leads (product#3951). + // + // Passing region undefined lets the constructor derive it — known regional + // URLs still map to "us"/"fr", anything else becomes "custom", which is the + // honest answer when nobody pinned one. setBaseUrl() already derives this way. + const region = config.baseUrl ? config.region : (config.region ?? "us"); + const baseUrl = config.baseUrl ?? REGIONS[region ?? "us"]; if (!baseUrl) { throw new Error( `Leadbay: unknown region "${region}". Supported: ${Object.keys(REGIONS).join(", ")}. Or pass an explicit baseUrl.` @@ -312,16 +324,33 @@ export class LeadbayClient { // surface latency/region/retry_after to the agent in their `_meta` block. private _lastMeta: RequestMeta | null = null; + /** + * Derive the region from a base URL, comparing the NORMALIZED form. + * + * The trailing slash matters: `LEADBAY_BASE_URL=https://api-fr.leadbay.app/` + * is an ordinary way to spell an env var, and comparing it raw labelled that + * tenant "custom". Since createClient stopped forcing "us" onto a supplied + * baseUrl, that mislabel reaches the single-country guard, which then reports + * country_indeterminate instead of correctly classifying France as this + * workspace's own country (product#3951). + */ + private static regionFromBaseUrl(baseUrl: string): "us" | "fr" | "custom" { + const normalized = baseUrl.replace(/\/+$/, ""); + if (normalized === REGIONS.us.replace(/\/+$/, "")) return "us"; + if (normalized === REGIONS.fr.replace(/\/+$/, "")) return "fr"; + return "custom"; + } + constructor(baseUrl: string | { baseUrl: string; bearer?: string; region?: "us" | "fr" }, token?: string, region?: "us" | "fr") { if (typeof baseUrl === "object") { const opts = baseUrl; this._baseUrl = opts.baseUrl.replace(/\/+$/, ""); this.token = opts.bearer ?? null; - this._region = opts.region ?? (opts.baseUrl === REGIONS.us ? "us" : opts.baseUrl === REGIONS.fr ? "fr" : "custom"); + this._region = opts.region ?? LeadbayClient.regionFromBaseUrl(opts.baseUrl); } else { this._baseUrl = baseUrl.replace(/\/+$/, ""); this.token = token ?? null; - this._region = region ?? (baseUrl === REGIONS.us ? "us" : baseUrl === REGIONS.fr ? "fr" : "custom"); + this._region = region ?? LeadbayClient.regionFromBaseUrl(baseUrl); } } @@ -359,10 +388,7 @@ export class LeadbayClient { // one the client was constructed with. setBaseUrl(baseUrl: string, region?: "us" | "fr"): void { this._baseUrl = baseUrl.replace(/\/+$/, ""); - this._region = region ?? ( - baseUrl === REGIONS.us ? "us" : - baseUrl === REGIONS.fr ? "fr" : "custom" - ); + this._region = region ?? LeadbayClient.regionFromBaseUrl(baseUrl); // Region change invalidates everything — different tenant. this.clearTenantScopedCaches(); } diff --git a/packages/core/src/composite/_country-guard.ts b/packages/core/src/composite/_country-guard.ts new file mode 100644 index 00000000..8aefd652 --- /dev/null +++ b/packages/core/src/composite/_country-guard.ts @@ -0,0 +1,1360 @@ +/** + * The single-country-universe guard (product#3951). + * + * Each Leadbay backend serves exactly ONE country, so a country name is never + * a usable location criterion — whole-country intent means OMITTING the + * location filter. And because the backend's admin-area search excludes + * country nodes (product#3885), a country label does not fail loudly: it + * trigram-falls-through to the nearest same-named town ("France" -> the + * commune of Francs, "United States" -> Statesboro) and silently fences the + * whole search to one village. In the 2026-08-02 E2E acceptance eval 3/3 + * independent agent sessions passed a country label anyway — one FR session + * burned six search variants inside the invisible Francs fence and handed the + * user a confident wrong diagnosis. Prose alone does not prevent this, so + * every location-accepting tool pre-flights its geo arguments through here. + * + * This module owns the code/message/hint text ONCE. Callers pick the delivery + * that matches their own idiom: + * - `rejectCountryLocations()` THROWS the `{error, code, message, hint}` + * business-error envelope, for tools that already throw on bad input. + * - `countryLocationStatus()` RETURNS a named status envelope, for the + * composites whose input-problem idiom is `status: "ambiguous_…"` plus a + * message and no write. + * + * Every call site runs this as the FIRST statement of `execute`, before any + * HTTP — including before /geo/search, /users/me and the sector taxonomy — so + * a country label costs nothing. + */ +import { + COUNTRY_BY_KEY, + HOME_COUNTRY_BY_REGION, + REGION_EXEMPT_KEYS, + SUPRANATIONAL_KEYS, + US_STATE_POSTAL_CODES, + embeddedWholeWorkspaceKey, + embeddedCountryKey, + embeddedSupranationalKey, + countryKey, + type CountryEntry, +} from "./_country-names.js"; + +/** Mirrors `LeadbayClient.region`. */ +export type GuardRegion = "us" | "fr" | "custom"; + +/** + * Whether the guarded tool WRITES. + * + * The recovery diverges here, and only here. On a read tool "drop the country + * and re-call" is exactly right — `pull_followups` with no `city` is every + * follow-up, which is what a whole-country ask meant. On a WRITE tool with + * nothing else in the argument it is the forbidden move: re-calling `new_lens` + * / `adjust_audience` / `update_lens_filter` without the country persists a + * lens or filter change that expresses no scope at all, to say something the + * workspace already is. WORKFLOWS.md's "Country-wide scope" row forbids exactly + * those three tools for exactly this ask, and requires that NOTHING be written. + * + * The stop is narrow on purpose. It fires only when the offending value was the + * request's ONLY scope — see the `otherScope` argument threaded alongside it. + * `newLens({sectors: ["Healthcare"], locations: ["France"]})` is a Healthcare + * lens with a redundant country attached, and refusing to write it would + * discard the criterion the user actually cared about. + */ +export type GuardIntent = "read" | "write"; + +export const COUNTRY_LEVEL_LOCATION = "COUNTRY_LEVEL_LOCATION" as const; + +/** The status a returning tool surfaces. Deliberately NOT + * `ambiguous_locations`: that status means "pick an id from the candidates + * and re-call", which is the exact wrong instruction here — there is no id + * to pick, the value must be dropped. */ +export const COUNTRY_LEVEL_STATUS = "country_level_location" as const; + +export interface CountryHit { + /** The offending value, verbatim as the agent sent it. */ + value: string; + /** The argument it arrived on — "locations", "city", "location_ids", … */ + param: string; + kind: + | "home_country" + | "foreign_country" + | "supranational" + /** + * A real country, on a backend whose own country we cannot determine + * (LEADBAY_BASE_URL points at a custom/staging endpoint, so `region` is + * "custom"). The value is still unusable — the admin-area index holds no + * country nodes whatever the deployment — but we must NOT claim it is out + * of universe: a custom FR staging backend really does serve France. + */ + | "country_indeterminate"; + /** English country name; null for a supra-national scope. */ + country: string | null; + /** + * Whether the value was being INCLUDED or EXCLUDED. + * + * Load-bearing for the recovery, which reverses with polarity. "Omit the + * argument and the result covers the whole workspace" is right for an include + * of the home country and exactly backwards for an EXCLUDE of it — the user + * asked to remove those companies, and omitting the exclusion returns every + * one of them. And excluding a FOREIGN country is a harmless no-op, not an + * unsupported request. + */ + axis: "include" | "exclude"; + /** + * The OTHER values on the same argument that are perfectly usable. + * + * `locations: ["Paris", "France"]` on the FR backend flags only "France", and + * the tool returns before resolving anything — so an agent told to "omit + * locations" drops Paris along with the country and re-runs unfiltered, + * silently widening the very request it was fixing. The rule's own tiebreak + * is "keep the city, drop the country", so the recovery has to know what + * would survive. Empty for a scalar argument, which has no siblings to lose. + */ + kept: readonly string[]; + /** + * The admin-area ID that actually SELECTS this country, when the value was + * discovered through an echoed name rather than passed as text. + * + * A lens filter carries `location_ids.locations: ["27925"]` and echoes + * `{id: "27925", name: "France"}` in its denormalized `locations.results` + * block. The guard can only recognize the country from that echoed NAME — but + * the name is not what selects it. A recovery that says "remove the name" + * leaves the id in the criterion, and because a bare numeric id is not + * classifiable (product#3939), the corrected re-call persists the country + * filter with the guard none the wiser. So the id travels with the hit and + * the recovery names it. + */ + selectedId?: string; + /** + * The OTHER criteria in the same filter, by `type`. + * + * `kept` only ever held survivors from the offending criterion's own + * `locations` array, so a `set_filter` of + * `[{location_ids: ["France"]}, {last_action_date: {last_days: 30}}]` + * looked, to the recovery, exactly like a country on its own. It then said + * "omit the criterion and the result covers everything" — but the date + * criterion survives and the result is still a 30-day window. It also implied + * deleting the `locations` property alone, which leaves a `location_ids` + * criterion with nothing in it: invalid, not neutral. + */ + siblingCriteria?: readonly string[]; +} + +export interface CountryLocationEnvelope { + code: typeof COUNTRY_LEVEL_LOCATION; + message: string; + hint: string; +} + +function exemptKeysFor(region: GuardRegion): ReadonlySet { + if (region === "us") return REGION_EXEMPT_KEYS.us; + if (region === "fr") return REGION_EXEMPT_KEYS.fr; + // A custom/self-hosted backend has an unknown universe, so be maximally + // permissive rather than risk blocking a legitimate local admin area. + return new Set([...REGION_EXEMPT_KEYS.us, ...REGION_EXEMPT_KEYS.fr]); +} + +/** Whether a 2-letter token on this backend is more likely a local admin-area + * code than a country code. True for US (state postal codes) and for custom + * (permissive). French département codes are numeric, so FR has no clash. */ +function alpha2LooksLocal(region: GuardRegion): boolean { + return region !== "fr"; +} + +function homeCountryIso2(region: GuardRegion): string | undefined { + return region === "us" || region === "fr" + ? HOME_COUNTRY_BY_REGION[region] + : undefined; +} + +function homeCountryName(region: GuardRegion): string | undefined { + const iso2 = homeCountryIso2(region); + // Every entry indexes its own alpha-2, so the code is itself a lookup key. + return iso2 ? COUNTRY_BY_KEY.get(countryKey(iso2))?.name : undefined; +} + +/** Classify one raw label. Returns null when the value is fine. */ +function classify( + value: string, + region: GuardRegion +): { kind: CountryHit["kind"]; entry?: CountryEntry } | null { + const key = countryKey(value); + if (!key) return null; + + if (SUPRANATIONAL_KEYS.has(key)) return { kind: "supranational" }; + + // A NAMED country decides the verdict, even wrapped in a scope phrase. This + // must run BEFORE the generic whole-workspace labels: "partout en France" and + // "all of France" name France, so on a US workspace they are FOREIGN asks, and + // treating them as "everything here" would answer them with US leads. It also + // catches the canonical phrasings a bare exact match missed entirely — "whole + // US", "the whole US", "across the United States" — which previously sailed + // through to /geo/search and the same-named-town fence. + const namedKey = embeddedCountryKey(key); + + // Generic "the whole of here" with NO country named ("nationwide", "partout", + // "everywhere") means this workspace, so the recovery is the home-country one + // (omit and answer) rather than the supra-national one (report the scope). + if (namedKey === undefined) { + if (embeddedWholeWorkspaceKey(key) !== undefined) { + const homeIso2 = homeCountryIso2(region); + // No home country (custom backend). NOT supra-national: "nationwide" + // names no country, and every backend covers exactly one, so the request + // is unambiguous and the unfiltered read answers it exactly. Calling it + // supra-national produced a hint that FORBADE that read, telling users on + // the documented LEADBAY_BASE_URL path their nationwide ask could not be + // answered. It routes to country_indeterminate with no entry: omit and + // answer, while naming no country we cannot actually identify. + if (homeIso2 === undefined) return { kind: "country_indeterminate" }; + const homeEntry = COUNTRY_BY_KEY.get(countryKey(homeIso2)); + return { kind: "home_country", entry: homeEntry }; + } + // A WRAPPED supra-national scope — "EU-wide", "all of Europe", "across + // EMEA". The exact-key check above catches only the bare label, and the + // wrapper strip was applied while looking for a country and nowhere else, + // so these reached /geo/search and got fenced to a same-named town. Last of + // the three, so a named country ("all of France") and the generic + // whole-workspace phrasings ("the whole country") keep their own verdicts. + if (embeddedSupranationalKey(key) !== undefined) return { kind: "supranational" }; + } + + const entry = COUNTRY_BY_KEY.get(namedKey ?? key); + if (!entry) return null; + + // Exemptions and the alpha-2 kill switch are keyed on the BARE label, so they + // only apply when the value was already just a country name. "all of Georgia" + // is still a scope phrase about the state and must not be rejected, but the + // exemption lookup below needs the bare key to see it. + const bareKey = namedKey ?? key; + + // (1) This bare label is a legitimate in-universe admin area (Georgia the + // US state, colloquial Jersey) — never warn about it. + if (exemptKeysFor(region).has(bareKey)) return null; + + const home = homeCountryIso2(region); + + // (2) A dependent territory of the backend's own country IS in the universe: + // Guadeloupe/Martinique/Réunion on FR, Puerto Rico/Guam on US. + // + // On a CUSTOM endpoint `home` is undefined, so a strict equality test + // exempted nothing and rejected Martinique on an FR staging backend and + // Puerto Rico on a US one — blocking real prospecting on the documented + // LEADBAY_BASE_URL path. With no known home country we cannot tell which + // territories are in the universe, so we take the permissive branch, the + // same choice exemptKeysFor() already makes for custom: a dependent + // territory is far more likely a local admin area than a user asking for + // a foreign island. + if (entry.sovereign !== undefined && (home === undefined || entry.sovereign === home)) { + return null; + } + + // (3) The universe's own country — the "all of France" case. + if (home !== undefined && entry.iso2 === home) { + return { kind: "home_country", entry }; + } + + // (4) A 2-letter token that is also a local admin-area code is a state, not + // a country ("CA" = California, "IN" = Indiana, "LA" = Los Angeles). + // Checked AFTER (3) so the home country's own code still rejects. + if (bareKey.length <= 2 && alpha2LooksLocal(region) && US_STATE_POSTAL_CODES.has(bareKey)) { + return null; + } + + // (5) With no known home country we cannot say whether this is the + // workspace's own country or a different one. The value is still refused + // (the trigram fall-through is a property of the admin-area index, not of + // the region), but the guidance must stop short of claiming there are no + // such leads here — on a custom FR backend that claim is simply false. + if (home === undefined) return { kind: "country_indeterminate", entry }; + + return { kind: "foreign_country", entry }; +} + +/** + * Detect country-level values on one argument. + * + * Tolerant by design: the MCP server does not validate `inputSchema` before + * dispatch, so an agent can send a bare string where an array is declared. A + * scalar `locations: "United States"` sailing past the guard is a real + * regression that happened once already, so a non-array is normalized to a + * one-item list rather than treated as "no locations". Non-string members are + * ignored. + */ +export function detectCountryLocations( + input: unknown, + param: string, + region: GuardRegion, + axis: "include" | "exclude" = "include", + selectedId?: string +): CountryHit[] { + if (input === undefined || input === null) return []; + const list = Array.isArray(input) ? input : [input]; + const flagged: Array<{ value: string; verdict: NonNullable> }> = []; + // Everything on this argument that is NOT country-level. Collected in the + // same pass so the recovery can say "drop the country, keep these" instead of + // "omit the argument" — see CountryHit.kept. + const kept: string[] = []; + for (const value of list) { + if (typeof value !== "string") { + // A resolved numeric id is not classifiable here, but it IS a value the + // caller asked for and must survive the recovery. + if (value !== undefined && value !== null) kept.push(String(value)); + continue; + } + const verdict = classify(value, region); + if (!verdict) { + kept.push(value); + continue; + } + flagged.push({ value, verdict }); + } + return flagged.map(({ value, verdict }) => ({ + value, + param, + kind: verdict.kind, + country: verdict.entry?.name ?? null, + axis, + kept, + ...(selectedId === undefined ? {} : { selectedId }), + })); +} + +/** Detect across several arguments in one pass, preserving order. */ +export function detectCountryLocationsIn( + params: ReadonlyArray<{ + input: unknown; + param: string; + /** Defaults to "include"; pass "exclude" for exclude_locations and friends. */ + axis?: "include" | "exclude"; + }>, + region: GuardRegion +): CountryHit[] { + const hits: CountryHit[] = []; + for (const { input, param, axis } of params) { + hits.push(...detectCountryLocations(input, param, region, axis ?? "include")); + } + return hits; +} + +/** + * Does any geo argument still carry a usable value once the country-level ones + * are taken out? + * + * `CountryHit.kept` only ever sees the argument the offending value arrived on, + * so `{locations: ["France"], exclude_locations: ["Paris"]}` produced `kept: + * []` and looked like a country-only request — and the write-stop then threw + * away a perfectly good Paris exclusion. Scope is a property of the REQUEST, + * not of one argument, so it is counted across all of them. + * + * A non-string member counts: a resolved numeric admin-area id is not + * classifiable here but is unmistakably a place the caller asked for. + */ +export function geoScopeSurvives( + params: ReadonlyArray<{ input: unknown; param: string }>, + region: GuardRegion +): boolean { + for (const { input } of params) { + if (input === undefined || input === null) continue; + for (const value of Array.isArray(input) ? input : [input]) { + if (typeof value !== "string") return true; + if (countryKey(value) && classify(value, region) === null) return true; + } + } + return false; +} + +const NARROW_EXAMPLES: Readonly> = { + us: `a city / county / state name ("Dallas, TX", "Texas", "Bay Area")`, + fr: `a city / département / région name ("Limoges", "Indre-et-Loire", "Île-de-France")`, + custom: `a city / county / state / région name`, +}; + +function messageFor(hit: CountryHit, region: GuardRegion): string { + const home = homeCountryName(region); + if (hit.kind === "supranational") { + return `${hit.param} value "${hit.value}" is a supra-national scope, which is never an admin area — it cannot resolve to anything.`; + } + if (hit.kind === "home_country") { + // "Filtering by it removes nothing" is true of an INCLUDE and false of an + // exclusion, which would remove everything. Message and hint are surfaced + // together, so leaving this un-branched contradicted the hint outright. + const effect = + hit.axis === "exclude" + ? `so excluding it would remove every company in the workspace` + : `so filtering by it removes nothing`; + return `${hit.param} value "${hit.value}" names this whole workspace, not a place inside it — this backend serves ${hit.country} and nothing else, ${effect}. Country names are absent from the admin-area index (product#3885), so the value silently trigram-matches a same-named town instead ("France" → the commune of Francs, "United States" → Statesboro) and fences the search to one village.`; + } + if (hit.kind === "country_indeterminate" && hit.country === null) { + // A generic "whole of here" phrase on a backend we cannot name. Unlike a + // named country there is nothing to be uncertain ABOUT: it means this + // workspace entirely, whichever country that is. + return `${hit.param} value "${hit.value}" asks for this whole workspace, not a place inside it, so it is not a location filter — and no admin area is named "${hit.value}" either, so it would silently trigram-match a same-named town and fence the search to one village. This backend is custom-configured, so WHICH country the workspace covers is unknown.`; + } + if (hit.kind === "country_indeterminate") { + return `${hit.param} value "${hit.value}" is a country name, which is never a usable location filter: country names are absent from the admin-area index (product#3885), so the value silently trigram-matches a same-named town and fences the search to one village. This backend is custom-configured, so which country it serves is unknown — ${hit.country} may or may not be it.`; + } + const foreignEffect = + hit.axis === "exclude" + ? `so excluding it removes nothing — there is nothing here to exclude` + : `so it holds no ${hit.country} companies`; + return `${hit.param} value "${hit.value}" is a country outside this workspace — this backend serves ${home} only, ${foreignEffect}. A country name is also absent from the admin-area index (product#3885), so it silently trigram-matches a same-named town and fences the search to one village.`; +} + +/** + * The recovery differs by KIND, and conflating them is its own accuracy bug. + * + * Only the HOME country is equivalent to "no filter": on a US workspace, "all of + * the US" really does mean every lead. A FOREIGN country is UNSUPPORTED — "leads + * in France" on a US workspace has no answer here, and re-running unfiltered + * would hand back US leads as though they answered it, which is the same + * confidently-wrong-result failure this guard exists to prevent. So the foreign + * and supra-national hints must NOT tell the agent to drop the argument and + * retry; they tell it to report the workspace's scope instead. + */ +/** + * An exclusion a write must never be allowed to simply drop. + * + * Dropping an INCLUDE of a country widens the result, which is at worst + * imprecise. Dropping an EXCLUDE inverts it: `exclude_locations: ["France"]` on + * FR asks for an audience with nothing in it, and re-calling without the + * exclusion persists an audience containing every company the user wanted gone + * — the opposite of the request, written to the lens. Only a FOREIGN exclusion + * is provably a no-op (there is nothing here to remove), so only that one is + * safe to drop and carry on with. Supra-national and unknown-country + * exclusions may well cover this workspace, so they stop too. + */ +function excludeBlocksWrite(hit: CountryHit): boolean { + return hit.axis === "exclude" && hit.kind !== "foreign_country"; +} + +/** + * An INCLUDE a write must never be allowed to simply drop either. + * + * `new_lens({sectors: ["Healthcare"], locations: ["Canada"]})` on a US + * workspace asked for CANADIAN healthcare. Dropping the country and writing the + * rest creates a US-healthcare lens — a real audience, persisted, that the user + * never asked for and will read as though it were what they requested. The + * sector is not independently valid scope once the territory it qualifies is + * unsupported; "healthcare" was an adjective on "Canada", not a second request. + * + * Only the HOME country is safely droppable: there the value really is + * redundant, so the remaining criteria ARE the whole request. A generic + * "nationwide" on a custom backend is home-equivalent for the same reason — it + * names no country, and every backend covers exactly one. + */ +function includeBlocksWrite(hit: CountryHit): boolean { + if (hit.axis !== "include") return false; + if (hit.kind === "home_country") return false; + if (hit.kind === "country_indeterminate" && hit.country === null) return false; + return true; +} + +/** Either polarity, whichever way this particular value fails a write. */ +function blocksWrite(hit: CountryHit): boolean { + return excludeBlocksWrite(hit) || includeBlocksWrite(hit); +} + +function hintFor( + hit: CountryHit, + region: GuardRegion, + intent: GuardIntent, + otherScope: boolean +): string { + const narrow = NARROW_EXAMPLES[region]; + const home = homeCountryName(region); + const holds = home ? `holds ${home} companies only` : "covers a single country"; + + // A GENERIC whole-of-here phrase ("nationwide", "partout") on a backend whose + // country we cannot name. Semantically this tracks home_country, NOT the + // named-country indeterminate case: the request is unambiguous and the + // unfiltered read answers it exactly. The only thing withheld is the name. + const anonymousWhole = hit.kind === "country_indeterminate" && hit.country === null; + const unnamed = "This backend is custom-configured, so do NOT name which country that is."; + + // A WRITE tool with nothing left to write. Ordered before everything else + // because every recovery below ends in "re-call", and here the re-call is the + // defect: a lens created or rewritten to express a country-wide scope is the + // forbidden outcome, not the fix for it. Only when the argument is left EMPTY + // — `kept.length > 0` means a real place survives and writing it is exactly + // what the user asked for. + if (intent === "write" && hit.kept.length === 0 && otherScope) { + // The argument empties, but the REQUEST does not: sectors, sizes or a + // non-geo criterion carry real scope. Writing that is exactly what the user + // asked for, so the country comes off and the call goes through once. + const carry = `Drop ${hit.param} from the call and re-call ONCE with the rest of the request intact — the rest of the request carries real scope and must not be lost with it.`; + if (hit.kind === "home_country") { + return hit.axis === "exclude" + ? `${carry} Excluding ${hit.country} would empty the audience, so that part cannot be honoured at all — say so rather than silently ignoring it.` + : `${carry} The lens then carries no geo criterion, which is correct: the workspace already covers all of ${hit.country}.`; + } + if (hit.kind === "foreign_country") { + return `${carry} And say this workspace ${holds}, so there is no ${hit.country} audience to add — the result is scoped by the other criteria only.`; + } + if (anonymousWhole) { + return hit.axis === "exclude" + ? `${carry} Excluding the workspace's own country would empty the audience, so that part cannot be honoured at all — say so rather than silently ignoring it. ${unnamed}` + : `${carry} The lens then carries no geo criterion, which is correct: the workspace already covers its entire country. ${unnamed}`; + } + if (hit.kind === "country_indeterminate") { + return `${carry} This backend is custom-configured, so claim nothing about whether ${hit.country} is inside it.`; + } + return `${carry} And say what the workspace covers rather than presenting the audience as "${hit.value}".`; + } + + if (intent === "write" && hit.kept.length === 0) { + const stop = `A country-level value was the ONLY scope passed, so do NOT re-call this tool with ${hit.param} omitted: that persists a lens or filter change carrying no scope at all, to express something this workspace already is. Write NOTHING here.`; + if (hit.kind === "home_country") { + return hit.axis === "exclude" + ? `${stop} Excluding ${hit.country} would empty the entire audience, so it cannot be written either. Ask what should actually be carved out — ${narrow} — and only then write.` + : `${stop} Say the audience already covers all of ${hit.country}, then offer the axes that DO narrow it: sector, size, or ${narrow}.`; + } + if (hit.kind === "foreign_country") { + return `${stop} Say this workspace ${holds}, so there is no ${hit.country} audience to scope to and none can be created. Ask what to target inside it — ${narrow}.`; + } + if (anonymousWhole) { + return hit.axis === "exclude" + ? `${stop} Excluding the workspace's own country would empty the entire audience, so it cannot be written either. Ask what should actually be carved out — ${narrow} — and only then write. ${unnamed}` + : `${stop} Say the audience already covers the workspace entirely, then offer the axes that DO narrow it: sector, size, or ${narrow}. ${unnamed}`; + } + if (hit.kind === "country_indeterminate") { + return `${stop} This backend is custom-configured, so claim nothing about whether ${hit.country} is inside it. Ask what should be targeted — ${narrow} — before writing anything.`; + } + return `${stop} A supra-national scope is not an admin area and cannot be persisted. Say what the workspace covers, then ask which part of it to target — ${narrow}.`; + } + + // MIXED ARRAY — the argument also carries values that are fine. Answered + // before anything else, because every hint below ends in some form of "drop + // the argument" and here that is destructive: the tool returned before + // resolving them, so "omit `locations`" on ["Paris", "France"] loses Paris + // and re-runs unfiltered — widening the request instead of correcting it. + // The rule's own tiebreak is "keep the city, drop the country". The kind + // still decides what to TELL the user, so each one keeps its own sentence. + if (hit.kept.length > 0) { + const rest = hit.kept.map((v) => `"${v}"`).join(", "); + const plural = hit.kept.length > 1 ? "are" : "is"; + const surgical = `Do NOT omit ${hit.param} — ${rest} ${plural} valid and would be lost with it. Remove ONLY "${hit.value}" and re-call with the rest.`; + + // A mixed EXCLUSION is the one mixed case with no surgical answer. The + // country is the DOMINANT half of `exclude: ["France", "Paris"]` on FR: it + // asks for an empty result, and Paris is a detail inside it. "Remove only + // France and re-call with the rest" silently downgrades that to a + // Paris-only exclusion and hands back most of France as though it answered + // — a far narrower question than the one asked, with nothing to signal the + // substitution. Only a FOREIGN exclusion is a provable no-op and keeps its + // surgical recovery below. + if (hit.axis === "exclude" && hit.kind !== "foreign_country") { + const empties = + hit.kind === "home_country" + ? `Excluding ${hit.country} excludes this ENTIRE workspace` + : hit.kind === "country_indeterminate" && hit.country === null + ? `Excluding the whole workspace` + : hit.kind === "country_indeterminate" + ? `This backend is custom-configured, so whether excluding ${hit.country} empties the workspace is unknown, and` + : `A supra-national scope may well cover this whole workspace, so excluding it`; + return `${empties} — so the request as written cannot be honoured, and there is no partial version of it to run. Do NOT re-call with only ${rest} excluded: that answers a much narrower question than the one asked, and nothing in the result would show the substitution. Ask what was actually meant to be carved out — ${narrow} — before re-calling at all.`; + } + + if (hit.kind === "home_country") { + return `${surgical} The result then covers ${rest} — describe it as those places, NOT as the whole workspace.`; + } + if (hit.kind === "foreign_country") { + return `${surgical} And say this workspace ${holds}: there are no ${hit.country} leads in it either way, so the result speaks only for ${rest}.`; + } + if (anonymousWhole) { + return `${surgical} The result then covers ${rest} — describe it as those places, NOT as the whole workspace.`; + } + if (hit.kind === "country_indeterminate") { + return `${surgical} This backend is custom-configured, so claim nothing about whether ${hit.country} is inside it — report the result as covering ${rest}.`; + } + return `${surgical} And say what the workspace actually covers rather than presenting the result as "${hit.value}" — it speaks only for ${rest}.`; + } + + // EXCLUDING a country inverts every recovery, so it is answered first. The + // generic advice ("omit it and the result covers the whole workspace") is the + // precise opposite of what an exclusion asked for. + if (hit.axis === "exclude") { + if (hit.kind === "home_country") { + return `Excluding ${hit.country} excludes this ENTIRE workspace, so the result would be empty — and dropping ${hit.param} does the reverse of what was asked, returning every company instead. Neither is what the user wants: ask what they actually meant to carve out, then exclude ${narrow} instead.`; + } + if (hit.kind === "foreign_country") { + return `Nothing in this workspace is in ${hit.country}, so this exclusion changes nothing — it is a no-op, not an unsupported request. Drop ${hit.param} and say the result is unaffected. To carve something out for real, exclude ${narrow}.`; + } + if (anonymousWhole) { + return `Excluding the whole workspace leaves nothing, and dropping ${hit.param} does the reverse of what was asked, returning every company instead. Neither is what the user wants: ask what they actually meant to carve out, then exclude ${narrow} instead.`; + } + if (hit.kind === "country_indeterminate") { + return `This backend is custom-configured, so whether ${hit.country} is inside this workspace is unknown — the exclusion may remove everything or nothing. Do not guess: ask what should be carved out, then exclude ${narrow}.`; + } + return `A supra-national scope cannot be excluded as an admin area, and dropping ${hit.param} would instead include everything. Say what the workspace covers and ask what should be carved out, then exclude ${narrow}.`; + } + + if (hit.kind === "home_country") { + return `Whole-workspace intent = OMIT ${hit.param} entirely, then say the result covers everything. To narrow, pass ${narrow}. Do NOT retry with another spelling or a nearby city.`; + } + + if (anonymousWhole) { + // The request names no country, so there is nothing to hedge: omitting the + // argument answers it exactly. Only the country's NAME is withheld. + return `Whole-workspace intent = OMIT ${hit.param} entirely, then say the result covers everything in this workspace. ${unnamed} To narrow, pass ${narrow}. Do NOT retry with another spelling or a nearby city.`; + } + + if (hit.kind === "country_indeterminate") { + // Deliberately claims nothing about what this workspace holds. Omitting is + // only correct if the user meant the whole workspace, so it is offered as a + // condition rather than an instruction. + return `If you meant this entire workspace, OMIT ${hit.param} and say the result covers all of it. If you meant a place inside it, pass ${narrow}. Do NOT re-run unfiltered while presenting the result as an answer about ${hit.country} specifically, and do NOT retry another spelling.`; + } + + if (hit.kind === "foreign_country") { + // `home` is always defined here: an unknown home country routes to + // country_indeterminate above rather than asserting "foreign". + return `Do NOT simply drop ${hit.param} and re-run — an unfiltered result is ${home} data, which does NOT answer a question about ${hit.country}. Tell the user this workspace ${holds}, so there are no ${hit.country} leads to return. If they actually meant a same-named town inside it, qualify the value ("Germany, OH") — a qualified place name is accepted.`; + } + + return `Do NOT drop ${hit.param} and re-run as though the result answered this — a supra-national ask is not the same as the whole workspace. Say the workspace ${holds}, then offer the whole-workspace view as an explicit choice. To narrow instead, pass ${narrow}.`; +} + +/** + * ONE recovery for an argument that carries country-level values of DIFFERENT + * kinds. + * + * The per-kind hints above are each correct alone and mutually exclusive + * together: `locations: ["France", "Canada"]` on FR produced "OMIT locations + * entirely" immediately followed by "Do NOT simply drop locations and re-run", + * and an agent handed both has no safe move left. The snippet is explicit that + * the kinds are not interchangeable, so they cannot be concatenated — they have + * to be reconciled. + * + * The reconciliation: the ARGUMENT is handled once (every country-level value + * comes off it, and what survives decides whether it is dropped or trimmed), + * then each kind contributes only the thing it alone knows — what may be + * claimed about the result. The home country is the only kind that licenses an + * unfiltered re-run, and even then only for its own half of the ask. + */ +function reconciledHint( + hits: readonly CountryHit[], + region: GuardRegion, + intent: GuardIntent, + otherScope: boolean +): string { + const { param, axis, kept } = hits[0]; + const narrow = NARROW_EXAMPLES[region]; + const home = homeCountryName(region); + const holds = home ? `holds ${home} companies only` : "covers a single country"; + const quoted = (values: readonly string[]) => values.map((v) => `"${v}"`).join(", "); + const offending = quoted(hits.map((h) => h.value)); + const countriesOf = (kind: CountryHit["kind"]) => [ + ...new Set( + hits.filter((h) => h.kind === kind).map((h) => h.country).filter((c): c is string => !!c) + ), + ]; + + const homeCountry = countriesOf("home_country")[0]; + const foreign = countriesOf("foreign_country"); + const indeterminate = countriesOf("country_indeterminate"); + const supra = hits.filter((h) => h.kind === "supranational").map((h) => h.value); + + // What happens to the argument. Identical in both axes: the offending values + // leave, and the survivors decide whether that empties it. + const because = + new Set(hits.map((h) => h.kind)).size > 1 + ? `they are country-level or wider, and mixing kinds makes none of them usable` + : `not one of them is a usable location filter`; + const surgical = + kept.length > 0 + ? `Do NOT omit ${param} — ${quoted(kept)} ${kept.length > 1 ? "are" : "is"} valid and would be lost with it. Remove ALL of ${offending} in ONE re-call and keep the rest.` + : `Remove every one of ${offending} from ${param} — ${because}.`; + + // A WRITE tool with an argument that would be left empty. Same reason as in + // hintFor: the re-call every branch below ends in is itself the forbidden + // outcome. Stated once for the whole group, since the group is one argument. + if (intent === "write" && kept.length === 0 && otherScope) { + return `${surgical} Then re-call ONCE with the rest of the request intact — the rest of the request carries real scope and must not be lost with this argument. Say what the audience actually covers: ${[ + homeCountry ? `it already spans all of ${homeCountry}` : undefined, + foreign.length > 0 ? `this workspace ${holds}, so no ${foreign.join(", ")} audience can be added` : undefined, + indeterminate.length > 0 + ? `this backend is custom-configured, so claim nothing about ${indeterminate.join(", ")}` + : undefined, + supra.length > 0 ? `${quoted(supra)} is a supra-national scope, not a place` : undefined, + ] + .filter(Boolean) + .join("; ")}.`; + } + + if (intent === "write" && kept.length === 0) { + const cannot: string[] = []; + if (homeCountry) { + cannot.push( + axis === "exclude" + ? `excluding ${homeCountry} would empty the audience entirely` + : `the audience already covers all of ${homeCountry}` + ); + } + if (foreign.length > 0) { + cannot.push( + `this workspace ${holds}, so there is no ${foreign.join(", ")} audience to scope to` + ); + } + if (indeterminate.length > 0) { + cannot.push( + `this backend is custom-configured, so whether ${indeterminate.join(", ")} is inside it is unknown` + ); + } + if (supra.length > 0) { + cannot.push(`${quoted(supra)} is a supra-national scope, which cannot be persisted`); + } + return `${surgical} Then STOP: do NOT re-call this tool with ${param} omitted, which would persist a lens or filter change carrying no scope at all. Write NOTHING — ${cannot.join("; ")}. Say what the audience already covers, then offer the axes that DO narrow it: sector, size, or ${narrow}.`; + } + + const say: string[] = []; + if (axis === "exclude") { + if (homeCountry) { + say.push( + `excluding ${homeCountry} would empty the ENTIRE workspace, so that part cannot be honoured at all` + ); + } + if (foreign.length > 0) { + say.push(`excluding ${foreign.join(", ")} removes nothing — there is nothing here to exclude`); + } + if (indeterminate.length > 0) { + say.push( + `this backend is custom-configured, so whether ${indeterminate.join(", ")} is inside it is unknown and its exclusion may remove everything or nothing` + ); + } + if (supra.length > 0) { + say.push(`${quoted(supra)} is a supra-national scope, which is not an admin area and cannot be excluded`); + } + const tail = + kept.length > 0 + ? `The other exclusions still apply.` + : `Do NOT present the result as though any of these exclusions had been applied.`; + return `${surgical} Then say why: ${say.join("; ")}. ${tail} Ask what should actually be carved out, then exclude ${narrow}.`; + } + + // INCLUDE. Only the home country makes an unfiltered re-run meaningful, and + // only for its own half — so it is stated as a partial answer, never as THE + // answer. + const scope = + kept.length > 0 + ? `The result then covers ${quoted(kept)} — describe it as those places only.` + : homeCountry + ? `Omitting ${param} entirely then returns the whole workspace, which IS ${homeCountry}: that answers the ${homeCountry} part of the ask and nothing else — say so in those words.` + : `Do NOT re-run with ${param} omitted as though the unfiltered result answered this.`; + + if (foreign.length > 0) { + say.push( + `this workspace ${holds}, so it holds no ${foreign.join(", ")} companies and the result says nothing about ${foreign.join(", ")}` + ); + } + if (indeterminate.length > 0) { + say.push( + `this backend is custom-configured, so claim nothing about whether ${indeterminate.join(", ")} is inside it` + ); + } + if (supra.length > 0) { + say.push( + `${quoted(supra)} is a supra-national scope, not a place — say what the workspace covers and offer the whole-workspace view as an explicit choice, rather than letting the result stand for it` + ); + } + return `${surgical} ${scope} And be explicit that ${say.join("; ")}. To narrow, pass ${narrow}. Do NOT retry with another spelling.`; +} + +/** + * The whole write is fail-closed, in one instruction, when ANY exclusion in it + * cannot be dropped. + * + * This is deliberately not a per-argument hint. Hints are otherwise built per + * argument, and that produced two live instructions for one request: + * `{locations: ["France"], exclude_locations: ["France"]}` emitted "drop + * `locations` and re-call ONCE with the rest of the request intact" and then + * "write nothing" — and an agent that acts on the first has already persisted + * the inversion. The same contradiction appeared inside a single argument, + * where the surgical "remove these and re-call" was prepended to the STOP. + * + * So a blocked exclusion dominates the entire request and this text carries no + * re-call directive at all: nothing else can be written either, because it + * would be written under a scope that inverts what was asked. + */ +function blockedWriteHint( + hits: readonly CountryHit[], + region: GuardRegion +): string { + const narrow = NARROW_EXAMPLES[region]; + const blocked = hits.filter(blocksWrite); + const quoted = (values: readonly string[]) => values.map((v) => `"${v}"`).join(", "); + const names = quoted([...new Set(blocked.map((h) => h.value))]); + + const inverts = blocked.some(excludeBlocksWrite); + const unsupported = blocked.some(includeBlocksWrite); + + const why = [ + ...new Set( + blocked.map((hit) => { + if (hit.axis === "exclude") { + return hit.kind === "home_country" + ? `"${hit.value}" is this entire workspace, so excluding it asks for an empty audience` + : hit.kind === "country_indeterminate" + ? `this backend is custom-configured, so whether "${hit.value}" covers it is unknown` + : `"${hit.value}" is a supra-national scope, which may well cover this whole workspace`; + } + return hit.kind === "foreign_country" + ? `"${hit.value}" is outside this workspace, so there is no such audience to create` + : hit.kind === "country_indeterminate" + ? `this backend is custom-configured, so whether "${hit.value}" is inside it is unknown` + : `"${hit.value}" is a supra-national scope, which no single workspace can be scoped to`; + }) + ), + ].join("; "); + + // Country-level values that are NOT the blocker still have to come off + // whenever the corrected call is finally made, so they are named once here + // rather than in a second instruction that reads as an alternative. + // Minus the blockers themselves: the same value can arrive on both axes, and + // naming it twice reads as two different problems. + const blockedValues = new Set(blocked.map((h) => h.value)); + const alsoBad = [ + ...new Set( + hits.filter((h) => !blocksWrite(h) && !blockedValues.has(h.value)).map((h) => h.value) + ), + ]; + const also = + alsoBad.length > 0 + ? ` When a corrected call is eventually made, ${quoted(alsoBad)} must come off it too — country-level values are never usable.` + : ""; + + // What a "corrected" re-call would actually persist. The two polarities fail + // differently and an agent needs the one that applies to ITS call. + const consequence = inverts + ? `Any call that leaves ${names} out persists the OPPOSITE of the exclusion: an audience holding exactly what was asked to be removed. The rest of the request cannot be written either, because it would be written under that inverted scope.` + : `Any call that leaves ${names} out persists an audience for THIS workspace instead — a real, saved audience for a territory nobody asked about. The rest of the request does not survive on its own: sectors, sizes and keywords were qualifying ${names}, not a second request to be written without it.`; + + const bothNote = + inverts && unsupported + ? " Both failures are present in this one call, and neither is fixed by dropping the other." + : ""; + + // The verb has to match the polarity. An exclusion asked to REMOVE something, + // so "what should actually be carved out" is the question; an include asked to + // target something. Getting this backwards reads as a non-sequitur at exactly + // the moment the agent is deciding what to ask the user. + const ask = inverts + ? `Ask what should actually be carved out — ${narrow} — and write only once that is settled.` + : `Ask what should actually be targeted — ${narrow} — and write only once that is settled.`; + + return `Write NOTHING, and do NOT re-call this tool in any form — not without ${names}, and not "with the rest of the request intact". ${why}. ${consequence}${bothNote}${also} ${ask}`; +} + +/** + * The single source of truth for the code, message and hint. Every hit is + * reported, not just the first, so an agent fixes one envelope instead of + * discovering its bad values one turn at a time. + */ +export function countryLocationEnvelope( + hits: readonly CountryHit[], + region: GuardRegion, + intent: GuardIntent = "read", + otherScope = false, + /** + * Appended ONLY when the recovery actually tells the caller to omit the + * argument. Some tools need more than the omission to genuinely widen: + * `pull_followups` defaults `filtered` to true, so a re-call without `city` + * still reads through whatever Monitor filter was persisted earlier and hands + * back that stale cohort as though it were the whole workspace. + */ + omitCaveat?: string +): CountryLocationEnvelope { + const message = hits.map((hit) => messageFor(hit, region)).join(" "); + + // Every recovery below is phrased as "remove the value". For a country the + // guard only saw through an echoed NAME, removing the value is not enough and + // not even the right edit: the criterion selects it by ID, and a bare id is + // not classifiable (product#3939), so a re-call that dropped only the echoed + // name would persist the country filter past a guard that could no longer see + // it. Appended once for the whole request, after whichever recovery applies. + const selectedIds = [ + ...new Set( + hits + .filter((hit) => hit.selectedId !== undefined) + .map((hit) => `"${hit.selectedId}" (echoed as "${hit.value}")`) + ), + ]; + // The offending value lives in a criterion that has siblings. Two things go + // wrong without this: the recovery reads as "omit and you cover everything" + // when the siblings still scope the result, and "remove the locations" leaves + // a `location_ids` criterion holding nothing, which is invalid rather than + // neutral. + const siblings = [ + ...new Set(hits.flatMap((hit) => hit.siblingCriteria ?? [])), + ]; + // Whether removing the offending values actually EMPTIES the criterion. When + // a real place survives on it, "remove the whole criterion" would discard + // that place — the opposite mistake, and the same one the mixed-array branch + // exists to prevent. So the emptiness half is conditional; the surviving-scope + // half is not. + const emptiesCriterion = hits + .filter((hit) => (hit.siblingCriteria?.length ?? 0) > 0) + .every((hit) => hit.kept.length === 0); + const siblingNote = + siblings.length === 0 + ? "" + : `${ + emptiesCriterion + ? " Removing it leaves that `location_ids` criterion holding nothing, so remove the WHOLE criterion rather than just its `locations` property — an empty `location_ids` criterion is invalid, not neutral." + : " Keep the `location_ids` criterion itself — it still selects a real place once the country comes off." + } The other criteria in this filter (${siblings + .map((type) => `\`${type}\``) + .join(", ")}) survive and keep scoping the result, so describe it by them and never as covering everything.`; + + const idNote = + selectedIds.length === 0 + ? "" + : ` ${selectedIds.length > 1 ? "These are" : "This is"} selected by ID, not by name: remove ${selectedIds.join(", ")} from the \`location_ids\` criterion in \`lens_filter.items[].criteria[]\` itself. Deleting the echoed \`locations.results[].name\` row alone leaves the id selected and the country filter in force.`; + + // Reconciled across the WHOLE request before anything per-argument is + // emitted: one un-droppable exclusion fails the entire write closed, and a + // per-argument hint sitting beside it would be a live instruction to perform + // the mutation it forbids. + if (intent === "write" && hits.some(blocksWrite)) { + // No omitCaveat here: this branch forbids the re-call outright. + const blocked = blockedWriteHint(hits, region) + siblingNote + idNote; + return { code: COUNTRY_LEVEL_LOCATION, message, hint: blocked }; + } + + // Hints are built PER ARGUMENT+AXIS, not per value. Two arguments genuinely + // need two instructions; two KINDS on one argument need one reconciled + // instruction, because the per-kind recoveries contradict each other by + // design (only the home country licenses an unfiltered re-run). Insertion + // order is preserved so the envelope reads in the order the caller sent the + // arguments. + const groups = new Map(); + for (const hit of hits) { + const key = `${hit.param}\u0000${hit.axis}`; + const group = groups.get(key); + if (group) group.push(hit); + else groups.set(key, [hit]); + } + + const hints: string[] = []; + const push = (hint: string) => hints.push(hint); + for (const group of groups.values()) { + // Reconciled whenever the argument carries MORE THAN ONE offender, same + // kind or not. Same-kind hints do not contradict each other on what to + // claim, but they do on what to DO: two per-hit hints each said `Remove + // ONLY "Canada"` / `Remove ONLY "Germany"` and re-call, so following either + // one literally leaves the other country in place, and "ONLY" made that + // read as deliberate. One argument gets one instruction. + if (group.length === 1) push(hintFor(group[0], region, intent, otherScope)); + else push(reconciledHint(group, region, intent, otherScope)); + } + const joined = hints.join(" "); + // Conditioned on the assembled text rather than re-deriving the branch, so it + // cannot drift from what hintFor actually said. + const caveat = omitCaveat !== undefined && joined.includes("OMIT") ? ` ${omitCaveat}` : ""; + const hint = joined + caveat + siblingNote + idNote; + return { code: COUNTRY_LEVEL_LOCATION, message, hint }; +} + +/** + * For tools whose input-error idiom is to THROW. Throws the 4-field business + * envelope `{error, code, message, hint}` that server.ts recognises + * (`formatErrorForLLM` renders message + hint; `isLeadbayBusinessError` + * classifies it for Sentry). No `_meta` on purpose: `formatErrorForLLM` + * appends "(region=…, endpoint=…)" whenever `_meta.region` is present, which + * reads as a broken diagnostic for a guard that never made a request. + */ +export function rejectCountryLocations( + params: ReadonlyArray<{ input: unknown; param: string }>, + region: GuardRegion, + intent: GuardIntent = "read", + otherScope = false +): void { + const hits = detectCountryLocationsIn(params, region); + if (hits.length === 0) return; + const envelope = countryLocationEnvelope(hits, region, intent, otherScope); + throw { + error: true, + code: envelope.code, + message: envelope.message, + hint: envelope.hint, + }; +} + +/** + * For tools whose input-error idiom is to RETURN a named status and write + * nothing. + * + * Deliberately carries NO `error: true`: server.ts collapses any result with + * that flag into a bare `{content, isError}`, dropping structuredContent and + * every other field, and files a Sentry event — so an agent's ordinary input + * mistake would both lose the structured detail and page us. + */ +export function countryLocationStatus( + hits: readonly CountryHit[], + region: GuardRegion, + intent: GuardIntent = "read", + otherScope = false, + /** See `countryLocationEnvelope` — appended only to an OMIT recovery. */ + omitCaveat?: string +): { + status: typeof COUNTRY_LEVEL_STATUS; + code: typeof COUNTRY_LEVEL_LOCATION; + message: string; + hint: string; + country_locations: CountryHit[]; +} { + const envelope = countryLocationEnvelope(hits, region, intent, otherScope, omitCaveat); + return { + status: COUNTRY_LEVEL_STATUS, + code: envelope.code, + message: envelope.message, + hint: envelope.hint, + country_locations: [...hits], + }; +} + +/** + * Walk one `FilterCriterion[]` array for country-level values. + * + * The wire shape is the backend's `anyOf` over 10 typed criteria; only + * `location_ids` carries geography. Defined once because the same criteria + * array reaches us through two different envelopes — a lens `FilterPayload` + * (`lens_filter.items[].criteria[]`) and a Monitor `set_filter` + * (`criteria[]`) — and a rule enforced on one envelope but not the other is + * how the `set_filter` bypass happened in the first place. + */ +function criteriaHits( + criteria: unknown, + param: string, + region: GuardRegion +): CountryHit[] { + if (!Array.isArray(criteria)) return []; + const hits: CountryHit[] = []; + for (const criterion of criteria) { + const record = criterion as Record | null; + if (!record || record.type !== "location_ids") continue; + // The criterion carries its own polarity, and the recovery reverses with it. + const axis = record.is_excluded === true ? "exclude" : "include"; + // Everything else in this filter survives the recovery and keeps scoping + // the result. Only visible here — a hit built from one criterion's + // `locations` array cannot see the array it came from. + const siblings = [ + ...new Set( + criteria + .filter((other) => other !== criterion) + .map((other) => (other as Record | null)?.type) + .filter((type): type is string => typeof type === "string") + ), + ]; + hits.push( + ...detectCountryLocations(record.locations, param, region, axis).map((hit) => + siblings.length === 0 ? hit : { ...hit, siblingCriteria: siblings } + ) + ); + } + return hits; +} + +/** + * Walk a Monitor `set_filter` (`MonitorFilterItem`) for country-level values. + * + * This ingress is documented and load-bearing: `pull_followups` and + * `scan_portfolio_signals` both accept geography as a raw `location_ids` + * criterion, which never touches `city` / `city_id` and so never touched the + * argument-level guard. Leaving it open was worse than the bug it was meant to + * fix: the criterion reaches `POST /monitor/filter`, and both composites CATCH + * a failed POST and continue reading the Monitor view with whatever filter was + * previously persisted (pull-followups.ts, "Fall through — still try to read"). + * So the caller got a confident, plausible cohort drawn from a stale filter + * instead of a named `country_level_location` — the exact + * silently-wrong-answer class this guard exists to prevent. + */ +export function detectCountryLocationsInSetFilter( + setFilter: unknown, + param: string, + region: GuardRegion +): CountryHit[] { + if (!setFilter || typeof setFilter !== "object") return []; + const criteria = (setFilter as Record).criteria; + return criteriaHits(criteria, `${param}.criteria[].locations`, region); +} + +/** + * Walk a raw `FilterPayload` for country-level values. + * + * Two ingress paths, both best-effort and both tolerant of junk: + * - `lens_filter.items[].criteria[]` entries of type `location_ids`, whose + * `locations` array should hold admin-area ids but regularly receives + * names from an agent hand-writing the payload. + * - `locations.results[]` / `locations.parents[]`, the backend's echoed + * resolved-areas block (see mergeFilter in adjust-audience.ts). A filter + * round-tripped through get_lens_filter carries area NAMES here, which is + * the only way to spot a country that arrived as a numeric id. + * + * A hand-written criterion with numeric ids and no echoed block stays + * invisible — deciding whether id "1234" is a country needs a backend lookup + * this client does not have. Narrowing the ingress is the most this layer can + * do; the real fix is server-side (product#3939). + */ +/** + * Does this lens-filter payload carry scope OUTSIDE its location criteria? + * + * A sector, keyword or size criterion is real audience scope, and + * `update_lens_filter` REPLACES the whole filter — so refusing to write when a + * country rode along beside one would discard the criterion the user meant. + * Only the absence of any non-location criterion makes the country the sole + * scope, and only then does the write-stop apply. + */ +/** + * Ids whose echoed name is country-level. + * + * An opaque id counts as real scope everywhere else in this module — nothing + * here can tell "416102" from a country, which is the documented limit + * (product#3939). But when the SAME payload echoes a name for it, that limit + * does not apply: the id is known to be a country, and treating it as surviving + * scope told update_lens_filter to "remove the country and re-call with the + * remainder" — where the remainder is nothing, so the corrected call replaces + * the lens with an empty filter. WORKFLOWS.md requires writing nothing. + */ +function echoedCountryIds(filter: unknown, region: GuardRegion): Set { + const ids = new Set(); + const locations = (filter as Record | null)?.locations as + | Record + | undefined; + for (const block of ["results", "parents"] as const) { + const rows = locations?.[block]; + if (!Array.isArray(rows)) continue; + for (const row of rows) { + const record = row as Record | null; + const name = record?.name; + const id = record?.id; + if (typeof name !== "string") continue; + if (typeof id !== "string" && typeof id !== "number") continue; + if (classify(name, region) !== null) ids.add(String(id)); + } + } + return ids; +} + +export function filterCarriesOtherScope(filter: unknown, region: GuardRegion): boolean { + if (!filter || typeof filter !== "object") return false; + const lensFilter = (filter as Record).lens_filter as + | Record + | undefined; + const items = lensFilter?.items; + if (!Array.isArray(items)) return false; + const countryIds = echoedCountryIds(filter, region); + for (const item of items) { + const criteria = (item as Record | null)?.criteria; + if (!Array.isArray(criteria)) continue; + for (const criterion of criteria) { + const record = criterion as Record | null; + if (!record) continue; + if (record.type !== "location_ids") return true; + // A location criterion still counts when it names a real place beside the + // country — the filter is replaced wholesale, so stopping loses it. Ids + // the echoed block has already named as countries are not such places. + const values = (Array.isArray(record.locations) ? record.locations : []).filter( + (value) => !countryIds.has(String(value)) + ); + if (geoScopeSurvives([{ input: values, param: "locations" }], region)) { + return true; + } + } + } + return false; +} + +/** + * Whether a Monitor `set_filter` still carries usable scope once the + * country-level values come off it. + * + * The first version of this test lived inline in `pull_followups` and rejected + * every criterion of type `location_ids` outright — on the assumption that a + * location criterion holding a country holds nothing else. It can: + * `{city: "France", set_filter: {criteria: [{type: "location_ids", + * locations: ["99"]}]}}` puts the offender on `city`, so no hit knows about the + * Paris id, and a type-only test then reported "nothing else was requested" and + * advised `filtered:false` — discarding exactly the scope the caller asked for. + * So the VALUES decide, not the type. + * + * No echoed-id discount here, unlike `filterCarriesOtherScope`: a Monitor + * `set_filter` is the raw criteria array the caller sent, with no denormalized + * `locations` block to cross-reference. + */ +export function setFilterCarriesOtherScope( + setFilter: unknown, + region: GuardRegion +): boolean { + if (!setFilter || typeof setFilter !== "object") return false; + const criteria = (setFilter as Record).criteria; + if (!Array.isArray(criteria)) return false; + for (const criterion of criteria) { + const record = criterion as Record | null; + if (!record) continue; + // Any non-geo criterion is scope the recovery must not discard. + if (record.type !== "location_ids") return true; + const values = Array.isArray(record.locations) ? record.locations : []; + if (geoScopeSurvives([{ input: values, param: "locations" }], region)) return true; + } + return false; +} + +export function detectCountryLocationsInFilter( + filter: unknown, + region: GuardRegion +): CountryHit[] { + if (!filter || typeof filter !== "object") return []; + const hits: CountryHit[] = []; + const asRecord = filter as Record; + + const lensFilter = asRecord.lens_filter as Record | undefined; + const items = lensFilter?.items; + // id -> polarity, harvested from the criteria so an echoed row can inherit the + // polarity of the criterion that actually references it. Without this an + // EXCLUDED country carried as a numeric id and revealed only by its echoed + // name defaulted to "include", and the recovery then told the caller to omit + // the location — returning the whole workspace instead of explaining that the + // exclusion would empty it. + const polarityById = new Map(); + // id -> the OTHER criteria in the same item, for exactly the same reason the + // name path carries them. `criteriaHits` attaches siblings when the country + // arrives as text; a country arriving as a bare ID is discovered further down + // via its echoed name and used to build a separate hit, which had none. The + // recovery then said "remove the id from the criterion" without "and remove + // the criterion itself" — leaving a `location_ids` criterion holding nothing, + // which is invalid rather than neutral, on the very retry it authorized. + const siblingsById = new Map(); + // id -> the other IDs selected by the SAME location_ids criterion. The echoed + // path rebuilds its hit from one country NAME, so `kept` came out empty even + // when the criterion also selected a real place: `locations: ["27925","99"]` + // (France, Paris) produced "omit the whole locations property" alongside an id + // note saying to remove only 27925. Following the first discards Paris; + // following it literally leaves an invalid criterion. Neither is recoverable + // from the text, so the sibling ids travel with the hit. + const criterionIdsById = new Map(); + if (Array.isArray(items)) { + for (const item of items) { + const criteria = (item as Record | null)?.criteria; + hits.push( + ...criteriaHits( + criteria, + "filter.lens_filter.items[].criteria[].locations", + region + ) + ); + if (!Array.isArray(criteria)) continue; + for (const criterion of criteria) { + const record = criterion as Record | null; + if (!record || record.type !== "location_ids") continue; + const axis = record.is_excluded === true ? "exclude" : "include"; + const siblings = [ + ...new Set( + criteria + .filter((other) => other !== criterion) + .map((other) => (other as Record | null)?.type) + .filter((type): type is string => typeof type === "string") + ), + ]; + const ids = Array.isArray(record.locations) ? record.locations : []; + for (const id of ids) { + if (typeof id === "string" || typeof id === "number") { + const key = String(id); + // An id named by BOTH axes is contradictory input; the exclusion is + // the destructive reading, so it wins. + if (axis === "exclude" || !polarityById.has(key)) { + polarityById.set(key, axis); + } + if (siblings.length > 0) { + siblingsById.set(key, [ + ...new Set([...(siblingsById.get(key) ?? []), ...siblings]), + ]); + } + const others = ids + .filter((other) => typeof other === "string" || typeof other === "number") + .map((other) => String(other)) + .filter((other) => other !== key); + if (others.length > 0) { + criterionIdsById.set(key, [ + ...new Set([...(criterionIdsById.get(key) ?? []), ...others]), + ]); + } + } + } + } + } + } + + // The echoed blocks are consulted ONLY to put a name on an id the criteria + // already select. `results` and `parents` are both denormalized lookup data, + // and `parents` in particular is a breadcrumb: a filter legitimately scoped to + // Île-de-France echoes France as its ancestor, and reading that row as a + // selected value rejected a filter whose criteria never mentioned a country. + // So a row participates only when its id is actually referenced by a + // location_ids criterion — which is also the only case the id-only bypass + // needed it for. A country passed by NAME inside a criterion is caught by + // criteriaHits above and does not depend on this at all. + const locations = asRecord.locations as Record | undefined; + const echoedRows: Array<{ id: string; name: string }> = []; + for (const block of ["results", "parents"] as const) { + const rows = locations?.[block]; + if (!Array.isArray(rows)) continue; + for (const row of rows) { + const record = row as Record | null; + const name = record?.name; + if (typeof name !== "string") continue; + const id = record?.id; + if (typeof id !== "string" && typeof id !== "number") continue; + echoedRows.push({ id: String(id), name }); + } + } + + // Pass 1: which selected ids are themselves country-level. Needed before any + // hit is built, because a SECOND country in the same criterion must never be + // listed as a survivor to keep — that would tell the caller to preserve the + // very thing the other hit is telling them to remove. + const countryIds = new Set( + echoedRows + .filter(({ id, name }) => { + const axis = polarityById.get(id); + return ( + axis !== undefined && + detectCountryLocations(name, "probe", region, axis).length > 0 + ); + }) + .map(({ id }) => id) + ); + + // Pass 2: build the hits, each carrying what survives beside it. + for (const { id, name } of echoedRows) { + const axis = polarityById.get(id); + if (axis === undefined) continue; + const siblings = siblingsById.get(id); + // An id with no echoed name is unclassifiable but still something the + // caller asked for, so it survives — same rule detectCountryLocations + // already applies to a raw numeric id on a plain argument. + // Named where the echo can name them: the caller edits the criterion BY id, + // so the id has to be the thing said — but "99" alone is not something a + // human can check the recovery against. + const nameById = new Map(echoedRows.map((row) => [row.id, row.name])); + const kept = (criterionIdsById.get(id) ?? []) + .filter((other) => !countryIds.has(other)) + .map((other) => { + const label = nameById.get(other); + return label === undefined ? other : `${other} (${label})`; + }); + hits.push( + ...detectCountryLocations( + name, + `filter.lens_filter.items[].criteria[].locations`, + region, + axis, + id + ).map((hit) => ({ + ...hit, + ...(siblings === undefined ? {} : { siblingCriteria: siblings }), + ...(kept.length === 0 ? {} : { kept }), + })) + ); + } + + return hits; +} diff --git a/packages/core/src/composite/_country-names.ts b/packages/core/src/composite/_country-names.ts new file mode 100644 index 00000000..d4eb2233 --- /dev/null +++ b/packages/core/src/composite/_country-names.ts @@ -0,0 +1,635 @@ +/** + * Country-name data for the single-country-universe guard (product#3951). + * + * Each Leadbay backend serves exactly ONE country: the US backend IS the US + * universe, the FR backend IS France. A country name is therefore never a + * meaningful location criterion — whole-country intent means OMITTING the + * location filter. Worse, the backend's admin-area search deliberately + * excludes country nodes (product#3885), so a country label trigram-falls + * through to the nearest same-named town ("France" -> the commune of Francs, + * "United States" -> Statesboro) and silently fences the whole search to one + * village. See _country-guard.ts for the detector built on this data. + * + * Data only — this module imports NOTHING, so it can never take part in an + * import cycle. Vendored rather than pulled from `i18n-iso-countries` / + * `world-countries`: those ship hundreds of KB of locale JSON into a package + * whose only dependency is zod, and packages/mcp bundles into a single + * dist/bin.js that also ships as a .dxt/.mcpb. The ISO 3166-1 list changes + * about once a decade. + */ + +export interface CountryEntry { + /** ISO 3166-1 alpha-2, uppercase. Canonical identity. */ + iso2: string; + /** ISO 3166-1 alpha-3, uppercase. */ + iso3: string; + /** English short name — used verbatim in the error message. */ + name: string; + /** French short name — the FR-backend user's spelling. */ + nameFr: string; + /** + * Sovereign state's iso2 when this entry is a DEPENDENT TERRITORY. + * Undefined for sovereign states. + * + * This field is load-bearing, not documentation. A raw ISO 3166-1 list + * contains Guadeloupe, Martinique, Réunion, Mayotte, Guyane, Saint-Martin, + * Saint-Barthélemy, Nouvelle-Calédonie, Polynésie française, Wallis-et- + * Futuna and Saint-Pierre-et-Miquelon — every one a LEGITIMATE in-universe + * French admin area — and Puerto Rico, Guam, the US Virgin Islands, + * American Samoa and the Northern Mariana Islands on the US side. + * Rejecting those would be a strictly worse failure than the bug this + * guard exists to fix, so a territory whose sovereign IS the backend's + * home country is exempt (and still rejected on the other backend, where + * it really is out of universe). + */ + sovereign?: string; + /** + * Extra accepted labels, raw spelling. Official long forms, endonyms, + * colloquials, and "and"-less spellings — `Bosnia & Herzegovina` folds to + * a DIFFERENT key than `Bosnia and Herzegovina`, so the variant is data, + * not logic. + */ + aliases?: string[]; +} + +/** + * Fold a location label to a comparison key so spelling variants collapse. + * + * Two deliberate differences from the earlier US/FR-only guard on the + * MCP-first-delivery branch, both of which are bugs there: + * + * 1. Apostrophes map to a SPACE, not to nothing. Deleting them ran before + * the article strip, so "l'Allemagne" folded to "lallemagne" and the + * leading-article branch was dead for every elided French form + * (l'Allemagne, l'Espagne, l'Italie, l'Inde, l'Irlande). Harmless with a + * US/FR-only value list; silently fatal with a full country list. + * 2. The combining-diacritical range is written as an escape (̀-ͯ) + * rather than with raw combining marks inside the character class, which + * are invisible in most editors and are silently destroyed by a + * reformat — that would disable accent folding entirely. + * + * Matching is on the WHOLE normalized string, never a substring: that is what + * keeps `Île-de-France` ("ile de france") distinct from France, and it is also + * the user's override path — a qualified `"China, ME"` ("china me") is a + * different key from the country and passes straight through. + */ +export function countryKey(raw: string): string { + return raw + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .toLowerCase() + // Dots close up so initialisms fold tight: "U.S" and "U.S.A." -> "us"/"usa". + .replace(/\./g, "") + // Apostrophes SEPARATE (see note 1 above): "l'Allemagne" -> "l allemagne". + .replace(/['’`]/g, " ") + // Anything else non-alphanumeric is a separator, so "France?", "(France)" + // and "Bosnia & Herzegovina" all fold predictably. + .replace(/[^a-z0-9]+/g, " ") + .replace(/\s+/g, " ") + .trim() + // Longest article first, so "les" is never matched as "le" + leftover. + .replace(/^(les|the|la|le|l|el|los)\s+/, "") + .trim(); +} + +/** + * ISO 3166-1, alphabetical by alpha-2. + * + * Deliberately NOT included: + * - England / Scotland / Wales / Northern Ireland — not ISO 3166-1, and they + * would be legitimate admin areas if a UK universe ever exists. + * - Historical names (USSR, Yugoslavia, Zaire) — no upside, only + * false-positive surface. + */ +export const COUNTRIES: readonly CountryEntry[] = [ + { iso2: "AD", iso3: "AND", name: "Andorra", nameFr: "Andorre" }, + { iso2: "AE", iso3: "ARE", name: "United Arab Emirates", nameFr: "Émirats arabes unis", aliases: ["UAE"] }, + { iso2: "AF", iso3: "AFG", name: "Afghanistan", nameFr: "Afghanistan" }, + { iso2: "AG", iso3: "ATG", name: "Antigua and Barbuda", nameFr: "Antigua-et-Barbuda", aliases: ["Antigua & Barbuda", "Antigua"] }, + { iso2: "AI", iso3: "AIA", name: "Anguilla", nameFr: "Anguilla", sovereign: "GB" }, + { iso2: "AL", iso3: "ALB", name: "Albania", nameFr: "Albanie" }, + { iso2: "AM", iso3: "ARM", name: "Armenia", nameFr: "Arménie" }, + { iso2: "AO", iso3: "AGO", name: "Angola", nameFr: "Angola" }, + { iso2: "AQ", iso3: "ATA", name: "Antarctica", nameFr: "Antarctique" }, + { iso2: "AR", iso3: "ARG", name: "Argentina", nameFr: "Argentine" }, + { iso2: "AS", iso3: "ASM", name: "American Samoa", nameFr: "Samoa américaines", sovereign: "US" }, + { iso2: "AT", iso3: "AUT", name: "Austria", nameFr: "Autriche" }, + { iso2: "AU", iso3: "AUS", name: "Australia", nameFr: "Australie" }, + { iso2: "AW", iso3: "ABW", name: "Aruba", nameFr: "Aruba", sovereign: "NL" }, + { iso2: "AX", iso3: "ALA", name: "Åland Islands", nameFr: "Îles Åland", sovereign: "FI" }, + { iso2: "AZ", iso3: "AZE", name: "Azerbaijan", nameFr: "Azerbaïdjan" }, + { iso2: "BA", iso3: "BIH", name: "Bosnia and Herzegovina", nameFr: "Bosnie-Herzégovine", aliases: ["Bosnia & Herzegovina", "Bosnia"] }, + { iso2: "BB", iso3: "BRB", name: "Barbados", nameFr: "Barbade" }, + { iso2: "BD", iso3: "BGD", name: "Bangladesh", nameFr: "Bangladesh" }, + { iso2: "BE", iso3: "BEL", name: "Belgium", nameFr: "Belgique" }, + { iso2: "BF", iso3: "BFA", name: "Burkina Faso", nameFr: "Burkina Faso" }, + { iso2: "BG", iso3: "BGR", name: "Bulgaria", nameFr: "Bulgarie" }, + { iso2: "BH", iso3: "BHR", name: "Bahrain", nameFr: "Bahreïn" }, + { iso2: "BI", iso3: "BDI", name: "Burundi", nameFr: "Burundi" }, + { iso2: "BJ", iso3: "BEN", name: "Benin", nameFr: "Bénin" }, + { iso2: "BL", iso3: "BLM", name: "Saint Barthélemy", nameFr: "Saint-Barthélemy", sovereign: "FR" }, + { iso2: "BM", iso3: "BMU", name: "Bermuda", nameFr: "Bermudes", sovereign: "GB" }, + { iso2: "BN", iso3: "BRN", name: "Brunei Darussalam", nameFr: "Brunéi", aliases: ["Brunei"] }, + { iso2: "BO", iso3: "BOL", name: "Bolivia", nameFr: "Bolivie" }, + { iso2: "BQ", iso3: "BES", name: "Bonaire, Sint Eustatius and Saba", nameFr: "Pays-Bas caribéens", sovereign: "NL" }, + { iso2: "BR", iso3: "BRA", name: "Brazil", nameFr: "Brésil" }, + { iso2: "BS", iso3: "BHS", name: "Bahamas", nameFr: "Bahamas" }, + { iso2: "BT", iso3: "BTN", name: "Bhutan", nameFr: "Bhoutan" }, + { iso2: "BV", iso3: "BVT", name: "Bouvet Island", nameFr: "Île Bouvet", sovereign: "NO" }, + { iso2: "BW", iso3: "BWA", name: "Botswana", nameFr: "Botswana" }, + { iso2: "BY", iso3: "BLR", name: "Belarus", nameFr: "Biélorussie" }, + { iso2: "BZ", iso3: "BLZ", name: "Belize", nameFr: "Belize" }, + { iso2: "CA", iso3: "CAN", name: "Canada", nameFr: "Canada" }, + { iso2: "CC", iso3: "CCK", name: "Cocos (Keeling) Islands", nameFr: "Îles Cocos", sovereign: "AU" }, + { iso2: "CD", iso3: "COD", name: "Democratic Republic of the Congo", nameFr: "République démocratique du Congo", aliases: ["DR Congo", "DRC", "Congo-Kinshasa"] }, + { iso2: "CF", iso3: "CAF", name: "Central African Republic", nameFr: "République centrafricaine" }, + { iso2: "CG", iso3: "COG", name: "Congo", nameFr: "Congo", aliases: ["Republic of the Congo", "Congo-Brazzaville"] }, + { iso2: "CH", iso3: "CHE", name: "Switzerland", nameFr: "Suisse" }, + { iso2: "CI", iso3: "CIV", name: "Côte d'Ivoire", nameFr: "Côte d'Ivoire", aliases: ["Ivory Coast"] }, + { iso2: "CK", iso3: "COK", name: "Cook Islands", nameFr: "Îles Cook", sovereign: "NZ" }, + { iso2: "CL", iso3: "CHL", name: "Chile", nameFr: "Chili" }, + { iso2: "CM", iso3: "CMR", name: "Cameroon", nameFr: "Cameroun" }, + { iso2: "CN", iso3: "CHN", name: "China", nameFr: "Chine" }, + { iso2: "CO", iso3: "COL", name: "Colombia", nameFr: "Colombie" }, + { iso2: "CR", iso3: "CRI", name: "Costa Rica", nameFr: "Costa Rica" }, + { iso2: "CU", iso3: "CUB", name: "Cuba", nameFr: "Cuba" }, + { iso2: "CV", iso3: "CPV", name: "Cabo Verde", nameFr: "Cap-Vert", aliases: ["Cape Verde"] }, + { iso2: "CW", iso3: "CUW", name: "Curaçao", nameFr: "Curaçao", sovereign: "NL" }, + { iso2: "CX", iso3: "CXR", name: "Christmas Island", nameFr: "Île Christmas", sovereign: "AU" }, + { iso2: "CY", iso3: "CYP", name: "Cyprus", nameFr: "Chypre" }, + { iso2: "CZ", iso3: "CZE", name: "Czechia", nameFr: "Tchéquie", aliases: ["Czech Republic"] }, + { iso2: "DE", iso3: "DEU", name: "Germany", nameFr: "Allemagne", aliases: ["Deutschland"] }, + { iso2: "DJ", iso3: "DJI", name: "Djibouti", nameFr: "Djibouti" }, + { iso2: "DK", iso3: "DNK", name: "Denmark", nameFr: "Danemark" }, + { iso2: "DM", iso3: "DMA", name: "Dominica", nameFr: "Dominique" }, + { iso2: "DO", iso3: "DOM", name: "Dominican Republic", nameFr: "République dominicaine" }, + { iso2: "DZ", iso3: "DZA", name: "Algeria", nameFr: "Algérie" }, + { iso2: "EC", iso3: "ECU", name: "Ecuador", nameFr: "Équateur" }, + { iso2: "EE", iso3: "EST", name: "Estonia", nameFr: "Estonie" }, + { iso2: "EG", iso3: "EGY", name: "Egypt", nameFr: "Égypte" }, + { iso2: "EH", iso3: "ESH", name: "Western Sahara", nameFr: "Sahara occidental" }, + { iso2: "ER", iso3: "ERI", name: "Eritrea", nameFr: "Érythrée" }, + { iso2: "ES", iso3: "ESP", name: "Spain", nameFr: "Espagne", aliases: ["España"] }, + { iso2: "ET", iso3: "ETH", name: "Ethiopia", nameFr: "Éthiopie" }, + { iso2: "FI", iso3: "FIN", name: "Finland", nameFr: "Finlande" }, + { iso2: "FJ", iso3: "FJI", name: "Fiji", nameFr: "Fidji" }, + { iso2: "FK", iso3: "FLK", name: "Falkland Islands", nameFr: "Îles Malouines", sovereign: "GB" }, + { iso2: "FM", iso3: "FSM", name: "Micronesia", nameFr: "Micronésie" }, + { iso2: "FO", iso3: "FRO", name: "Faroe Islands", nameFr: "Îles Féroé", sovereign: "DK" }, + { iso2: "FR", iso3: "FRA", name: "France", nameFr: "France", aliases: ["French Republic", "République française"] }, + { iso2: "GA", iso3: "GAB", name: "Gabon", nameFr: "Gabon" }, + { iso2: "GB", iso3: "GBR", name: "United Kingdom", nameFr: "Royaume-Uni", aliases: ["UK", "Great Britain", "Britain", "United Kingdom of Great Britain and Northern Ireland"] }, + { iso2: "GD", iso3: "GRD", name: "Grenada", nameFr: "Grenade" }, + { iso2: "GE", iso3: "GEO", name: "Georgia", nameFr: "Géorgie" }, + { iso2: "GF", iso3: "GUF", name: "French Guiana", nameFr: "Guyane française", sovereign: "FR", aliases: ["Guyane"] }, + { iso2: "GG", iso3: "GGY", name: "Guernsey", nameFr: "Guernesey", sovereign: "GB" }, + { iso2: "GH", iso3: "GHA", name: "Ghana", nameFr: "Ghana" }, + { iso2: "GI", iso3: "GIB", name: "Gibraltar", nameFr: "Gibraltar", sovereign: "GB" }, + { iso2: "GL", iso3: "GRL", name: "Greenland", nameFr: "Groenland", sovereign: "DK" }, + { iso2: "GM", iso3: "GMB", name: "Gambia", nameFr: "Gambie" }, + { iso2: "GN", iso3: "GIN", name: "Guinea", nameFr: "Guinée" }, + { iso2: "GP", iso3: "GLP", name: "Guadeloupe", nameFr: "Guadeloupe", sovereign: "FR" }, + { iso2: "GQ", iso3: "GNQ", name: "Equatorial Guinea", nameFr: "Guinée équatoriale" }, + { iso2: "GR", iso3: "GRC", name: "Greece", nameFr: "Grèce" }, + { iso2: "GS", iso3: "SGS", name: "South Georgia and the South Sandwich Islands", nameFr: "Géorgie du Sud-et-les Îles Sandwich du Sud", sovereign: "GB" }, + { iso2: "GT", iso3: "GTM", name: "Guatemala", nameFr: "Guatemala" }, + { iso2: "GU", iso3: "GUM", name: "Guam", nameFr: "Guam", sovereign: "US" }, + { iso2: "GW", iso3: "GNB", name: "Guinea-Bissau", nameFr: "Guinée-Bissau" }, + { iso2: "GY", iso3: "GUY", name: "Guyana", nameFr: "Guyana" }, + { iso2: "HK", iso3: "HKG", name: "Hong Kong", nameFr: "Hong Kong", sovereign: "CN" }, + { iso2: "HM", iso3: "HMD", name: "Heard Island and McDonald Islands", nameFr: "Îles Heard-et-MacDonald", sovereign: "AU" }, + { iso2: "HN", iso3: "HND", name: "Honduras", nameFr: "Honduras" }, + { iso2: "HR", iso3: "HRV", name: "Croatia", nameFr: "Croatie" }, + { iso2: "HT", iso3: "HTI", name: "Haiti", nameFr: "Haïti" }, + { iso2: "HU", iso3: "HUN", name: "Hungary", nameFr: "Hongrie" }, + { iso2: "ID", iso3: "IDN", name: "Indonesia", nameFr: "Indonésie" }, + { iso2: "IE", iso3: "IRL", name: "Ireland", nameFr: "Irlande" }, + { iso2: "IL", iso3: "ISR", name: "Israel", nameFr: "Israël" }, + { iso2: "IM", iso3: "IMN", name: "Isle of Man", nameFr: "Île de Man", sovereign: "GB" }, + { iso2: "IN", iso3: "IND", name: "India", nameFr: "Inde" }, + { iso2: "IO", iso3: "IOT", name: "British Indian Ocean Territory", nameFr: "Territoire britannique de l'océan Indien", sovereign: "GB" }, + { iso2: "IQ", iso3: "IRQ", name: "Iraq", nameFr: "Irak" }, + { iso2: "IR", iso3: "IRN", name: "Iran", nameFr: "Iran" }, + { iso2: "IS", iso3: "ISL", name: "Iceland", nameFr: "Islande" }, + { iso2: "IT", iso3: "ITA", name: "Italy", nameFr: "Italie" }, + { iso2: "JE", iso3: "JEY", name: "Jersey", nameFr: "Jersey", sovereign: "GB" }, + { iso2: "JM", iso3: "JAM", name: "Jamaica", nameFr: "Jamaïque" }, + { iso2: "JO", iso3: "JOR", name: "Jordan", nameFr: "Jordanie" }, + { iso2: "JP", iso3: "JPN", name: "Japan", nameFr: "Japon" }, + { iso2: "KE", iso3: "KEN", name: "Kenya", nameFr: "Kenya" }, + { iso2: "KG", iso3: "KGZ", name: "Kyrgyzstan", nameFr: "Kirghizistan" }, + { iso2: "KH", iso3: "KHM", name: "Cambodia", nameFr: "Cambodge" }, + { iso2: "KI", iso3: "KIR", name: "Kiribati", nameFr: "Kiribati" }, + { iso2: "KM", iso3: "COM", name: "Comoros", nameFr: "Comores" }, + { iso2: "KN", iso3: "KNA", name: "Saint Kitts and Nevis", nameFr: "Saint-Christophe-et-Niévès" }, + { iso2: "KP", iso3: "PRK", name: "North Korea", nameFr: "Corée du Nord" }, + { iso2: "KR", iso3: "KOR", name: "South Korea", nameFr: "Corée du Sud" }, + { iso2: "KW", iso3: "KWT", name: "Kuwait", nameFr: "Koweït" }, + { iso2: "KY", iso3: "CYM", name: "Cayman Islands", nameFr: "Îles Caïmans", sovereign: "GB" }, + { iso2: "KZ", iso3: "KAZ", name: "Kazakhstan", nameFr: "Kazakhstan" }, + { iso2: "LA", iso3: "LAO", name: "Laos", nameFr: "Laos" }, + { iso2: "LB", iso3: "LBN", name: "Lebanon", nameFr: "Liban" }, + { iso2: "LC", iso3: "LCA", name: "Saint Lucia", nameFr: "Sainte-Lucie" }, + { iso2: "LI", iso3: "LIE", name: "Liechtenstein", nameFr: "Liechtenstein" }, + { iso2: "LK", iso3: "LKA", name: "Sri Lanka", nameFr: "Sri Lanka" }, + { iso2: "LR", iso3: "LBR", name: "Liberia", nameFr: "Liberia" }, + { iso2: "LS", iso3: "LSO", name: "Lesotho", nameFr: "Lesotho" }, + { iso2: "LT", iso3: "LTU", name: "Lithuania", nameFr: "Lituanie" }, + { iso2: "LU", iso3: "LUX", name: "Luxembourg", nameFr: "Luxembourg" }, + { iso2: "LV", iso3: "LVA", name: "Latvia", nameFr: "Lettonie" }, + { iso2: "LY", iso3: "LBY", name: "Libya", nameFr: "Libye" }, + { iso2: "MA", iso3: "MAR", name: "Morocco", nameFr: "Maroc" }, + { iso2: "MC", iso3: "MCO", name: "Monaco", nameFr: "Monaco" }, + { iso2: "MD", iso3: "MDA", name: "Moldova", nameFr: "Moldavie" }, + { iso2: "ME", iso3: "MNE", name: "Montenegro", nameFr: "Monténégro" }, + { iso2: "MF", iso3: "MAF", name: "Saint Martin", nameFr: "Saint-Martin", sovereign: "FR" }, + { iso2: "MG", iso3: "MDG", name: "Madagascar", nameFr: "Madagascar" }, + { iso2: "MH", iso3: "MHL", name: "Marshall Islands", nameFr: "Îles Marshall" }, + { iso2: "MK", iso3: "MKD", name: "North Macedonia", nameFr: "Macédoine du Nord" }, + { iso2: "ML", iso3: "MLI", name: "Mali", nameFr: "Mali" }, + { iso2: "MM", iso3: "MMR", name: "Myanmar", nameFr: "Birmanie", aliases: ["Burma"] }, + { iso2: "MN", iso3: "MNG", name: "Mongolia", nameFr: "Mongolie" }, + { iso2: "MO", iso3: "MAC", name: "Macao", nameFr: "Macao", sovereign: "CN" }, + { iso2: "MP", iso3: "MNP", name: "Northern Mariana Islands", nameFr: "Îles Mariannes du Nord", sovereign: "US" }, + { iso2: "MQ", iso3: "MTQ", name: "Martinique", nameFr: "Martinique", sovereign: "FR" }, + { iso2: "MR", iso3: "MRT", name: "Mauritania", nameFr: "Mauritanie" }, + { iso2: "MS", iso3: "MSR", name: "Montserrat", nameFr: "Montserrat", sovereign: "GB" }, + { iso2: "MT", iso3: "MLT", name: "Malta", nameFr: "Malte" }, + { iso2: "MU", iso3: "MUS", name: "Mauritius", nameFr: "Maurice" }, + { iso2: "MV", iso3: "MDV", name: "Maldives", nameFr: "Maldives" }, + { iso2: "MW", iso3: "MWI", name: "Malawi", nameFr: "Malawi" }, + { iso2: "MX", iso3: "MEX", name: "Mexico", nameFr: "Mexique" }, + { iso2: "MY", iso3: "MYS", name: "Malaysia", nameFr: "Malaisie" }, + { iso2: "MZ", iso3: "MOZ", name: "Mozambique", nameFr: "Mozambique" }, + { iso2: "NA", iso3: "NAM", name: "Namibia", nameFr: "Namibie" }, + { iso2: "NC", iso3: "NCL", name: "New Caledonia", nameFr: "Nouvelle-Calédonie", sovereign: "FR" }, + { iso2: "NE", iso3: "NER", name: "Niger", nameFr: "Niger" }, + { iso2: "NF", iso3: "NFK", name: "Norfolk Island", nameFr: "Île Norfolk", sovereign: "AU" }, + { iso2: "NG", iso3: "NGA", name: "Nigeria", nameFr: "Nigéria" }, + { iso2: "NI", iso3: "NIC", name: "Nicaragua", nameFr: "Nicaragua" }, + { iso2: "NL", iso3: "NLD", name: "Netherlands", nameFr: "Pays-Bas", aliases: ["Holland"] }, + { iso2: "NO", iso3: "NOR", name: "Norway", nameFr: "Norvège" }, + { iso2: "NP", iso3: "NPL", name: "Nepal", nameFr: "Népal" }, + { iso2: "NR", iso3: "NRU", name: "Nauru", nameFr: "Nauru" }, + { iso2: "NU", iso3: "NIU", name: "Niue", nameFr: "Niue", sovereign: "NZ" }, + { iso2: "NZ", iso3: "NZL", name: "New Zealand", nameFr: "Nouvelle-Zélande" }, + { iso2: "OM", iso3: "OMN", name: "Oman", nameFr: "Oman" }, + { iso2: "PA", iso3: "PAN", name: "Panama", nameFr: "Panama" }, + { iso2: "PE", iso3: "PER", name: "Peru", nameFr: "Pérou" }, + { iso2: "PF", iso3: "PYF", name: "French Polynesia", nameFr: "Polynésie française", sovereign: "FR" }, + { iso2: "PG", iso3: "PNG", name: "Papua New Guinea", nameFr: "Papouasie-Nouvelle-Guinée" }, + { iso2: "PH", iso3: "PHL", name: "Philippines", nameFr: "Philippines" }, + { iso2: "PK", iso3: "PAK", name: "Pakistan", nameFr: "Pakistan" }, + { iso2: "PL", iso3: "POL", name: "Poland", nameFr: "Pologne" }, + { iso2: "PM", iso3: "SPM", name: "Saint Pierre and Miquelon", nameFr: "Saint-Pierre-et-Miquelon", sovereign: "FR" }, + { iso2: "PN", iso3: "PCN", name: "Pitcairn", nameFr: "Pitcairn", sovereign: "GB" }, + { iso2: "PR", iso3: "PRI", name: "Puerto Rico", nameFr: "Porto Rico", sovereign: "US" }, + { iso2: "PS", iso3: "PSE", name: "Palestine", nameFr: "Palestine" }, + { iso2: "PT", iso3: "PRT", name: "Portugal", nameFr: "Portugal" }, + { iso2: "PW", iso3: "PLW", name: "Palau", nameFr: "Palaos" }, + { iso2: "PY", iso3: "PRY", name: "Paraguay", nameFr: "Paraguay" }, + { iso2: "QA", iso3: "QAT", name: "Qatar", nameFr: "Qatar" }, + { iso2: "RE", iso3: "REU", name: "Réunion", nameFr: "La Réunion", sovereign: "FR" }, + { iso2: "RO", iso3: "ROU", name: "Romania", nameFr: "Roumanie" }, + { iso2: "RS", iso3: "SRB", name: "Serbia", nameFr: "Serbie" }, + { iso2: "RU", iso3: "RUS", name: "Russia", nameFr: "Russie", aliases: ["Russian Federation"] }, + { iso2: "RW", iso3: "RWA", name: "Rwanda", nameFr: "Rwanda" }, + { iso2: "SA", iso3: "SAU", name: "Saudi Arabia", nameFr: "Arabie saoudite" }, + { iso2: "SB", iso3: "SLB", name: "Solomon Islands", nameFr: "Îles Salomon" }, + { iso2: "SC", iso3: "SYC", name: "Seychelles", nameFr: "Seychelles" }, + { iso2: "SD", iso3: "SDN", name: "Sudan", nameFr: "Soudan" }, + { iso2: "SE", iso3: "SWE", name: "Sweden", nameFr: "Suède" }, + { iso2: "SG", iso3: "SGP", name: "Singapore", nameFr: "Singapour" }, + { iso2: "SH", iso3: "SHN", name: "Saint Helena", nameFr: "Sainte-Hélène", sovereign: "GB" }, + { iso2: "SI", iso3: "SVN", name: "Slovenia", nameFr: "Slovénie" }, + { iso2: "SJ", iso3: "SJM", name: "Svalbard and Jan Mayen", nameFr: "Svalbard et Jan Mayen", sovereign: "NO" }, + { iso2: "SK", iso3: "SVK", name: "Slovakia", nameFr: "Slovaquie" }, + { iso2: "SL", iso3: "SLE", name: "Sierra Leone", nameFr: "Sierra Leone" }, + { iso2: "SM", iso3: "SMR", name: "San Marino", nameFr: "Saint-Marin" }, + { iso2: "SN", iso3: "SEN", name: "Senegal", nameFr: "Sénégal" }, + { iso2: "SO", iso3: "SOM", name: "Somalia", nameFr: "Somalie" }, + { iso2: "SR", iso3: "SUR", name: "Suriname", nameFr: "Suriname" }, + { iso2: "SS", iso3: "SSD", name: "South Sudan", nameFr: "Soudan du Sud" }, + { iso2: "ST", iso3: "STP", name: "Sao Tome and Principe", nameFr: "Sao Tomé-et-Principe" }, + { iso2: "SV", iso3: "SLV", name: "El Salvador", nameFr: "Salvador" }, + { iso2: "SX", iso3: "SXM", name: "Sint Maarten", nameFr: "Saint-Martin (partie néerlandaise)", sovereign: "NL" }, + { iso2: "SY", iso3: "SYR", name: "Syria", nameFr: "Syrie" }, + { iso2: "SZ", iso3: "SWZ", name: "Eswatini", nameFr: "Eswatini", aliases: ["Swaziland"] }, + { iso2: "TC", iso3: "TCA", name: "Turks and Caicos Islands", nameFr: "Îles Turques-et-Caïques", sovereign: "GB" }, + { iso2: "TD", iso3: "TCD", name: "Chad", nameFr: "Tchad" }, + { iso2: "TF", iso3: "ATF", name: "French Southern Territories", nameFr: "Terres australes et antarctiques françaises", sovereign: "FR" }, + { iso2: "TG", iso3: "TGO", name: "Togo", nameFr: "Togo" }, + { iso2: "TH", iso3: "THA", name: "Thailand", nameFr: "Thaïlande" }, + { iso2: "TJ", iso3: "TJK", name: "Tajikistan", nameFr: "Tadjikistan" }, + { iso2: "TK", iso3: "TKL", name: "Tokelau", nameFr: "Tokelau", sovereign: "NZ" }, + { iso2: "TL", iso3: "TLS", name: "Timor-Leste", nameFr: "Timor oriental", aliases: ["East Timor"] }, + { iso2: "TM", iso3: "TKM", name: "Turkmenistan", nameFr: "Turkménistan" }, + { iso2: "TN", iso3: "TUN", name: "Tunisia", nameFr: "Tunisie" }, + { iso2: "TO", iso3: "TON", name: "Tonga", nameFr: "Tonga" }, + { iso2: "TR", iso3: "TUR", name: "Türkiye", nameFr: "Turquie", aliases: ["Turkey"] }, + { iso2: "TT", iso3: "TTO", name: "Trinidad and Tobago", nameFr: "Trinité-et-Tobago", aliases: ["Trinidad & Tobago"] }, + { iso2: "TV", iso3: "TUV", name: "Tuvalu", nameFr: "Tuvalu" }, + { iso2: "TW", iso3: "TWN", name: "Taiwan", nameFr: "Taïwan" }, + { iso2: "TZ", iso3: "TZA", name: "Tanzania", nameFr: "Tanzanie" }, + { iso2: "UA", iso3: "UKR", name: "Ukraine", nameFr: "Ukraine" }, + { iso2: "UG", iso3: "UGA", name: "Uganda", nameFr: "Ouganda" }, + { iso2: "UM", iso3: "UMI", name: "United States Minor Outlying Islands", nameFr: "Îles mineures éloignées des États-Unis", sovereign: "US" }, + { + iso2: "US", + iso3: "USA", + name: "United States", + nameFr: "États-Unis", + aliases: [ + "United States of America", + "America", + "U.S.A.", + "États-Unis d'Amérique", + "Etats-Unis", + ], + }, + { iso2: "UY", iso3: "URY", name: "Uruguay", nameFr: "Uruguay" }, + { iso2: "UZ", iso3: "UZB", name: "Uzbekistan", nameFr: "Ouzbékistan" }, + { iso2: "VA", iso3: "VAT", name: "Holy See", nameFr: "Saint-Siège", aliases: ["Vatican", "Vatican City"] }, + { iso2: "VC", iso3: "VCT", name: "Saint Vincent and the Grenadines", nameFr: "Saint-Vincent-et-les-Grenadines" }, + { iso2: "VE", iso3: "VEN", name: "Venezuela", nameFr: "Venezuela" }, + { iso2: "VG", iso3: "VGB", name: "British Virgin Islands", nameFr: "Îles Vierges britanniques", sovereign: "GB" }, + { iso2: "VI", iso3: "VIR", name: "United States Virgin Islands", nameFr: "Îles Vierges des États-Unis", sovereign: "US", aliases: ["US Virgin Islands"] }, + { iso2: "VN", iso3: "VNM", name: "Vietnam", nameFr: "Viêt Nam", aliases: ["Viet Nam"] }, + { iso2: "VU", iso3: "VUT", name: "Vanuatu", nameFr: "Vanuatu" }, + { iso2: "WF", iso3: "WLF", name: "Wallis and Futuna", nameFr: "Wallis-et-Futuna", sovereign: "FR" }, + { iso2: "WS", iso3: "WSM", name: "Samoa", nameFr: "Samoa" }, + { iso2: "YE", iso3: "YEM", name: "Yemen", nameFr: "Yémen" }, + { iso2: "YT", iso3: "MYT", name: "Mayotte", nameFr: "Mayotte", sovereign: "FR" }, + { iso2: "ZA", iso3: "ZAF", name: "South Africa", nameFr: "Afrique du Sud" }, + { iso2: "ZM", iso3: "ZMB", name: "Zambia", nameFr: "Zambie" }, + { iso2: "ZW", iso3: "ZWE", name: "Zimbabwe", nameFr: "Zimbabwe" }, +]; + +/** + * "The whole of my own country" phrasings. + * + * These are NOT supra-national: on a single-country workspace "nationwide" or + * "partout en France" means exactly the home country, so the recovery is the + * same as naming the home country outright — omit the geo argument and say the + * result covers everything. Keeping them in the supra-national bucket produced + * the wrong advice (report the scope instead of just answering). + */ +export const WHOLE_WORKSPACE_LABELS: readonly string[] = [ + // The bare noun earns its place: it is what the wrapper strip REDUCES the + // common phrasings to. "country-wide" normalizes to "country wide" and loses + // its suffix to /\s+wide$/; "across the country" loses "across " and then + // the article. Both land on "country", and without this entry both missed + // every key and reached /geo/search — the exact fence this module prevents. + "Country", + "Nationwide", + "Nation-wide", + "Countrywide", + "Whole country", + "Entire country", + "The whole country", + "Everywhere", + "Anywhere", + "All regions", + "Tout le pays", + "Toute la France", + "Partout", + "Partout en France", + "Échelle nationale", + "National", + "Nationale", +]; + +/** + * Genuinely MULTI-country scopes. Not admin areas, and — unlike the labels + * above — not satisfiable by this workspace either: a request for "EMEA" or + * "APAC" is not answered by handing back one country's leads. The recovery is + * to name what the workspace covers, never to silently re-run unfiltered. + */ +export const SUPRANATIONAL_LABELS: readonly string[] = [ + "EU", + "European Union", + "Europe", + "EMEA", + "DACH", + "Benelux", + "Scandinavia", + "Nordics", + "North America", + "South America", + "Latin America", + "LATAM", + "APAC", + "Asia", + "Africa", + "Middle East", + "Worldwide", + "Global", + "Globally", + "International", + "All countries", + "Monde", + "Monde entier", + "Le monde entier", +]; + +/** Which country each backend region IS. `custom` has no home country. */ +export const HOME_COUNTRY_BY_REGION: Readonly> = { + us: "US", + fr: "FR", +}; + +/** + * Bare labels that ARE a legitimate in-universe admin area on that region and + * must therefore survive the guard. Keys are countryKey()-normalized. + * + * Kept deliberately minimal — every entry here is a country a user can no + * longer be warned about, so it earns its place by being the overwhelmingly + * more likely reading of the bare word on that backend. + */ +export const REGION_EXEMPT_KEYS: Readonly>> = { + // "Georgia": a US rep prospecting the STATE writes exactly this, and would + // never write "Georgia, US". "Jersey": colloquial New Jersey. + us: new Set(["georgia", "jersey"]), + // Empty by design: no French région or département shares a bare country + // name. Every FR homonym is a dependent territory (Guadeloupe, Martinique, + // La Réunion, Mayotte, Guyane…), which the `sovereign` rule already exempts. + fr: new Set(), +}; + +/** + * US state + DC postal codes. These collide with ~25 ISO alpha-2 country + * codes (CA Canada/California, IN India/Indiana, LA Laos/Louisiana — and + * _geo-helpers.ts already expands `la` to Los Angeles — plus ID, MO, MD, ME, + * AL, AR, MS, MT, NE, PA, SC, VA, DE, IE/…), and a two-letter code is the + * single most common way a US rep names a state. So on a US universe an + * alpha-2 that is also a state code is never treated as a foreign country. + * + * The HOME country's own alpha-2 still rejects: neither "US" nor "FR" is a + * state postal code, and French département codes are numeric. + */ +export const US_STATE_POSTAL_CODES: ReadonlySet = new Set([ + "al", "ak", "az", "ar", "ca", "co", "ct", "de", "dc", "fl", "ga", "hi", + "id", "il", "in", "ia", "ks", "ky", "la", "me", "md", "ma", "mi", "mn", + "ms", "mo", "mt", "ne", "nv", "nh", "nj", "nm", "ny", "nc", "nd", "oh", + "ok", "or", "pa", "ri", "sc", "sd", "tn", "tx", "ut", "vt", "va", "wa", + "wv", "wi", "wy", +]); + +function buildKeyIndex(): { + byKey: Map; + collisions: string[]; +} { + const byKey = new Map(); + const collisions: string[] = []; + for (const entry of COUNTRIES) { + const labels = [ + entry.name, + entry.nameFr, + entry.iso2, + entry.iso3, + ...(entry.aliases ?? []), + ]; + for (const label of labels) { + const key = countryKey(label); + if (!key) continue; + const existing = byKey.get(key); + if (existing && existing.iso2 !== entry.iso2) { + collisions.push(`${key}: ${existing.iso2} vs ${entry.iso2}`); + continue; // first writer wins; the collision is surfaced for the audit + } + byKey.set(key, entry); + } + } + return { byKey, collisions }; +} + +const KEY_INDEX = buildKeyIndex(); + +/** Normalized label -> country. Built from name, nameFr, iso2, iso3, aliases. */ +export const COUNTRY_BY_KEY: ReadonlyMap = KEY_INDEX.byKey; + +/** + * Any two distinct countries that fold to the SAME key. Must stay empty — a + * collision means one country silently shadows another. Asserted by + * test/unit/composite/country-guard-helpers.test.ts rather than thrown at + * import time, so a data slip fails a test instead of breaking the server. + */ +export const COUNTRY_KEY_COLLISIONS: readonly string[] = KEY_INDEX.collisions; + +export const SUPRANATIONAL_KEYS: ReadonlySet = new Set( + SUPRANATIONAL_LABELS.map((label) => countryKey(label)).filter(Boolean) +); + +/** + * Generic "the whole of somewhere" wrappers, applied to an already-normalized + * key. Stripping these is what lets a NAMED country inside a scope phrase be + * classified by that country rather than by the generic phrase: + * + * "all of France" -> "france" -> classified as France + * "across the United States" -> "united states" -> classified as the US + * "partout en France" -> "france" -> France, even on a US + * workspace (where it is + * FOREIGN, not "everything + * here") + * + * A phrase that leaves no country behind ("nationwide", "partout", + * "everywhere") falls through to WHOLE_WORKSPACE_KEYS and means this workspace. + * + * Kept deliberately narrow, and only ever applied when the remainder is a + * recognized country: "Whole Foods" -> "foods" and "across the Bay" -> "bay" + * match nothing, so ordinary place names are untouched. + */ +export const SCOPE_WRAPPERS: readonly RegExp[] = [ + // ORDER MATTERS: the stripper takes the FIRST wrapper that matches, so every + // longer form must precede the shorter one it contains. "the whole of France" + // hit the bare /^whole\s+/ first and was left as "of france", which matches no + // country — so the guard returned no hit and the caller went on to /geo/search + // and the same-named-town fence this module exists to prevent. There is no + // generic "of " strip: it belongs to this phrase, not to place names. + /^whole\s+of\s+/, + /^whole\s+/, + /^all\s+of\s+/, + /^all\s+/, + /^across\s+/, + /^entire\s+/, + /^anywhere\s+in\s+/, + /^everywhere\s+in\s+/, + /^nationwide\s+in\s+/, + /^throughout\s+/, + /^partout\s+en\s+/, + /^partout\s+dans\s+/, + /^toute\s+la\s+/, + /^tout\s+le\s+/, + /^toute\s+l\s+/, + /^dans\s+toute\s+la\s+/, + /^dans\s+tout\s+le\s+/, + /\s+wide$/, + /\s+entier$/, + /\s+entiere$/, +]; + +/** Leading articles, re-stripped after a wrapper is removed ("across the US"). */ +const LEADING_ARTICLE = /^(les|the|la|le|l|el|los|du|de|d)\s+/; + +/** + * Peel generic scope wrappers off a normalized key and return the embedded + * country key, or undefined when nothing recognizable is left. + */ +function embeddedKey( + key: string, + known: { has(candidate: string): boolean } +): string | undefined { + let current = key; + // Bounded loop: each pass must shorten the string, so it cannot spin. + for (let pass = 0; pass < 4; pass += 1) { + if (known.has(current)) return current; + let next = current; + for (const wrapper of SCOPE_WRAPPERS) { + const stripped = next.replace(wrapper, "").trim(); + if (stripped !== next && stripped.length > 0) { + next = stripped; + break; + } + } + next = next.replace(LEADING_ARTICLE, "").trim(); + if (next === current || next.length === 0) return undefined; + current = next; + } + return known.has(current) ? current : undefined; +} + +export function embeddedCountryKey(key: string): string | undefined { + return embeddedKey(key, COUNTRY_BY_KEY); +} + +/** + * The same wrapper strip, against the supra-national labels. + * + * "EU-wide", "all of Europe" and "across EMEA" are the phrasings a rep types, + * and an exact-key check saw none of them: the wrappers were only ever applied + * while looking for a COUNTRY, so these fell through to /geo/search and the + * same-named-town fence — the one outcome this module exists to prevent. Only + * consulted after `embeddedCountryKey` comes up empty, so a named country + * inside a scope phrase still decides the verdict ("all of France" is France, + * not a region). + */ +export function embeddedSupranationalKey(key: string): string | undefined { + return embeddedKey(key, SUPRANATIONAL_KEYS); +} + +export const WHOLE_WORKSPACE_KEYS: ReadonlySet = new Set( + WHOLE_WORKSPACE_LABELS.map((label) => countryKey(label)).filter(Boolean) +); + +/** + * The same wrapper strip again, against the whole-workspace labels. + * + * These labels were the only one of the three vocabularies matched by EXACT + * key, so a wrapper around one of them defeated it: "across the country", + * "country-wide" and "across the whole country" all missed and went on to + * /geo/search. Consulted only after `embeddedCountryKey` comes up empty, so a + * named country inside a scope phrase still wins ("all of France" is France). + */ +export function embeddedWholeWorkspaceKey(key: string): string | undefined { + return embeddedKey(key, WHOLE_WORKSPACE_KEYS); +} diff --git a/packages/core/src/composite/adjust-audience.ts b/packages/core/src/composite/adjust-audience.ts index 68c7b1e3..1c5e460a 100644 --- a/packages/core/src/composite/adjust-audience.ts +++ b/packages/core/src/composite/adjust-audience.ts @@ -9,6 +9,11 @@ import type { } from "../types.js"; import { resolveLocations } from "./_geo-helpers.js"; +import { + countryLocationStatus, + geoScopeSurvives, + detectCountryLocationsIn, +} from "./_country-guard.js"; import { leadbay_adjust_audience as ADJUST_AUDIENCE_DESCRIPTION } from "../tool-descriptions.generated.js"; interface AdjustAudienceParams { sectors?: string[]; // free text or sector ids @@ -306,17 +311,17 @@ export const adjustAudience: Tool = { type: "array", items: { type: "string" }, description: - "Geographic scope — free text (e.g. ['Indre-et-Loire', 'Bavaria', 'Austin']) or admin-area ids. Auto-resolved via /geo/search across all admin levels (city / county / département / région / state / country). Place names go HERE, never in sectors/keywords.", + "Geographic scope — free text (e.g. ['Indre-et-Loire', 'Texas', 'Austin']) or admin-area ids. Resolved via /geo/search at any level from state down to city (state / région / département / county / city). NEVER a country name — this workspace serves exactly ONE country, so a whole-country ask means passing NO location at all (rejected with COUNTRY_LEVEL_LOCATION). Place names go HERE, never in sectors/keywords.", }, location_ids: { type: "array", items: { type: "string" }, - description: "Explicit admin-area ids (skips /geo/search resolution)", + description: "Explicit admin-area ids (skips /geo/search resolution). Sub-country areas only — a country name here is rejected with COUNTRY_LEVEL_LOCATION.", }, exclude_locations: { type: "array", items: { type: "string" }, - description: "Locations to exclude (free text or ids)", + description: "Locations to exclude (free text or ids). Sub-country areas only — excluding a country is meaningless on a single-country workspace and is rejected.", }, lensId: { type: "number", description: "Lens id (escape hatch)" }, lensName: { @@ -340,12 +345,18 @@ export const adjustAudience: Tool = { outputSchema: { type: "object", description: - "Return shapes: 'applied' on success; 'ambiguous_sectors' when free-text sectors matched multiple candidates (re-call with sector_ids); 'ambiguous_locations' when free-text locations didn't resolve to one area — re-call with the chosen id via the SAME axis it came from (an include pick → location_ids; an EXCLUDE pick → exclude_locations, NOT location_ids, which would include it); 'lens_not_found' / 'ambiguous_lens' when a lensName didn't resolve to exactly one lens (re-call with lensId or an exact lensName).", + "Return shapes: 'applied' on success; 'ambiguous_sectors' when free-text sectors matched multiple candidates (re-call with sector_ids); 'ambiguous_locations' when free-text locations didn't resolve to one area — re-call with the chosen id via the SAME axis it came from (an include pick → location_ids; an EXCLUDE pick → exclude_locations, NOT location_ids, which would include it); 'country_level_location' when a country-level value was passed as a location (nothing was read or written; read `hint` — re-calling without the value is often itself wrong); 'lens_not_found' / 'ambiguous_lens' when a lensName didn't resolve to exactly one lens (re-call with lensId or an exact lensName).", properties: { status: { type: "string", description: - "'applied', 'ambiguous_sectors', 'ambiguous_locations', 'lens_not_found', or 'ambiguous_lens'.", + "'applied', 'ambiguous_sectors', 'ambiguous_locations', 'country_level_location', 'lens_not_found', or 'ambiguous_lens'.", + }, + country_locations: { + type: "array", + description: + "On 'country_level_location': per offending value {value, param, kind, country, axis, kept}. A country name is never a location criterion — each workspace serves exactly ONE country. The lens was NOT modified. The recovery BRANCHES on `country_locations[].axis` and `[].kind`; `hint` states the one for THIS call — follow it verbatim. When the country was the ONLY scope, or on ANY non-foreign `exclude`, the answer is to write NOTHING at all — re-calling with the value merely dropped persists a scope that inverts the request. Never retry with another spelling or a nearby city.", + items: { type: "object" }, }, sector_ambiguities: { type: "array", @@ -395,6 +406,54 @@ export const adjustAudience: Tool = { params: AdjustAudienceParams, ctx?: ToolContext ) => { + // Country-level geo values are rejected before the FIRST request. This + // tool MERGES criteria as a union rather than replacing them, so a bad + // value that got through would permanently add a village-sized fence to + // the lens filter — bailing here means nothing is read and nothing is + // written (product#3951). + const geoParams = [ + { input: params.locations, param: "locations" }, + { input: params.location_ids, param: "location_ids" }, + { input: params.exclude_locations, param: "exclude_locations", axis: "exclude" as const }, + ]; + const countryHits = detectCountryLocationsIn(geoParams, client.region); + if (countryHits.length > 0) { + // Same narrowing as new_lens: a sector or size adjustment riding along + // with a redundant country is a legitimate write, and only the + // country-ONLY request is the forbidden one. + const otherScope = + (params.sectors?.length ?? 0) > 0 || + (params.sector_ids?.length ?? 0) > 0 || + (params.exclude_sectors?.length ?? 0) > 0 || + (params.sizes?.length ?? 0) > 0 || + geoScopeSurvives(geoParams, client.region); + const envelope = countryLocationStatus( + countryHits, + client.region, + "write", + otherScope + ); + + // This tool MERGES into the lens's existing filter rather than replacing + // it, so the shared recovery's "the lens then carries no geo criterion" is + // a claim about a filter nobody has read. On a lens already scoped to + // Paris, `{sectors:["Healthcare"], locations:["France"]}` drops the + // country, merges Healthcare into the Paris criterion, and the result is + // Paris healthcare described as nationwide — the same confidently-wrong + // deliverable as the fence this guard exists to prevent, reached through + // the guard's own advice. + // + // Attached only where a re-call is actually authorized; the write-stop + // branches forbid one and must not read as though one were on the table. + if (!/re-call ONCE/.test(envelope.hint)) return envelope; + const lensRef = + params.lensId !== undefined ? String(params.lensId) : ""; + return { + ...envelope, + hint: `${envelope.hint} Before that re-call, read \`lens://${lensRef}/definition\` — location criteria MERGE here rather than replace, so any geography the lens already carries survives the re-call untouched. \`leadbay_pull_leads\` returns only \`lens: {id}\` and \`leadbay_my_lenses\` returns no filter, so neither can tell you what it is. If the lens is already scoped to a place, the edited audience stays scoped to it: say which places it actually covers, or clear those criteria first if whole-workspace is what was meant.`, + }; + } + const me = await client.resolveMe(); const isAdmin = me.admin === true; diff --git a/packages/core/src/composite/new-lens.ts b/packages/core/src/composite/new-lens.ts index e3be36e9..6181b6fb 100644 --- a/packages/core/src/composite/new-lens.ts +++ b/packages/core/src/composite/new-lens.ts @@ -17,6 +17,11 @@ import type { LeadbayClient } from "../client.js"; import type { Tool, ToolContext, LensPayload, FilterPayload } from "../types.js"; import { resolveSectors, mergeFilter, filterWriteBody } from "./adjust-audience.js"; import { resolveLocations } from "./_geo-helpers.js"; +import { + countryLocationStatus, + geoScopeSurvives, + detectCountryLocationsIn, +} from "./_country-guard.js"; import { leadbay_new_lens as NEW_LENS_DESCRIPTION } from "../tool-descriptions.generated.js"; @@ -73,12 +78,12 @@ export const newLens: Tool = { type: "array", items: { type: "string" }, description: - "Geographic scope — free text (e.g. ['Indre-et-Loire', 'Bavaria']) or admin-area ids. Auto-resolved via /geo/search across all admin levels (city / county / département / région / state / country). Scopes the lens to a sales territory.", + "Geographic scope — free text (e.g. ['Indre-et-Loire', 'Texas']) or admin-area ids. Resolved via /geo/search at any level from state down to city (state / région / département / county / city). NEVER a country name — this workspace serves exactly ONE country, so a whole-country ask means passing NO location at all (rejected with COUNTRY_LEVEL_LOCATION). Scopes the lens to a sales territory.", }, exclude_locations: { type: "array", items: { type: "string" }, - description: "Locations to exclude — free text or ids.", + description: "Locations to exclude — free text or ids. Sub-country areas only — excluding a country is meaningless on a single-country workspace and is rejected.", }, base: { type: "number", @@ -98,9 +103,9 @@ export const newLens: Tool = { outputSchema: { type: "object", description: - "'preview' (default, NOTHING created — confirm with the user then re-call with confirm:true); 'created' on success; 'ambiguous_sectors' / 'ambiguous_locations' when free-text sectors / locations didn't resolve (re-call with ids — the lens was NOT created).", + "'preview' (default, NOTHING created — confirm with the user then re-call with confirm:true); 'created' on success; 'ambiguous_sectors' / 'ambiguous_locations' when free-text sectors / locations didn't resolve (re-call with ids — the lens was NOT created); 'country_level_location' when a country-level value was passed as a location (the lens was NOT created; read `hint` — re-calling without the value is often itself wrong).", properties: { - status: { type: "string", description: "'preview', 'created', 'ambiguous_sectors', 'ambiguous_locations', or 'orphan_created' (filter write failed + cleanup failed)." }, + status: { type: "string", description: "'preview', 'created', 'ambiguous_sectors', 'ambiguous_locations', 'country_level_location', or 'orphan_created' (filter write failed + cleanup failed)." }, will_create: { type: "object", description: @@ -123,6 +128,12 @@ export const newLens: Tool = { "On 'ambiguous_locations': per text {location_text, matches:[{id,name,country,level,score}]}. Re-call the chosen id via the SAME axis the text came from — an include text → locations; a text from exclude_locations → exclude_locations (NOT locations, which would include the area the user asked to exclude). The `message` field names the correct param per text.", items: { type: "object" }, }, + country_locations: { + type: "array", + description: + "On 'country_level_location': per offending value {value, param, kind, country, axis, kept}. A country name is never a location criterion — each workspace serves exactly ONE country. The recovery BRANCHES on `country_locations[].axis` and `[].kind`; `hint` states the one for THIS call — follow it verbatim. When the country was the ONLY scope, or on ANY non-foreign `exclude`, the answer is to write NOTHING at all — re-calling with the value merely dropped persists a scope that inverts the request. Never retry with another spelling or a nearby city.", + items: { type: "object" }, + }, filter_applied: { type: "object", description: "On 'created': the FilterPayload POSTed to the new lens." }, computing_wishlist: { type: "boolean", @@ -139,6 +150,58 @@ export const newLens: Tool = { params: NewLensParams, ctx?: ToolContext ) => { + // 0. Country-level geo values are rejected BEFORE anything else, so a + // doomed lens costs neither a taxonomy fetch nor a /geo/search call. + // A country name would silently resolve to a same-named commune and + // leave a lens permanently fenced to one village (product#3951). + const geoParams = [ + { input: params.locations, param: "locations" }, + { input: params.exclude_locations, param: "exclude_locations", axis: "exclude" as const }, + ]; + const countryHits = detectCountryLocationsIn(geoParams, client.region); + if (countryHits.length > 0) { + // A lens built from sectors / sizes / a base lens is a real lens with a + // redundant country attached — refusing to write it would discard the + // criteria the user actually asked for. Only a country-ONLY request is + // the one WORKFLOWS.md forbids writing. + // + // A bare `base` id is NOT scope. It names a lens whose own geography + // we have not read, and a criteria-less clone inherits it wholesale — + // so `{name:"Nationwide", base:, locations:["France"]}` + // would be told to drop the country and retry, and the retry writes a + // Paris audience under the name "Nationwide". Counting an unread id as + // valid remaining scope is what authorized that. + const otherScope = + (params.sectors?.length ?? 0) > 0 || + (params.exclude_sectors?.length ?? 0) > 0 || + (params.sizes?.length ?? 0) > 0 || + // A real place on ANOTHER geo argument is scope too: `kept` only sees + // the argument its own value came from. + geoScopeSurvives(geoParams, client.region); + const envelope = countryLocationStatus( + countryHits, + client.region, + "write", + otherScope + ); + + // Every new lens is a CLONE — of `base` when given, of the active lens + // otherwise — so it starts with that lens's filter, geography included. + // Whenever the recovery authorizes a retry, the retry therefore inherits a + // geography nobody has looked at: "nationwide healthcare" on a Paris- + // scoped active lens writes Paris healthcare and calls it nationwide. + // Only attached when a re-call is actually on the table; the write-stop + // branches forbid one outright and must not read as though one existed. + const authorizesReCall = /re-call ONCE/.test(envelope.hint); + if (!authorizesReCall) return envelope; + return { + ...envelope, + hint: `${envelope.hint} Before that re-call, read the geography of the lens being cloned — \`lens://${ + params.base ?? "" + }/definition\`, which is the only place a lens's \`location_ids\` are visible (\`leadbay_pull_leads\` returns only \`lens: {id}\`, and \`leadbay_my_lenses\` returns no filter at all). A clone INHERITS that geography, so if the base carries any, the new lens is scoped to it no matter that no location was passed — and calling the result whole-workspace would be false. If it does carry geography, either clear it on the new lens or say plainly which places it actually covers.`, + }; + } + // 1. Resolve sectors FIRST — if any don't resolve, surface and bail before // creating a lens, so we never leave a half-built lens behind. const includeRes = await resolveSectors( diff --git a/packages/core/src/composite/pull-followups.ts b/packages/core/src/composite/pull-followups.ts index 368cde69..dc40f8ab 100644 --- a/packages/core/src/composite/pull-followups.ts +++ b/packages/core/src/composite/pull-followups.ts @@ -4,6 +4,12 @@ import { withAgentMemoryMeta } from "../agent-memory/index.js"; import { leadbay_pull_followups as PULL_FOLLOWUPS_DESCRIPTION } from "../tool-descriptions.generated.js"; import { resolveLocations } from "./_geo-helpers.js"; +import { + countryLocationStatus, + setFilterCarriesOtherScope, + detectCountryLocationsIn, + detectCountryLocationsInSetFilter, +} from "./_country-guard.js"; // B6/B7: coerce the legacy literal `"null"` LinkedIn string back to JSON null // across every contact-shaped object the response emits. @@ -128,7 +134,7 @@ export const pullFollowups: Tool = { criteria: { type: "array", description: - "Array of FilterCriterion objects per the backend FilterCriterion anyOf schema (location_ids, sector_ids, size, keywords, last_action, last_action_date, liked, yc, custom_field, custom_field_comparison).", + "Array of FilterCriterion objects per the backend FilterCriterion anyOf schema (location_ids, sector_ids, size, keywords, last_action, last_action_date, liked, yc, custom_field, custom_field_comparison). A `location_ids` criterion must carry sub-country admin areas only — a country name here is rejected with COUNTRY_LEVEL_LOCATION before anything is persisted.", items: { type: "object" }, }, }, @@ -136,7 +142,7 @@ export const pullFollowups: Tool = { city: { type: "string", description: - "Free-text city / region (e.g. 'Berlin', 'NYC', 'São Paulo'). The composite resolves it to an admin_area id via GET /geo/search and merges it into the active Monitor filter as a `location_ids` FilterCriterion. Ambiguous matches surface as `status: 'ambiguous_locations'` with `location_ambiguities[]` — the agent picks an id and re-calls via `city_id`.", + "Free-text city / region (e.g. 'Berlin', 'NYC', 'São Paulo'). The composite resolves it to an admin_area id via GET /geo/search and merges it into the active Monitor filter as a `location_ids` FilterCriterion. Ambiguous matches surface as `status: 'ambiguous_locations'` with `location_ambiguities[]` — the agent picks an id and re-calls via `city_id`. NEVER a country name: this workspace serves exactly ONE country, so a whole-country ask means omitting `city` entirely.", }, city_id: { type: "string", @@ -172,7 +178,7 @@ export const pullFollowups: Tool = { status: { type: "string", description: - "`ambiguous_locations` when a passed `city` matched multiple admin_areas; the agent picks an id from `location_ambiguities` and re-calls with `city_id`. Absent on the happy path.", + "`ambiguous_locations` when a passed `city` matched multiple admin_areas; the agent picks an id from `location_ambiguities` and re-calls with `city_id`. `country_level_location` when `city`, `city_id` or a `set_filter` `location_ids` criterion carried a country-level value — nothing was read and no filter was persisted; read `hint` for the recovery, which differs per case. Absent on the happy path.", }, location_ambiguities: { type: "array", @@ -180,6 +186,12 @@ export const pullFollowups: Tool = { "Per ambiguous city: {location_text, matches:[{id, name, country, level, score}]}. Only present when `status === 'ambiguous_locations'`.", items: { type: "object" }, }, + country_locations: { + type: "array", + description: + "Per offending value: {value, param, kind, country, axis, kept}. Only present when `status === 'country_level_location'`. The recovery BRANCHES on `country_locations[].axis` and `[].kind`; `hint` states the one for THIS call — follow it verbatim. Dropping the argument is NOT the general answer: on an `exclude` axis it returns the very companies the user asked to remove, and for a `foreign_country` an unfiltered result is this workspace's own leads, which answer a different question. Never retry with another spelling or a nearby city.", + items: { type: "object" }, + }, _meta: { type: "object", description: "Operator context: region + last-call latency.", @@ -203,6 +215,67 @@ export const pullFollowups: Tool = { const page = params.page ?? 0; const count = Math.min(params.count ?? 20, 200); + // A country name in `city` is refused before any request. It would not + // fail loudly: the admin-area index has no country nodes, so the resolver + // trigram-matches a same-named commune ("France" → Francs) and the whole + // view is silently fenced to one village (product#3951). The envelope is + // deliberately NOT wrapped in withAgentMemoryMeta — that helper calls + // resolveMe(), which would make a doomed call cost an HTTP round-trip. + // `set_filter` is checked alongside the shortcut args, not instead of them: + // geography can arrive as a raw `location_ids` criterion that never touches + // `city`/`city_id`. That path is the dangerous one — the criterion would + // reach POST /monitor/filter, and the failed-POST handler below deliberately + // falls through to read with the PREVIOUSLY stored filter, so the caller + // would get a confident cohort from a stale filter instead of a named error. + const countryHits = [ + ...detectCountryLocationsIn( + [ + { input: params.city, param: "city" }, + { input: params.city_id, param: "city_id" }, + ], + client.region + ), + ...detectCountryLocationsInSetFilter( + params.set_filter, + "set_filter", + client.region + ), + ]; + if (countryHits.length > 0) { + // What the caller asked for that ISN'T the country. A criterion of any + // other type survives the recovery, and so does a `location_ids` + // criterion that still holds a real place once the country comes off. + const survivingCriteria = + setFilterCarriesOtherScope(params.set_filter, client.region) || + countryHits.some((hit) => hit.kept.length > 0); + + // Two different recoveries, and giving the wrong one destroys data. + // + // With NOTHING else requested, omitting the geo argument is only half the + // fix: `filtered` defaults to true, so the Monitor view is still read + // through the filter persisted by an earlier call — an old Paris filter + // comes back looking like the whole workspace. + // + // But when the caller DID ask for other criteria, `filtered:false` + // bypasses them and `set_filter:{criteria:[]}` deletes them, turning a + // requested date-scoped read into an all-dates org-wide one. There the + // answer is to re-send the corrected filter, which overwrites the stale + // one anyway — so the stale-filter problem solves itself and the advice + // above would be actively destructive. + const omitCaveat = survivingCriteria + ? "Do NOT pass `filtered:false`, and do NOT send `set_filter:{criteria:[]}`: either one discards the other criteria in this request, turning a scoped read into an unscoped one. Re-call with `set_filter` carrying the SURVIVING criteria and the country criterion removed — that overwrites the stored filter with the corrected one, so no stale filter can leak in. Then describe the result by the criteria that remain, never as covering everything." + : "Omitting the geo argument is NOT enough here: `filtered` defaults to true, so the Monitor view is still read through the filter persisted from an earlier call. Nothing else was requested, so pass `filtered:false` as well (or clear the stored filter with `set_filter:{criteria:[]}`) — otherwise a stale cohort comes back looking like the whole workspace. `active_filters` in the response reports what was actually applied; check it before describing the scope."; + + return { + ...countryLocationStatus(countryHits, client.region, "read", false, omitCaveat), + leads: [], + active_filters: null, + pagination: null, + total_excluded_by_pushback: 0, + _meta: { region: client.region, latency_ms: null }, + }; + } + // Geo-shortcut: resolve city / city_id → location_ids, then merge into // the effective set_filter. city_id bypasses the resolver; city goes // through /geo/search with the same ambiguity-surfacing pattern that diff --git a/packages/core/src/composite/scan-portfolio-signals.ts b/packages/core/src/composite/scan-portfolio-signals.ts index 86ee96e2..3ff72e6c 100644 --- a/packages/core/src/composite/scan-portfolio-signals.ts +++ b/packages/core/src/composite/scan-portfolio-signals.ts @@ -9,6 +9,12 @@ import type { import { withAgentMemoryMeta } from "../agent-memory/index.js"; import { reshapeWebFetchContent } from "./_web-fetch-helpers.js"; import { resolveLocations } from "./_geo-helpers.js"; +import { + countryLocationStatus, + setFilterCarriesOtherScope, + detectCountryLocationsIn, + detectCountryLocationsInSetFilter, +} from "./_country-guard.js"; import { leadbay_scan_portfolio_signals as SCAN_PORTFOLIO_SIGNALS_DESCRIPTION } from "../tool-descriptions.generated.js"; @@ -177,7 +183,7 @@ export const scanPortfolioSignals: Tool = { city: { type: "string", description: - "Free-text city / region to scope the Monitor portfolio before scanning (resolved via /geo/search, same as leadbay_pull_followups). Ignored when `leadIds` is given.", + "Free-text city / region to scope the Monitor portfolio before scanning (resolved via /geo/search, same as leadbay_pull_followups). Ignored when `leadIds` is given. NEVER a country name: this workspace serves exactly ONE country, so a whole-country ask means omitting `city` entirely.", }, city_id: { type: "string", @@ -187,7 +193,7 @@ export const scanPortfolioSignals: Tool = { set_filter: { type: "object", description: - "Optional Monitor FilterItem ({criteria: FilterCriterion[]}) to scope the portfolio before scanning. Persisted server-side then applied, mirroring leadbay_pull_followups. Ignored when `leadIds` is given.", + "Optional Monitor FilterItem ({criteria: FilterCriterion[]}) to scope the portfolio before scanning. Persisted server-side then applied, mirroring leadbay_pull_followups. Ignored when `leadIds` is given. A `location_ids` criterion must carry sub-country admin areas only — a country name here is rejected with COUNTRY_LEVEL_LOCATION before anything is persisted.", properties: { criteria: { type: "array", items: { type: "object" } }, }, @@ -238,13 +244,19 @@ export const scanPortfolioSignals: Tool = { status: { type: "string", description: - "`ambiguous_locations` when a passed `city` matched multiple admin_areas; pick an id from `location_ambiguities` and re-call with `city_id`. Absent on the happy path.", + "`ambiguous_locations` when a passed `city` matched multiple admin_areas; pick an id from `location_ambiguities` and re-call with `city_id`. `country_level_location` when `city`, `city_id` or a `set_filter` `location_ids` criterion carried a country name — nothing was scanned and no filter was persisted. Absent on the happy path.", }, location_ambiguities: { type: "array", description: "Only present when status === 'ambiguous_locations'.", items: { type: "object" }, }, + country_locations: { + type: "array", + description: + "Per offending value: {value, param, kind, country, axis, kept}. Only present when `status === 'country_level_location'`. The recovery BRANCHES on `country_locations[].axis` and `[].kind`; `hint` states the one for THIS call — follow it verbatim. Dropping the argument is NOT the general answer: on an `exclude` axis it returns the very companies the user asked to remove, and for a `foreign_country` an unfiltered result is this workspace's own leads, which answer a different question. Never retry with another spelling or a nearby city.", + items: { type: "object" }, + }, _meta: { type: "object", properties: { @@ -282,6 +294,63 @@ export const scanPortfolioSignals: Tool = { if (params.leadIds.length > maxLeads) truncatedAt = maxLeads; portfolio = sliced.map((id) => ({ id, name: null, location: null })); } else { + // A country in `city` is refused before any request — it would resolve + // to a same-named commune and silently scan one village (product#3951). + // Deliberately inside this branch: when `leadIds` is supplied the schema + // documents `city` as ignored, so failing on it would be a false alarm. + // Unwrapped by withAgentMemoryMeta, which would cost a resolveMe() call. + // Includes `set_filter`: geography can arrive as a raw `location_ids` + // criterion that never touches `city`/`city_id`, and that criterion would + // be persisted server-side by the store-then-apply POST below — scanning + // a village, or falling back to a stale filter, either way silently. + const countryHits = [ + ...detectCountryLocationsIn( + [ + { input: params.city, param: "city" }, + { input: params.city_id, param: "city_id" }, + ], + client.region + ), + ...detectCountryLocationsInSetFilter( + params.set_filter, + "set_filter", + client.region + ), + ]; + if (countryHits.length > 0) { + // Same split as pull_followups. Where the caller asked for OTHER + // criteria, the shared "omit the locations" recovery contradicts the + // sibling note beside it, and acting on the omission sends a filter that + // either drops those criteria or fails validation — and a failed POST + // here falls back to an UNfiltered scan, so a requested date-scoped scan + // silently becomes an all-dates one. + // + // Unlike pull_followups there is no stale-filter half to add when + // nothing else was requested: this tool sends `filtered` only when it + // stored the filter itself, so dropping the geo argument really does + // scan unfiltered. That case needs no caveat at all. + const survivingCriteria = + setFilterCarriesOtherScope(params.set_filter, client.region) || + countryHits.some((hit) => hit.kept.length > 0); + return { + ...countryLocationStatus( + countryHits, + client.region, + "read", + false, + survivingCriteria + ? "Re-call with `set_filter` carrying the SURVIVING criteria and the country criterion removed — do NOT send an empty `criteria` array and do NOT drop the other criteria, which are part of the request. A `set_filter` that fails validation is not a no-op here: the failed POST makes this tool scan UNFILTERED, so the criteria you were asked to keep would silently vanish from the scan. Describe the result by the criteria that remain, never as covering everything." + : undefined + ), + matched: [], + not_researched: [], + scanned_count: 0, + matched_count: 0, + quota_exceeded: false, + _meta: { region: client.region }, + }; + } + // Geo / filter scope, then paginate /monitor (same store-then-apply // mechanism as leadbay_pull_followups). let effectiveSetFilter: MonitorFilterItem | undefined = params.set_filter; diff --git a/packages/core/src/composite/tour-plan.ts b/packages/core/src/composite/tour-plan.ts index c5474978..c5e0e45d 100644 --- a/packages/core/src/composite/tour-plan.ts +++ b/packages/core/src/composite/tour-plan.ts @@ -16,6 +16,10 @@ import type { LeadbayClient } from "../client.js"; import type { Tool, ToolContext } from "../types.js"; import { pullFollowups } from "./pull-followups.js"; import { pullLeads } from "./pull-leads.js"; +import { + countryLocationStatus, + detectCountryLocationsIn, +} from "./_country-guard.js"; import { leadbay_tour_plan as TOUR_PLAN_DESCRIPTION } from "../tool-descriptions.generated.js"; @@ -164,7 +168,7 @@ export const tourPlan: Tool = { city: { type: "string", description: - "Free-text city or region (e.g. 'Limoges', 'Bay Area'). Resolved via the same /geo/search the followups_map uses. Ambiguous matches surface as `status: ambiguous_locations` with location_ambiguities[]; pick a location id and re-call with city_id.", + "Free-text city or region (e.g. 'Limoges', 'Bay Area'). Resolved via the same /geo/search the followups_map uses. Ambiguous matches surface as `status: ambiguous_locations` with location_ambiguities[]; pick a location id and re-call with city_id. NEVER a country name — and unlike the Monitor tools the fix is NOT to omit this argument: a tour with no city returns arbitrary leads from the whole workspace, which is not an itinerary. Ask which city or region the user is visiting and pass that.", }, city_id: { type: "string", @@ -223,12 +227,18 @@ export const tourPlan: Tool = { status: { type: "string", description: - "'ambiguous_locations' when the passed `city` matched multiple admin areas — pick an id from location_ambiguities and re-call with city_id.", + "'ambiguous_locations' when the passed `city` matched multiple admin areas — pick an id from location_ambiguities and re-call with city_id. 'country_level_location' when `city` was a country name — do NOT drop the argument (a city-less tour is arbitrary nationwide leads); ask which city or region to use. The itinerary arrays are empty and nothing was fetched.", }, location_ambiguities: { type: "array", items: { type: "object" }, }, + country_locations: { + type: "array", + description: + "Per offending value: {value, param, kind, country}. Only present when `status === 'country_level_location'`. Unlike the Monitor tools, the recovery here is NOT to drop `city`: a tour with no city returns arbitrary leads from the whole workspace, which is not an itinerary. Ask which city or region the user is visiting and re-call with that — see `hint`.", + items: { type: "object" }, + }, _meta: { type: "object", properties: { @@ -244,6 +254,50 @@ export const tourPlan: Tool = { params: TourPlanParams, ctx?: ToolContext, ) => { + // Guard here rather than relying on the delegated pullFollowups call: + // the two pulls run in parallel, so leaving it to the delegate would + // still spend the pullLeads request on a doomed tour. A country in `city` + // would silently fence the itinerary to a same-named commune + // (product#3951). + const countryHits = detectCountryLocationsIn( + [ + { input: params.city, param: "city" }, + { input: params.city_id, param: "city_id" }, + ], + client.region + ); + if (countryHits.length > 0) { + const envelope = countryLocationStatus(countryHits, client.region); + return { + ...envelope, + // The shared hint says "omit the geo argument and the result covers the + // whole workspace" — right for a Monitor pull, WRONG here. tour_plan + // accepts no city and then returns arbitrary nationwide leads, which is + // not an itinerary; the prompt contract requires asking which city or + // region the user is visiting (prompts/leadbay_plan_tour_in_city.md.tmpl). + // So this tool overrides the recovery rather than forwarding advice that + // would produce a confident, useless tour. + hint: + "A tour needs a place to walk around in, so there is nothing to omit here: do NOT re-call without `city`, which would return arbitrary leads from across the whole workspace as an itinerary. Ask which city or region the user is actually visiting, then re-call with that. Do NOT retry another spelling of the country.", + monitor_leads: [], + discover_leads: [], + // A STRING, not null: the declared schema allows only a string, and a + // client that validates structuredContent would reject the whole + // rejection payload — hiding the very recovery hint it carries. + discover_filter_note: + "No Discover leads were fetched: the request named a country, which cannot scope an itinerary.", + map_locations: [], + map_summary: { + total_leads: 0, + leads_with_coords: 0, + leads_without_coords: 0, + }, + city: params.city ?? null, + city_id: params.city_id ?? null, + _meta: { region: client.region }, + }; + } + const followupsCount = params.followups_count ?? DEFAULT_FOLLOWUPS_COUNT; const discoverCount = params.discover_count ?? DEFAULT_DISCOVER_COUNT; diff --git a/packages/core/src/tool-descriptions.generated.ts b/packages/core/src/tool-descriptions.generated.ts index 49b344d5..85fbd8d4 100644 --- a/packages/core/src/tool-descriptions.generated.ts +++ b/packages/core/src/tool-descriptions.generated.ts @@ -441,7 +441,7 @@ Trigger phrases: "narrow the audience to ", "add to my l **Memory:** recall + capture via \`leadbay_agent_memory_*\` tools. -Do NOT use for: "create a new lens called X" → \`leadbay_new_lens\`; "make a new audience for Y" → \`leadbay_new_lens\`; "show me / list / switch my lenses" → \`leadbay_my_lenses\`; "focus on a kind of company beyond sector/size (e.g. 'hospitals running their own IT')" → \`leadbay_refine_prompt\`. +Do NOT use for: "companies anywhere in this workspace's OWN country / nationwide (a foreign country is unsupported, not unfiltered — call nothing)" → \`leadbay_pull_leads\`; "create a new lens called X" → \`leadbay_new_lens\`; "make a new audience for Y" → \`leadbay_new_lens\`; "show me / list / switch my lenses" → \`leadbay_my_lenses\`; "focus on a kind of company beyond sector/size (e.g. 'hospitals running their own IT')" → \`leadbay_refine_prompt\`. Prefer when: user wants to change an EXISTING lens's sectors/sizes. If the user NAMES a lens ('my Joinery lens'), you MUST pass lensName with that name — do NOT edit the active lens. To create a brand-new lens use leadbay_new_lens instead. @@ -454,6 +454,7 @@ Examples that should NOT invoke this tool (sound similar, route elsewhere): - "Create a lens called Joinery for fintech." - "Show me my lenses." - "Focus on hospitals that run their own IT." +- "Show me companies anywhere in the US." ## RENDER (quick) @@ -468,7 +469,29 @@ Restrict (or expand) the lens audience by sector / size. Free-text sectors are a **Targeting a lens — READ THIS.** By default this edits the user's ACTIVE lens. **If the user names a lens** ("add fintech to my **Joinery** lens", "in my Nordics lens, exclude retail"), you MUST pass \`lensName\` with that name (\`lensName:"Joinery"\`). Do NOT silently edit the active lens when a different one was named — that corrupts the wrong audience and is a top friction source. The name resolves against the user's lenses (case-insensitive, exact then unique-substring); it is edit-only and does NOT change which lens is active. An unmatched name returns \`status:"lens_not_found"\` with the lens list, and a name matching several returns \`status:"ambiguous_lens"\` with the candidates — surface them and re-call with the exact \`lensName\` or a \`lensId\`. Use \`leadbay_my_lenses\` if the user first wants to SEE or SWITCH lenses. To CREATE a brand-new lens, use \`leadbay_new_lens\` — not this tool. -**Geography — scope a sales territory.** Pass \`locations\` (free text like \`["Indre-et-Loire"]\`, \`["Bavaria"]\`, \`["Austin"]\`, or admin-area ids) to restrict the lens to a region, and \`exclude_locations\` to carve one out. Free text auto-resolves via \`/geo/search\` across every admin level — city, county, *département*, *région*, state, country. Place names go in \`locations\`, **never** in \`sectors\` or \`refine_prompt\`. Unresolved/ambiguous text returns \`status:"ambiguous_locations"\` with candidates — surface them and re-call the chosen id via the SAME axis it came from: an INCLUDE pick → \`location_ids\`; an EXCLUDE pick → \`exclude_locations\` (**NOT** \`location_ids\`, which would include the area the user asked to exclude). The returned \`message\` names the right param per text. This is how a director scopes a rep's territory and then asks for net-new accounts there. +**Geography — scope a sales territory.** Pass \`locations\` (free text like \`["Indre-et-Loire"]\`, \`["Texas"]\`, \`["Austin"]\`, or admin-area ids) to restrict the lens to a region, and \`exclude_locations\` to carve one out. Free text auto-resolves via \`/geo/search\` at any level from state down to city — state, *région*, *département*, county, city. Unresolved/ambiguous text returns \`status:"ambiguous_locations"\` with candidates — surface them and re-call the chosen id via the SAME axis it came from: an INCLUDE pick → \`location_ids\`; an EXCLUDE pick → \`exclude_locations\` (**NOT** \`location_ids\`, which would include the area the user asked to exclude). The returned \`message\` names the right param per text. This is how a director scopes a rep's territory and then asks for net-new accounts there. + +**One workspace = one country — a country name is NEVER a location filter.** The admin-area index holds no country nodes, so \`"France"\` matches the *commune of Francs* and \`"United States"\` matches *Statesboro*: the call is silently fenced to one village and every conclusion from it is wrong. City AND country named? Keep the city, drop the country. + +**On \`code: "COUNTRY_LEVEL_LOCATION"\` read \`country_locations[].axis\` and \`[].kind\` — the recovery differs per case and they are NOT interchangeable, and do NOT retry with another spelling or a nearby city.** + +\`axis: "include"\`: + +- \`home_country\`, or "nationwide" / "everywhere" → drop that ONE value. Omit the geo argument (\`city\` / \`locations\` / \`location_ids\`) only if nothing else was on it — then the result covers the whole workspace. If other values remain, keep them and describe the result as those places. +- \`foreign_country\` ("leads in France" on a US workspace) → **unsupported, not unfiltered.** Do NOT re-run without the argument: whole-workspace results are US leads and answer nothing about France. Say the workspace holds only its own country's companies. +- \`supranational\` ("EU", "EMEA") → name what the workspace covers, then offer the whole-workspace view as an explicit choice rather than assuming it. +- \`country_indeterminate\` (custom/staging backend) → its country is unknown, so claim nothing about what it holds. + +\`axis: "exclude"\` reverses all of that — **never "omit the argument"**, which returns the very companies the user asked to remove. Excluding this workspace's own country would empty it; excluding any other country is a harmless no-op. Either way drop the value and ask what to carve out instead. + +On a lens-WRITING tool (\`new_lens\`, \`adjust_audience\`, \`update_lens_filter\`) write NOTHING, with no re-call in any form: when the country was the only scope, and for ANY non-\`foreign_country\` \`exclude\` hit however much else came with it — dropping it and writing the rest inverts the ask. + +**Never infer WHICH country this workspace serves from the user's wording** — "the whole US" does not make it one. Read \`_meta.region\` on any tool result — it outranks any recalled memory; on \`custom\`, claim nothing. + +Place names never go in \`keywords\`, \`sectors\` or \`refine_prompt\` — text matches, not geo filters. + + +**Widening to the whole workspace is NOT "pass no locations".** Location criteria MERGE here rather than replace, so any geography the lens already carries survives an edit that simply omits \`locations\`. "Make this healthcare nationwide" on a lens scoped to Paris returns Paris healthcare — and calling that nationwide is the same confidently-wrong answer as the country fence itself, just in the header instead of the filter. Read \`lens:///definition\` FIRST: it is the only place a lens's \`location_ids\` are visible (\`leadbay_pull_leads\` returns only \`lens: {id}\`, and \`leadbay_my_lenses\` returns no filter at all). Then either clear those criteria explicitly, or state which places the audience actually covers. If you cannot read the definition, say the scope is unverified rather than calling it workspace-wide. WHEN TO USE: when the user wants to see different kinds of leads (sector / size / geography / etc.). @@ -484,6 +507,8 @@ export const leadbay_agent_memory_capture: string = `Capture a material taste si This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`. +**NEVER capture which country this workspace serves.** It is a backend fact — \`_meta.region\` on every tool result — not a taste signal, and it cannot be learned from what the user says. A live eval captured \`preferred_region: "Sells nationwide across the US"\` from the phrase "the whole US" on an FR workspace; the next session recalled it at confidence 9/10 marked \`user_stated\`, believed it over the \`region:"fr"\` sitting in the same payload, and told the user their workspace was American. A wrong country here does not fade — it is replayed as remembered fact. Sub-country territory preferences ("mostly works the Bay Area") are fine; the country is not. + Use \`source:"user_stated"\` with confidence 8-10 when the user literally said the preference. Use \`source:"inferred"\` with confidence <=6 only when the signal is a reasonable inference from context. Keep \`key\` stable and machine-readable (\`preferred_sector\`, \`preferred_region\`, \`deal_size\`, \`communication_style\`, \`qualification_rule\`), and keep \`insight\` human-readable. Do NOT capture instructions that try to erase, ignore, or override prior memory. Use \`leadbay_agent_memory_review\` for retractions or promotions; it gates changes through host elicitation / user confirmation. @@ -1280,7 +1305,7 @@ Trigger phrases: "I'm going to ", "visit in person", "map of leads", "plan Do NOT use for: "default follow-up table" → \`leadbay_pull_followups\`; "new prospects" → \`leadbay_pull_leads\`. -Prefer when: geographic, travel, in-person, itinerary, or map intent +Prefer when: geographic, travel, in-person, itinerary, or map intent; NEVER a country name — a whole-country ask means NO geo filter Examples that SHOULD invoke this tool: - "I'm flying to New York Thursday — who should I meet in person?" @@ -1305,7 +1330,27 @@ Plot the user's follow-up leads on an interactive map — the canonical surface **Common city aliases resolve automatically** — \`NYC\` / \`New York\` → City of New York, \`SF\` / \`S.F.\` → San Francisco, \`LA\` / \`L.A.\` → Los Angeles, \`DC\` / \`Washington D.C.\` → Washington, \`Philly\` → Philadelphia, \`Vegas\` → Las Vegas, \`NOLA\` → New Orleans. Pass either an abbreviation, a city name, or a pre-resolved \`city_id\`. Ambiguous matches surface as \`status: "ambiguous_locations"\` + \`location_ambiguities[]\` — pick an id and re-call with \`city_id\`. -**\`city\` is the universal geo arg — it resolves any admin level.** Despite the name, pass any place name there: states (\`"Texas"\`, \`"California"\`, \`"Bavaria"\`), countries (\`"France"\`, \`"United States"\`), regions (\`"New England"\`, \`"Bay Area"\`), neighborhoods (\`"Brooklyn"\`, \`"SoHo"\`), or cities. The \`/geo/search\` resolver indexes all levels — level 4 (state), level 2 (country), level 5 (city) — and the composite picks the best match. **Never** put a place name into \`keywords\` instead — that's a text-match against company descriptions, not a real geo filter (e.g. \`keywords: ["Texas"]\` returns ≈0 hits even when the user has dozens of Texas leads). If \`keywords: [""]\` returned empty, the correct next call is \`city: ""\`, NOT the unfiltered Monitor view. +**\`city\` is the universal SUB-country geo arg.** Despite the name, pass any place name BELOW country level: states (\`"Texas"\`, \`"California"\`), regions (\`"New England"\`, \`"Bay Area"\`), counties, neighborhoods (\`"Brooklyn"\`, \`"SoHo"\`), or cities — the \`/geo/search\` resolver indexes every level it returns and the composite picks the best match. A COUNTRY name is the one thing it must never receive (rule below). And \`keywords: ["Texas"]\` returns ≈0 hits even when the user has dozens of Texas leads — that's a text-match against company descriptions, not a geo filter. If \`keywords: [""]\` returned empty, the correct next call is \`city: ""\`, NOT the unfiltered Monitor view. + +**One workspace = one country — a country name is NEVER a location filter.** The admin-area index holds no country nodes, so \`"France"\` matches the *commune of Francs* and \`"United States"\` matches *Statesboro*: the call is silently fenced to one village and every conclusion from it is wrong. City AND country named? Keep the city, drop the country. + +**On \`code: "COUNTRY_LEVEL_LOCATION"\` read \`country_locations[].axis\` and \`[].kind\` — the recovery differs per case and they are NOT interchangeable, and do NOT retry with another spelling or a nearby city.** + +\`axis: "include"\`: + +- \`home_country\`, or "nationwide" / "everywhere" → drop that ONE value. Omit the geo argument (\`city\` / \`locations\` / \`location_ids\`) only if nothing else was on it — then the result covers the whole workspace. If other values remain, keep them and describe the result as those places. +- \`foreign_country\` ("leads in France" on a US workspace) → **unsupported, not unfiltered.** Do NOT re-run without the argument: whole-workspace results are US leads and answer nothing about France. Say the workspace holds only its own country's companies. +- \`supranational\` ("EU", "EMEA") → name what the workspace covers, then offer the whole-workspace view as an explicit choice rather than assuming it. +- \`country_indeterminate\` (custom/staging backend) → its country is unknown, so claim nothing about what it holds. + +\`axis: "exclude"\` reverses all of that — **never "omit the argument"**, which returns the very companies the user asked to remove. Excluding this workspace's own country would empty it; excluding any other country is a harmless no-op. Either way drop the value and ask what to carve out instead. + +On a lens-WRITING tool (\`new_lens\`, \`adjust_audience\`, \`update_lens_filter\`) write NOTHING, with no re-call in any form: when the country was the only scope, and for ANY non-\`foreign_country\` \`exclude\` hit however much else came with it — dropping it and writing the rest inverts the ask. + +**Never infer WHICH country this workspace serves from the user's wording** — "the whole US" does not make it one. Read \`_meta.region\` on any tool result — it outranks any recalled memory; on \`custom\`, claim nothing. + +Place names never go in \`keywords\`, \`sectors\` or \`refine_prompt\` — text matches, not geo filters. + --- @@ -2179,7 +2224,29 @@ WHEN NOT TO USE: in normal flow — composites auto-resolve the active lens via // region: leadbay_list_locations export const leadbay_list_locations: string = `Search the geo / admin-area taxonomy by free-text name and return the matching admin_area ids. This is the primary way to turn a user's "leads in Berlin" / "filter to Lyon" intent into the \`{type: "location_ids", locations: []}\` shape that the backend filter expects. -The response has two arrays: \`results\` (top-10 prefix matches ranked by relevance) and \`parents\` (the admin-area chain referenced by \`results[].parent_ids\`, useful for disambiguation breadcrumbs). Each entry: \`{id, country, level, name, parent_ids}\`. The \`level\` is the admin depth — **5** = region, **6** = county, **7** = township-area, **8** = city/town. +The response has two arrays: \`results\` (top-10 prefix matches ranked by relevance) and \`parents\` (the admin-area chain referenced by \`results[].parent_ids\`, useful for disambiguation breadcrumbs). Each entry: \`{id, country, level, name, parent_ids}\`. The \`level\` is the admin depth — **5** = region, **6** = county, **7** = township-area, **8** = city/town. Country nodes are NOT in this index, so searching a country name cannot return that country — it returns whatever same-named town the trigram matcher finds (measured: \`France\` → the commune of Francs, \`United States\` → Statesboro). Passing such an id onward fences the caller to one village, so this tool refuses a country query outright and returns \`status: "country_level_location"\` with an empty \`results\`. + +**One workspace = one country — a country name is NEVER a location filter.** The admin-area index holds no country nodes, so \`"France"\` matches the *commune of Francs* and \`"United States"\` matches *Statesboro*: the call is silently fenced to one village and every conclusion from it is wrong. City AND country named? Keep the city, drop the country. + +**On \`code: "COUNTRY_LEVEL_LOCATION"\` read \`country_locations[].axis\` and \`[].kind\` — the recovery differs per case and they are NOT interchangeable, and do NOT retry with another spelling or a nearby city.** + +\`axis: "include"\`: + +- \`home_country\`, or "nationwide" / "everywhere" → drop that ONE value. Omit the geo argument (\`city\` / \`locations\` / \`location_ids\`) only if nothing else was on it — then the result covers the whole workspace. If other values remain, keep them and describe the result as those places. +- \`foreign_country\` ("leads in France" on a US workspace) → **unsupported, not unfiltered.** Do NOT re-run without the argument: whole-workspace results are US leads and answer nothing about France. Say the workspace holds only its own country's companies. +- \`supranational\` ("EU", "EMEA") → name what the workspace covers, then offer the whole-workspace view as an explicit choice rather than assuming it. +- \`country_indeterminate\` (custom/staging backend) → its country is unknown, so claim nothing about what it holds. + +\`axis: "exclude"\` reverses all of that — **never "omit the argument"**, which returns the very companies the user asked to remove. Excluding this workspace's own country would empty it; excluding any other country is a harmless no-op. Either way drop the value and ask what to carve out instead. + +On a lens-WRITING tool (\`new_lens\`, \`adjust_audience\`, \`update_lens_filter\`) write NOTHING, with no re-call in any form: when the country was the only scope, and for ANY non-\`foreign_country\` \`exclude\` hit however much else came with it — dropping it and writing the rest inverts the ask. + +**Never infer WHICH country this workspace serves from the user's wording** — "the whole US" does not make it one. Read \`_meta.region\` on any tool result — it outranks any recalled memory; on \`custom\`, claim nothing. + +Place names never go in \`keywords\`, \`sectors\` or \`refine_prompt\` — text matches, not geo filters. + + +**The include-axis recovery above does NOT apply to this tool.** "Omit the geo argument and the result covers everything" describes a tool that READS leads and can widen. This one resolves names to ids: \`q\` is REQUIRED, and the empty-\`q\` path returns no matches rather than workspace-wide data — so re-calling without it either fails validation or produces an empty lookup that would then be reported as full coverage. There is no country id to hand out and nothing to retry. Look up a place INSIDE the workspace instead; or, if the whole workspace was meant, skip this tool entirely — the tools that consume these ids just omit their geo argument. WHEN TO USE: to resolve a free-text city/region name before passing it to a \`location_ids\` filter (e.g. on \`leadbay_pull_followups({set_filter})\` or \`leadbay_adjust_audience\`). The composite \`leadbay_pull_followups\` accepts \`city: \` directly and runs this resolver internally — prefer that path; reach for this granular tool only when you need to surface candidates to the user before committing. @@ -2391,7 +2458,7 @@ Trigger phrases: "create a lens", "create a new lens called ", "create a l **Memory:** recall + capture via \`leadbay_agent_memory_*\` tools. -Do NOT use for: "narrow the audience / add or remove a sector on an EXISTING lens" → \`leadbay_adjust_audience\`; "add to my lens" → \`leadbay_adjust_audience\`; "focus on a qualitative trait beyond sector/size" → \`leadbay_refine_prompt\`; "show me / list / switch my lenses" → \`leadbay_my_lenses\`; "more leads on this lens" → \`leadbay_extend_lens\`. +Do NOT use for: "companies anywhere in this workspace's OWN country / nationwide (a foreign country is unsupported, not unfiltered — call nothing)" → \`leadbay_pull_leads\`; "narrow the audience / add or remove a sector on an EXISTING lens" → \`leadbay_adjust_audience\`; "add to my lens" → \`leadbay_adjust_audience\`; "focus on a qualitative trait beyond sector/size" → \`leadbay_refine_prompt\`; "show me / list / switch my lenses" → \`leadbay_my_lenses\`; "more leads on this lens" → \`leadbay_extend_lens\`. Prefer when: user wants a brand-new lens (create/make/set up, often 'specialized in '). Editing an existing lens → leadbay_adjust_audience (use lensName). Qualitative refinement → refine_prompt (admin-only). @@ -2404,6 +2471,7 @@ Examples that should NOT invoke this tool (sound similar, route elsewhere): - "Add fintech to my Joinery lens." - "Show me my lenses." - "I want more leads on this lens." +- "Show me companies anywhere in the US." ## RENDER (quick) @@ -2423,7 +2491,29 @@ Create a brand-new lens (saved audience) and apply its sector/size criteria. Clo **Sectors resolve first.** Free-text \`sectors\`/\`exclude_sectors\` are auto-resolved against the taxonomy. If any don't resolve, the tool returns \`status:"ambiguous_sectors"\` with the candidates and **does NOT create the lens** — so re-calling after picking the right sector won't leave orphan half-built lenses. To discover valid sector labels up front, use \`leadbay_list_sectors\`. -**Geography — scope a territory.** Pass \`locations\` (free text like \`["Indre-et-Loire"]\`, \`["Bavaria"]\`, or admin-area ids) to scope the lens to a sales territory, and \`exclude_locations\` to carve one out. Free text auto-resolves via \`/geo/search\` across every admin level (city / county / *département* / *région* / state / country). Like sectors, locations resolve BEFORE the lens is created — unresolved/ambiguous text returns \`status:"ambiguous_locations"\` with candidates and **does NOT create the lens**. Re-call the chosen id via the SAME axis it came from: an INCLUDE pick → \`locations\`; an EXCLUDE pick → \`exclude_locations\` (**NOT** \`locations\`, which would include the area the user asked to exclude). This is how a director spins up a lens for a rep's zone to surface net-new accounts there. +**Geography — scope a territory.** Pass \`locations\` (free text like \`["Indre-et-Loire"]\`, \`["Texas"]\`, or admin-area ids) to scope the lens to a sales territory, and \`exclude_locations\` to carve one out. Free text auto-resolves via \`/geo/search\` at any level from state down to city (state / *région* / *département* / county / city). Like sectors, locations resolve BEFORE the lens is created — unresolved/ambiguous text returns \`status:"ambiguous_locations"\` with candidates and **does NOT create the lens**. Re-call the chosen id via the SAME axis it came from: an INCLUDE pick → \`locations\`; an EXCLUDE pick → \`exclude_locations\` (**NOT** \`locations\`, which would include the area the user asked to exclude). This is how a director spins up a lens for a rep's zone to surface net-new accounts there. + +**One workspace = one country — a country name is NEVER a location filter.** The admin-area index holds no country nodes, so \`"France"\` matches the *commune of Francs* and \`"United States"\` matches *Statesboro*: the call is silently fenced to one village and every conclusion from it is wrong. City AND country named? Keep the city, drop the country. + +**On \`code: "COUNTRY_LEVEL_LOCATION"\` read \`country_locations[].axis\` and \`[].kind\` — the recovery differs per case and they are NOT interchangeable, and do NOT retry with another spelling or a nearby city.** + +\`axis: "include"\`: + +- \`home_country\`, or "nationwide" / "everywhere" → drop that ONE value. Omit the geo argument (\`city\` / \`locations\` / \`location_ids\`) only if nothing else was on it — then the result covers the whole workspace. If other values remain, keep them and describe the result as those places. +- \`foreign_country\` ("leads in France" on a US workspace) → **unsupported, not unfiltered.** Do NOT re-run without the argument: whole-workspace results are US leads and answer nothing about France. Say the workspace holds only its own country's companies. +- \`supranational\` ("EU", "EMEA") → name what the workspace covers, then offer the whole-workspace view as an explicit choice rather than assuming it. +- \`country_indeterminate\` (custom/staging backend) → its country is unknown, so claim nothing about what it holds. + +\`axis: "exclude"\` reverses all of that — **never "omit the argument"**, which returns the very companies the user asked to remove. Excluding this workspace's own country would empty it; excluding any other country is a harmless no-op. Either way drop the value and ask what to carve out instead. + +On a lens-WRITING tool (\`new_lens\`, \`adjust_audience\`, \`update_lens_filter\`) write NOTHING, with no re-call in any form: when the country was the only scope, and for ANY non-\`foreign_country\` \`exclude\` hit however much else came with it — dropping it and writing the rest inverts the ask. + +**Never infer WHICH country this workspace serves from the user's wording** — "the whole US" does not make it one. Read \`_meta.region\` on any tool result — it outranks any recalled memory; on \`custom\`, claim nothing. + +Place names never go in \`keywords\`, \`sectors\` or \`refine_prompt\` — text matches, not geo filters. + + +**A new lens is a CLONE, and inherits the base lens's geography.** \`base\` defaults to the ACTIVE lens, so this applies even when no base was named. A criteria-less clone inherits the base audience wholesale, and adding sectors does not clear the base's location criteria either — so "nationwide healthcare" built on a Paris-scoped active lens creates a Paris healthcare lens under a nationwide name. Omitting \`locations\` is therefore not the same as having no geography. Read \`lens:///definition\` before describing a new lens as workspace-wide, and say the scope is unverified if you cannot. **Does not switch the active lens.** The new lens is created but the user stays on their current one. Offer \`leadbay_my_lenses(switchToLensId=)\` as a next step if they want to start pulling from it. @@ -2767,20 +2857,18 @@ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible // region: leadbay_pull_followups export const leadbay_pull_followups: string = `## WHEN TO USE -Trigger phrases: "what should I follow up on", "leads I've already worked", "what's overdue", "leads in ", "reach out to today", "should reach out to", "get back to", "contact today", "reconnect with", "re-engage", "leads to contact", "who should I ping". +Trigger phrases: "what should I follow up on", "leads I've already worked", "what's overdue", "stale leads", "leads in ", "reach out to today", "should reach out to", "get back to", "contact today", "reconnect with", "re-engage", "leads to contact", "who should I ping". **Memory:** recall + capture via \`leadbay_agent_memory_*\` tools. Do NOT use for: "new leads / today's prospects" → \`leadbay_pull_leads\`; "map / trip / in person" → \`leadbay_followups_map\`. -Prefer when: known Monitor leads; pass \`city\` or \`set_filter\` for geo/sector/recency +Prefer when: known Monitor leads; pass \`city\` or \`set_filter\` for geo/sector/recency; NEVER a country name — a whole-country ask means NO geo filter Examples that SHOULD invoke this tool: - "What should I follow up on this week?" - "What's overdue in my pipeline?" - "Show me leads I should reach out to today." -- "Who should I get back to today?" -- "Leads I should contact today." Examples that should NOT invoke this tool (sound similar, route elsewhere): - "Show me today's new leads." @@ -2797,7 +2885,7 @@ table. Detail + status priority below. --- -Pull KNOWN leads from the user's Monitor view — the re-engagement entry point. Use when the user asks "what should I follow up on", "leads I haven't contacted", "leads in [city]", "before my trip", or any phrasing implying pre-existing pipeline context. For NEW leads from Discover, use \`leadbay_pull_leads\`. +Pull KNOWN leads from the user's Monitor view — the re-engagement entry point. Backend: wraps \`GET /1.6/monitor?personal=&liked=&filtered=&count=&page=\` plus, when \`set_filter\` is supplied, a preceding \`POST /1.6/monitor/filter\`. The Monitor filter is a single \`FilterItem\` per user — refreshing restores it. @@ -2805,24 +2893,44 @@ Backend: wraps \`GET /1.6/monitor?personal=&liked=&filtered=&count=&page=\` plus Practical mapping from user phrasing to criterion: -| User phrase | Criterion | -|--------------------------------------|----------------------------------------------------------------------| -| "leads in Lyon" | \`{type: "location_ids", locations: []}\` | -| "healthcare staffing" | \`{type: "keywords", keywords: ["healthcare", "staffing"]}\` | -| "leads I haven't touched in 30 days" | \`{type: "last_action_date", last_days: 30}\` | -| "leads I liked" | \`{type: "liked"}\` | -| "leads 50–200 employees" | \`{type: "size", sizes: [{min: 50, max: 200}]}\` | -| "Y Combinator companies" | \`{type: "yc"}\` | +| User phrase | Criterion | +|---|---| +| "leads in Lyon" | \`{type: "location_ids", locations: []}\` | +| "healthcare staffing" | \`{type: "keywords", keywords: ["healthcare", "staffing"]}\` | +| "leads I haven't touched in 30 days" | \`{type: "last_action_date", last_days: 30}\` | +| "leads I liked" | \`{type: "liked"}\` | +| "leads 50–200 employees" | \`{type: "size", sizes: [{min: 50, max: 200}]}\` | +| "Y Combinator companies" | \`{type: "yc"}\` | Geo filtering needs \`admin_area_id\` resolution — backend rejects free-text in \`location_ids\`. Pass \`city: ""\` and the composite calls \`/geo/search\` internally, picks the best match, merges its id into \`set_filter\`. Ambiguous matches return \`status: "ambiguous_locations"\` + \`location_ambiguities[]\` — pick an id and re-call with \`city_id\`. -**Place names go through \`city\`, NEVER \`keywords\`.** Any geographic token the user names — cities (\`"Berlin"\`), states/regions (\`"Texas"\`, \`"Bavaria"\`), countries (\`"France"\`), neighborhoods (\`"Brooklyn"\`) — resolves via \`/geo/search\` (all admin levels). A place name in \`keywords\` becomes a TEXT-MATCH against company descriptions (≈0 hits), not a real filter. If a place resolves ambiguously, surface the choices — never silently fall back to keyword search or the unfiltered view. +In \`keywords\` a place name is a TEXT-MATCH on company descriptions (≈0 hits), not a filter — never fall back to it, nor to the unfiltered view, when a place is ambiguous. -**Pushback exclusion.** Leads with active pushback (\`pushback_status\` set, \`pushback_until > today\`) are excluded client-side; \`total_excluded_by_pushback\` reports how many rows were dropped. +**One workspace = one country — a country name is NEVER a location filter.** The admin-area index holds no country nodes, so \`"France"\` matches the *commune of Francs* and \`"United States"\` matches *Statesboro*: the call is silently fenced to one village and every conclusion from it is wrong. City AND country named? Keep the city, drop the country. + +**On \`code: "COUNTRY_LEVEL_LOCATION"\` read \`country_locations[].axis\` and \`[].kind\` — the recovery differs per case and they are NOT interchangeable, and do NOT retry with another spelling or a nearby city.** + +\`axis: "include"\`: + +- \`home_country\`, or "nationwide" / "everywhere" → drop that ONE value. Omit the geo argument (\`city\` / \`locations\` / \`location_ids\`) only if nothing else was on it — then the result covers the whole workspace. If other values remain, keep them and describe the result as those places. +- \`foreign_country\` ("leads in France" on a US workspace) → **unsupported, not unfiltered.** Do NOT re-run without the argument: whole-workspace results are US leads and answer nothing about France. Say the workspace holds only its own country's companies. +- \`supranational\` ("EU", "EMEA") → name what the workspace covers, then offer the whole-workspace view as an explicit choice rather than assuming it. +- \`country_indeterminate\` (custom/staging backend) → its country is unknown, so claim nothing about what it holds. + +\`axis: "exclude"\` reverses all of that — **never "omit the argument"**, which returns the very companies the user asked to remove. Excluding this workspace's own country would empty it; excluding any other country is a harmless no-op. Either way drop the value and ask what to carve out instead. -WHEN TO USE: re-engaging pipeline ("what should I follow up on", "stale leads"), filtering monitored leads by city / sector / recency / action type / liked. The canonical orchestrator is the \`leadbay_followup_check_in\` prompt. +On a lens-WRITING tool (\`new_lens\`, \`adjust_audience\`, \`update_lens_filter\`) write NOTHING, with no re-call in any form: when the country was the only scope, and for ANY non-\`foreign_country\` \`exclude\` hit however much else came with it — dropping it and writing the rest inverts the ask. -WHEN NOT TO USE: for NEW leads — that's \`leadbay_pull_leads\` (Discover). +**Never infer WHICH country this workspace serves from the user's wording** — "the whole US" does not make it one. Read \`_meta.region\` on any tool result — it outranks any recalled memory; on \`custom\`, claim nothing. + +Place names never go in \`keywords\`, \`sectors\` or \`refine_prompt\` — text matches, not geo filters. + + +**A whole-workspace read also needs \`filtered:false\`.** Omitting \`city\` does not widen this tool — \`filtered\` defaults to true, so a filter persisted earlier still applies and its stale cohort reads as everything. If other criteria were requested, re-send them in \`set_filter\` instead; \`active_filters\` reports what applied. + +**Pushback exclusion.** Leads with active pushback (\`pushback_status\` set, \`pushback_until > today\`) are excluded client-side; \`total_excluded_by_pushback\` reports how many rows were dropped. + +The canonical orchestrator for a re-engagement pass is the \`leadbay_followup_check_in\` prompt. **Anti-confusion guardrail.** Iterating \`pull_leads\` pages looking for \`prospecting_actions_count > 0\` or \`notes_count > 0\` rows is the wrong entry point — the two read different tables. Leads with follow-up history live in \`pull_followups\`. @@ -2844,16 +2952,16 @@ Markdown table with FOUR columns, sorted by \`last_monitor_action_at\` desc. **N **Active-filters line** ABOVE the table, \` · \`-separated chips from \`active_filters.criteria\`: -| Criterion type | Chip | -|-----------------------|----------------------------| -| \`location_ids\` | 📍 \\ | -| \`sector_ids\` | 🏷 \\ | -| \`keywords\` | 🔍 \\ | -| \`size\` | 👥 \\–\\ | -| \`last_action_date\` | 📅 \\ | -| \`last_action\` | 🎯 \\ | -| \`liked\` / \`yc\` | ⭐ liked / 🏅 YC | -| \`custom_field*\` | ⚙ \\ | +| Criterion type | Chip | +| --- | --- | +| \`location_ids\` | 📍 \\ | +| \`sector_ids\` | 🏷 \\ | +| \`keywords\` | 🔍 \\ | +| \`size\` | 👥 \\–\\ | +| \`last_action_date\` | 📅 \\ | +| \`last_action\` | 🎯 \\ | +| \`liked\` / \`yc\` | ⭐ liked / 🏅 YC | +| \`custom_field*\` | ⚙ \\ | Render \`*No filters applied.*\` when empty. @@ -2940,21 +3048,19 @@ User picks → call the matching \`Calls\` tool. Constraints: 2–4 mutually-exc -Always include at least one filter-modification offer (users think in filters: by city, by recency, by action type). Filter modification goes through \`set_filter: FilterItem\` which the composite POSTs to \`/monitor/filter\` server-side. - -| Observation | Suggest | Calls | -|-----------------------------------------------|----------------------------------------------------------|----------------------------------------------------------------------------------------------------| -| Always (top of menu) | "Prep outreach for [top row's contact]" | leadbay_prepare_outreach(leadId) | -| User named a city / sector / timeframe | "Refilter by [their phrase]" | leadbay_pull_followups(set_filter: { criteria: [...] }) | -| \`pagination.has_more == true\` | "Pull the next page" | leadbay_pull_followups(page = current + 1) | -| ≥3 rows ✨ (never-touched) | "Surface only never-touched leads" | set_filter with \`last_action_date.last_days = 0\` | -| ≥3 rows ⚡ (Trying to reach) | "Focus on overdue commitments" | set_filter with \`last_action.types = ["EPILOGUE_COULD_NOT_REACH_STILL_TRYING"]\` | -| User planning a trip / in a city | "Group by city for trip planning" | leadbay_pull_followups({city: ""}) — composite resolves admin_area_id via /geo/search | -| All rows last action > 60d | "Re-qualify — context may have changed" | leadbay_bulk_qualify_leads([leadId, ...]) | -| One obvious priority row | "Take me to that lead's full brief" | leadbay_prepare_outreach(leadId) / leadbay_research_lead_by_id(leadId) | -| User wants to defer a lead | "Snooze [Company] for 3 / 6 / 12 months" | leadbay_set_pushback({ lead_ids:[leadId], status:"3" }) | -| User completed outreach mid-flow | "Log the outreach + record the outcome" | leadbay_report_outreach | -| Discovery mode might fit better | "Looking for NEW leads instead? Switch to discovery." | leadbay_pull_leads | +| Observation | Suggest | Calls | +|---|---|---| +| Always (top of menu) | "Prep outreach for [top row's contact]" | leadbay_prepare_outreach(leadId) | +| User named a city / sector / timeframe | "Refilter by [their phrase]" | leadbay_pull_followups(set_filter: { criteria: [...] }) | +| \`pagination.has_more == true\` | "Pull the next page" | leadbay_pull_followups(page = current + 1) | +| ≥3 rows ✨ (never-touched) | "Surface only never-touched leads" | set_filter with \`last_action_date.last_days = 0\` | +| ≥3 rows ⚡ (Trying to reach) | "Focus on overdue commitments" | set_filter with \`last_action.types = ["EPILOGUE_COULD_NOT_REACH_STILL_TRYING"]\` | +| User planning a trip / in a city | "Group by city for trip planning" | leadbay_pull_followups({city: ""}) — composite resolves admin_area_id via /geo/search | +| All rows last action > 60d | "Re-qualify — context may have changed" | leadbay_bulk_qualify_leads([leadId, ...]) | +| One obvious priority row | "Take me to that lead's full brief" | leadbay_prepare_outreach(leadId) / leadbay_research_lead_by_id(leadId) | +| User wants to defer a lead | "Snooze [Company] for 3 / 6 / 12 months" | leadbay_set_pushback({ lead_ids:[leadId], status:"3" }) | +| User completed outreach mid-flow | "Log the outreach + record the outcome" | leadbay_report_outreach | +| Discovery mode might fit better | "Looking for NEW leads instead? Switch to discovery." | leadbay_pull_leads | Always offer at least one of: prep outreach, refilter, pushback. Pushback is the canonical way to honor "not now" / "next quarter" — leads with active pushback are excluded from this view until expiry. `; // endregion: leadbay_pull_followups @@ -3844,7 +3950,7 @@ Trigger phrases: "which of my leads ", "find leads that " → \`leadbay_research_lead_by_id\`; "qualify my next N leads (they aren't researched yet)" → \`leadbay_bulk_qualify_leads\`; "just list my follow-ups" → \`leadbay_pull_followups\`. -Prefer when: user wants to FILTER a known portfolio by a web-research signal in bulk — pass \`query\`, optionally \`since\`, \`city\`/\`set_filter\`, or \`leadIds\` +Prefer when: user wants to FILTER a known portfolio by a web-research signal in bulk — pass \`query\`, optionally \`since\`, \`city\`/\`set_filter\`, or \`leadIds\`; NEVER a country name in \`city\` — a whole-country ask means NO geo filter Examples that SHOULD invoke this tool: - "Which of my leads acquired a company since 2025?" @@ -3881,7 +3987,27 @@ match". Qualify them with \`leadbay_bulk_qualify_leads\`, then re-scan. **Scope.** Pass \`leadIds\` for an explicit cohort, or omit it to scan the Monitor portfolio. Narrow the Monitor scope with \`city\` / \`set_filter\` exactly -as \`leadbay_pull_followups\` does (store-then-apply server-side filter). The +as \`leadbay_pull_followups\` does (store-then-apply server-side filter). + +**One workspace = one country — a country name is NEVER a location filter.** The admin-area index holds no country nodes, so \`"France"\` matches the *commune of Francs* and \`"United States"\` matches *Statesboro*: the call is silently fenced to one village and every conclusion from it is wrong. City AND country named? Keep the city, drop the country. + +**On \`code: "COUNTRY_LEVEL_LOCATION"\` read \`country_locations[].axis\` and \`[].kind\` — the recovery differs per case and they are NOT interchangeable, and do NOT retry with another spelling or a nearby city.** + +\`axis: "include"\`: + +- \`home_country\`, or "nationwide" / "everywhere" → drop that ONE value. Omit the geo argument (\`city\` / \`locations\` / \`location_ids\`) only if nothing else was on it — then the result covers the whole workspace. If other values remain, keep them and describe the result as those places. +- \`foreign_country\` ("leads in France" on a US workspace) → **unsupported, not unfiltered.** Do NOT re-run without the argument: whole-workspace results are US leads and answer nothing about France. Say the workspace holds only its own country's companies. +- \`supranational\` ("EU", "EMEA") → name what the workspace covers, then offer the whole-workspace view as an explicit choice rather than assuming it. +- \`country_indeterminate\` (custom/staging backend) → its country is unknown, so claim nothing about what it holds. + +\`axis: "exclude"\` reverses all of that — **never "omit the argument"**, which returns the very companies the user asked to remove. Excluding this workspace's own country would empty it; excluding any other country is a harmless no-op. Either way drop the value and ask what to carve out instead. + +On a lens-WRITING tool (\`new_lens\`, \`adjust_audience\`, \`update_lens_filter\`) write NOTHING, with no re-call in any form: when the country was the only scope, and for ANY non-\`foreign_country\` \`exclude\` hit however much else came with it — dropping it and writing the rest inverts the ask. + +**Never infer WHICH country this workspace serves from the user's wording** — "the whole US" does not make it one. Read \`_meta.region\` on any tool result — it outranks any recalled memory; on \`custom\`, claim nothing. + +Place names never go in \`keywords\`, \`sectors\` or \`refine_prompt\` — text matches, not geo filters. + The scan is bounded by \`max_leads\` (default 200, hard cap 300); when the portfolio is larger, \`truncated_at\` is set and coverage is partial — say so. @@ -4356,7 +4482,7 @@ Trigger phrases: "visiting in days", "I'm in next week / Tuesd Do NOT use for: "follow-ups only, no new prospects" → \`leadbay_followups_map\`; "new leads only" → \`leadbay_pull_leads\`; "research one account" → \`leadbay_research_lead_by_id\`. -Prefer when: user wants known accounts plus new discoveries in one geographic itinerary +Prefer when: user wants known accounts plus new discoveries in one geographic itinerary; NEVER a country name — unlike the Monitor tools, do NOT omit \`city\`; a city-less tour is arbitrary nationwide leads, so ask which city or region Examples that SHOULD invoke this tool: - "I'm flying to Limoges in 4 days — give me 3 customers, 3 qualified prospects, and 3 new high-potential." @@ -4385,7 +4511,35 @@ prose paragraph. Full recipe below. Build a single-call mixed-mode itinerary for a field sales tour. Combines \`leadbay_pull_followups\` (Monitor leads in the city — known accounts) with \`leadbay_pull_leads\` (Discover wishlist — new prospects, then client-side filtered by city) so the agent can answer the canonical #3630 US1 ask: *"I'm visiting Limoges in 4 days — propose 3 customers + 3 qualified prospects + 3 new high-potential discoveries."* -**Geo resolution** is identical to \`leadbay_followups_map\`: pass \`city\` (any admin level — city, state, country, region — the \`/geo/search\` resolver picks the best match), or a pre-resolved \`city_id\`. Ambiguous matches surface as \`status: "ambiguous_locations"\` + \`location_ambiguities[]\`; pick an id and re-call with \`city_id\`. +**Geo resolution** is identical to \`leadbay_followups_map\`: pass \`city\` (any level from state down to neighborhood — state, *région*, county, city — the \`/geo/search\` resolver picks the best match), or a pre-resolved \`city_id\`. Ambiguous matches surface as \`status: "ambiguous_locations"\` + \`location_ambiguities[]\`; pick an id and re-call with \`city_id\`. + +**One workspace = one country — a country name is NEVER a location filter.** The admin-area index holds no country nodes, so \`"France"\` matches the *commune of Francs* and \`"United States"\` matches *Statesboro*: the call is silently fenced to one village and every conclusion from it is wrong. City AND country named? Keep the city, drop the country. + +**On \`code: "COUNTRY_LEVEL_LOCATION"\` read \`country_locations[].axis\` and \`[].kind\` — the recovery differs per case and they are NOT interchangeable, and do NOT retry with another spelling or a nearby city.** + +\`axis: "include"\`: + +- \`home_country\`, or "nationwide" / "everywhere" → drop that ONE value. Omit the geo argument (\`city\` / \`locations\` / \`location_ids\`) only if nothing else was on it — then the result covers the whole workspace. If other values remain, keep them and describe the result as those places. +- \`foreign_country\` ("leads in France" on a US workspace) → **unsupported, not unfiltered.** Do NOT re-run without the argument: whole-workspace results are US leads and answer nothing about France. Say the workspace holds only its own country's companies. +- \`supranational\` ("EU", "EMEA") → name what the workspace covers, then offer the whole-workspace view as an explicit choice rather than assuming it. +- \`country_indeterminate\` (custom/staging backend) → its country is unknown, so claim nothing about what it holds. + +\`axis: "exclude"\` reverses all of that — **never "omit the argument"**, which returns the very companies the user asked to remove. Excluding this workspace's own country would empty it; excluding any other country is a harmless no-op. Either way drop the value and ask what to carve out instead. + +On a lens-WRITING tool (\`new_lens\`, \`adjust_audience\`, \`update_lens_filter\`) write NOTHING, with no re-call in any form: when the country was the only scope, and for ANY non-\`foreign_country\` \`exclude\` hit however much else came with it — dropping it and writing the rest inverts the ask. + +**Never infer WHICH country this workspace serves from the user's wording** — "the whole US" does not make it one. Read \`_meta.region\` on any tool result — it outranks any recalled memory; on \`custom\`, claim nothing. + +Place names never go in \`keywords\`, \`sectors\` or \`refine_prompt\` — text matches, not geo filters. + + +**Tour-specific override of the rule above.** For a tour, the home-country +recovery ("omit the geo argument") does NOT apply: this tool accepts a missing +\`city\` and then returns arbitrary leads from across the whole workspace, which is +not an itinerary. So for ANY country-level \`city\` — this workspace's own included +— do not drop the argument. Ask which city or region the user is actually +visiting and re-call with that. \`status: "country_level_location"\` carries the +same instruction in its \`hint\`. **Counts**: \`followups_count\` (default 6 — generous so the agent can split into "customers + qualified" client-side) and \`discover_count\` (default 6 after client-side geo filter). The composite over-pulls Discover (30 raw) because the wishlist endpoint has no server-side geo filter — it then filters by \`location.city/state/country/full\` substring match against the requested city. The \`discover_filter_note\` string in the response tells the agent the match ratio so it can be honest about coverage ("matched 3/30 by city/state" vs. "matched 12/30"). @@ -4579,7 +4733,27 @@ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible // endregion: leadbay_update_lens // region: leadbay_update_lens_filter -export const leadbay_update_lens_filter: string = `Replace the audience filter (sectors, sizes, locations) on a lens. Body is the full \`Filter\` object — this is a REPLACE, not a merge. Returns 400 \`default_lens\` if applied to the org default lens (clone it first). \`dry_run:true\` returns the call shape without contacting the backend. +export const leadbay_update_lens_filter: string = `Replace the audience filter (sectors, sizes, locations) on a lens. Body is the full \`Filter\` object — this is a REPLACE, not a merge. Returns 400 \`default_lens\` if applied to the org default lens (clone it first). \`dry_run:true\` returns the call shape without contacting the backend. A country name anywhere in the payload's \`location_ids\` criteria (or in the echoed \`locations.results[]\` block) is rejected with \`code: "COUNTRY_LEVEL_LOCATION"\` — including on a dry run, so a preview can never suggest such a body is valid. + +**One workspace = one country — a country name is NEVER a location filter.** The admin-area index holds no country nodes, so \`"France"\` matches the *commune of Francs* and \`"United States"\` matches *Statesboro*: the call is silently fenced to one village and every conclusion from it is wrong. City AND country named? Keep the city, drop the country. + +**On \`code: "COUNTRY_LEVEL_LOCATION"\` read \`country_locations[].axis\` and \`[].kind\` — the recovery differs per case and they are NOT interchangeable, and do NOT retry with another spelling or a nearby city.** + +\`axis: "include"\`: + +- \`home_country\`, or "nationwide" / "everywhere" → drop that ONE value. Omit the geo argument (\`city\` / \`locations\` / \`location_ids\`) only if nothing else was on it — then the result covers the whole workspace. If other values remain, keep them and describe the result as those places. +- \`foreign_country\` ("leads in France" on a US workspace) → **unsupported, not unfiltered.** Do NOT re-run without the argument: whole-workspace results are US leads and answer nothing about France. Say the workspace holds only its own country's companies. +- \`supranational\` ("EU", "EMEA") → name what the workspace covers, then offer the whole-workspace view as an explicit choice rather than assuming it. +- \`country_indeterminate\` (custom/staging backend) → its country is unknown, so claim nothing about what it holds. + +\`axis: "exclude"\` reverses all of that — **never "omit the argument"**, which returns the very companies the user asked to remove. Excluding this workspace's own country would empty it; excluding any other country is a harmless no-op. Either way drop the value and ask what to carve out instead. + +On a lens-WRITING tool (\`new_lens\`, \`adjust_audience\`, \`update_lens_filter\`) write NOTHING, with no re-call in any form: when the country was the only scope, and for ANY non-\`foreign_country\` \`exclude\` hit however much else came with it — dropping it and writing the rest inverts the ask. + +**Never infer WHICH country this workspace serves from the user's wording** — "the whole US" does not make it one. Read \`_meta.region\` on any tool result — it outranks any recalled memory; on \`custom\`, claim nothing. + +Place names never go in \`keywords\`, \`sectors\` or \`refine_prompt\` — text matches, not geo filters. + WHEN TO USE: low-level mutation when you've already prepared the merged filter. diff --git a/packages/core/src/tools/list-locations.ts b/packages/core/src/tools/list-locations.ts index 7c8e7dd4..08c8b5fa 100644 --- a/packages/core/src/tools/list-locations.ts +++ b/packages/core/src/tools/list-locations.ts @@ -1,6 +1,10 @@ import type { LeadbayClient } from "../client.js"; import type { Tool, GeoSearchResponse } from "../types.js"; import { leadbay_list_locations as LIST_LOCATIONS_DESCRIPTION } from "../tool-descriptions.generated.js"; +import { + countryLocationStatus, + detectCountryLocations, +} from "../composite/_country-guard.js"; interface ListLocationsParams { q: string; @@ -22,7 +26,7 @@ export const listLocations: Tool = { q: { type: "string", description: - "Free-text city / region name (e.g. 'Berlin', 'NYC', 'São Paulo'). Returns top-10 prefix matches sorted by relevance, each with an admin_area id usable in FilterCriterion.location_ids.", + "Free-text city / region name (e.g. 'Berlin', 'NYC', 'São Paulo'). Returns top-10 prefix matches sorted by relevance, each with an admin_area id usable in FilterCriterion.location_ids. A COUNTRY name is refused — the index holds no country nodes, so the lookup could only return a same-named town.", }, }, required: ["q"], @@ -43,12 +47,48 @@ export const listLocations: Tool = { "Parent admin areas referenced by `results[].parent_ids`, returned for breadcrumb / hover-disambiguation rendering.", items: { type: "object" }, }, + status: { + type: "string", + description: + "`country_level_location` when `q` was a country name — `results` is empty on purpose. This workspace serves exactly ONE country, so there is no country to look up and no id to pass on. Absent on the happy path.", + }, + country_locations: { + type: "array", + description: + "Per offending value: {value, param, kind, country, axis, kept}. Only present when `status === 'country_level_location'`. Unlike the lead-reading tools, the recovery here is NOT to drop `q` and re-call: `q` is required and an empty lookup returns no results, not workspace-wide coverage. There is simply no country id to hand out — see `hint`.", + items: { type: "object" }, + }, }, required: ["results", "parents"], }, execute: async (client: LeadbayClient, params: ListLocationsParams) => { const q = (params.q ?? "").trim(); if (!q) return { results: [], parents: [] }; + // This is the tool that HANDS OUT the ids other tools filter on, so + // refusing a country lookup here is the single highest-leverage stop: + // there is no country node to return, only a same-named town, and an id + // pasted from such a result fences the caller to one village with no + // visible sign (product#3951). Matches this tool's own idiom — an empty + // `q` already returns an envelope rather than throwing. + const countryHits = detectCountryLocations(q, "q", client.region); + if (countryHits.length > 0) { + const envelope = countryLocationStatus(countryHits, client.region); + return { + results: [], + parents: [], + ...envelope, + // The shared read recovery is "omit the geo argument and the result + // covers the whole workspace". That is right for a tool that READS + // leads and wrong here in both halves: `q` is required, so omitting it + // fails schema validation, and the empty-`q` branch above returns an + // empty envelope rather than workspace-wide data — so an agent that + // followed the advice would report "covers everything" over a lookup + // that found nothing. This tool hands out IDS; there is no country id + // to hand out and no wider lookup to fall back to, so there is nothing + // to retry. Overridden the same way tour_plan overrides it. + hint: `There is no country to look up: country nodes are absent from the admin-area index (product#3885), so no id exists to return and no spelling of "${q}" will produce one. Do NOT re-call this tool with \`q\` omitted — \`q\` is required, and an empty lookup is not a whole-workspace result. If the caller wanted somewhere INSIDE this workspace, look up that place instead; if they meant the workspace as a whole, no location id is needed at all — the tools that consume these ids simply omit the geo argument.`, + }; + } const path = `/geo/search?q=${encodeURIComponent(q)}`; return await client.request("GET", path); }, diff --git a/packages/core/src/tools/update-lens-filter.ts b/packages/core/src/tools/update-lens-filter.ts index 6832bc9a..3ca7f8de 100644 --- a/packages/core/src/tools/update-lens-filter.ts +++ b/packages/core/src/tools/update-lens-filter.ts @@ -1,6 +1,11 @@ import type { LeadbayClient } from "../client.js"; import type { Tool, FilterPayload } from "../types.js"; import { leadbay_update_lens_filter as UPDATE_LENS_FILTER_DESCRIPTION } from "../tool-descriptions.generated.js"; +import { + countryLocationEnvelope, + filterCarriesOtherScope, + detectCountryLocationsInFilter, +} from "../composite/_country-guard.js"; interface UpdateLensFilterParams { lensId: number; @@ -41,6 +46,30 @@ export const updateLensFilter: Tool = { client: LeadbayClient, params: UpdateLensFilterParams ) => { + // Checked BEFORE the dry_run short-circuit on purpose: a dry run that + // cheerfully echoed `would_call` for a country-bearing payload would + // teach the agent the payload is valid. This is the rawest write path to + // a location_ids criterion, so it is also the easiest place to smuggle a + // country in (product#3951). + const countryHits = detectCountryLocationsInFilter( + params.filter, + client.region + ); + if (countryHits.length > 0) { + const envelope = countryLocationEnvelope( + countryHits, + client.region, + "write", + filterCarriesOtherScope(params.filter, client.region) + ); + throw { + error: true, + code: envelope.code, + message: envelope.message, + hint: envelope.hint, + }; + } + if (params.dry_run) { return { dry_run: true, diff --git a/packages/core/test/unit/composite/country-blocked-write-single-instruction.test.ts b/packages/core/test/unit/composite/country-blocked-write-single-instruction.test.ts new file mode 100644 index 00000000..67115710 --- /dev/null +++ b/packages/core/test/unit/composite/country-blocked-write-single-instruction.test.ts @@ -0,0 +1,184 @@ +/** + * A blocked exclusion fails the WHOLE write closed, in ONE instruction + * (product#3951). + * + * The stop was correct and its packaging was not. Hints are built per argument, + * so a request could carry two live instructions at once: + * + * {locations: ["France"], exclude_locations: ["France"], sectors: [...]} + * → "drop `locations` and re-call ONCE with the rest of the request intact" + * → "write nothing" + * + * and an agent that acts on the first has already persisted the inversion. The + * same contradiction fitted inside a single argument, where the surgical + * "remove these and re-call" was prepended to the STOP: on + * `exclude_locations: ["France", "EU", "Paris"]` the first half licenses a + * Paris-only exclusion that keeps every French company the user asked to + * remove. + * + * So the envelope reconciles the write globally, before any per-argument hint + * is emitted, and the resulting text contains no re-call directive at all. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { newLens } from "../../../src/composite/new-lens.js"; +import { + detectCountryLocationsIn, + countryLocationEnvelope, +} from "../../../src/composite/_country-guard.js"; + +const frClient = () => new LeadbayClient("https://api-fr.leadbay.app", "u.test-token", "fr"); + +beforeEach(() => resetHttpMock()); + +/** Every phrasing in this module that authorises a call. None may appear. */ +const RECALL = [ + /re-call ONCE/i, + /in ONE re-call/i, + /Remove ALL of/i, + /Remove ONLY/i, + /Remove every one of/i, + /and re-call with the rest/i, +]; + +const writeHint = ( + params: ReadonlyArray<{ input: unknown; param: string; axis?: "include" | "exclude" }> +) => + countryLocationEnvelope(detectCountryLocationsIn(params, "fr"), "fr", "write", true).hint; + +const expectFailClosed = (hint: string) => { + for (const pattern of RECALL) { + expect(hint, `a blocked write must not carry ${pattern} — it authorises the mutation`).not.toMatch( + pattern + ); + } + expect(hint).toMatch(/Write NOTHING/); + expect(hint).toMatch(/do NOT re-call this tool in any form/i); +}; + +describe("one instruction, no re-call directive", () => { + it("several blocked values beside a valid sibling on one argument", () => { + const hint = writeHint([ + { input: ["France", "EU", "Paris"], param: "exclude_locations", axis: "exclude" }, + ]); + expectFailClosed(hint); + expect(hint).toMatch(/not without "France", "EU"/); + // Both reasons, since they are different reasons. + expect(hint).toMatch(/"France" is this entire workspace/); + expect(hint).toMatch(/"EU" is a supra-national scope/); + }); + + it("a blocked exclusion dominates an include hit on ANOTHER argument", () => { + const hint = writeHint([ + { input: ["France"], param: "locations" }, + { input: ["France"], param: "exclude_locations", axis: "exclude" }, + ]); + expectFailClosed(hint); + // Exactly one instruction — no second hint trailing behind it. + expect(hint.match(/Write NOTHING/g)).toHaveLength(1); + }); + + it("and dominates a legitimate-looking include of a foreign country too", () => { + const hint = writeHint([ + { input: ["Canada"], param: "locations" }, + { input: ["France"], param: "exclude_locations", axis: "exclude" }, + ]); + expectFailClosed(hint); + // "Canada" is no longer a mere note here: a foreign INCLUDE became a + // blocker in its own right, so both halves are named as blockers and + // neither is presented as fixable by dropping the other. + expect(hint).toMatch(/"Canada"/); + expect(hint).toMatch(/"France"/); + expect(hint).toMatch(/Both failures are present in this one call/); + }); + + it("does not name the blocker twice when it arrives on both axes", () => { + const hint = writeHint([ + { input: ["France"], param: "locations" }, + { input: ["France"], param: "exclude_locations", axis: "exclude" }, + ]); + expect(hint).not.toMatch(/must come off it too/); + }); + + it("says the rest of the request cannot be written either", () => { + // The point an agent is most likely to talk itself out of: the sectors are + // fine, so why not write those? Because they would be written under a scope + // that inverts the ask. + const hint = writeHint([ + { input: ["France"], param: "exclude_locations", axis: "exclude" }, + ]); + expect(hint).toMatch(/rest of the request cannot be written either/i); + }); +}); + +describe("through the real tool", () => { + it("new_lens with sectors and a home-country exclusion writes nothing", async () => { + mockHttp([]); + const result: any = await newLens.execute(frClient(), { + name: "Not France", + sectors: ["Healthcare"], + locations: ["France"], + exclude_locations: ["France", "EU", "Paris"], + confirm: true, + }); + expect(result.status).toBe("country_level_location"); + expectFailClosed(result.hint); + expect(getHttpRequests()).toHaveLength(0); + }); +}); + +describe("what the global reconciliation must NOT swallow", () => { + it("a FOREIGN exclusion alone still carries the re-call", () => { + const hint = writeHint([ + { input: ["Canada"], param: "exclude_locations", axis: "exclude" }, + ]); + expect(hint).toMatch(/re-call ONCE with the rest of the request intact/); + expect(hint).not.toMatch(/Write NOTHING/); + }); + + it("a HOME-only include write keeps its per-argument instructions", () => { + // The global block is for values that cannot be dropped. The home country + // can: it is genuinely redundant, so each argument keeps its own recovery + // and the write goes through once corrected. + const hint = writeHint([ + { input: ["France"], param: "locations" }, + { input: ["France"], param: "location_ids" }, + ]); + expect(hint).not.toMatch(/Write NOTHING/); + expect(hint).toMatch(/locations/); + expect(hint).toMatch(/location_ids/); + }); + + it("…but a FOREIGN include among them does block", () => { + // Previously this passed as an "include-only writes are safe" case. It is + // not: dropping "Canada" and writing the rest persists a French audience + // for a Canadian request. + const hint = writeHint([ + { input: ["France"], param: "locations" }, + { input: ["Canada"], param: "location_ids" }, + ]); + expect(hint).toMatch(/Write NOTHING/); + expect(hint).toMatch(/no such audience to create/); + }); + + it("READS are untouched — nothing is being written there", () => { + const hint = countryLocationEnvelope( + detectCountryLocationsIn( + [{ input: ["France"], param: "exclude_locations", axis: "exclude" }], + "fr" + ), + "fr" + ).hint; + expect(hint).toMatch(/Excluding France excludes this ENTIRE workspace/); + expect(hint).not.toMatch(/Write NOTHING/); + }); +}); diff --git a/packages/core/test/unit/composite/country-echoed-parents.test.ts b/packages/core/test/unit/composite/country-echoed-parents.test.ts new file mode 100644 index 00000000..707e1b07 --- /dev/null +++ b/packages/core/test/unit/composite/country-echoed-parents.test.ts @@ -0,0 +1,228 @@ +/** + * A breadcrumb is not a filter value (product#3951). + * + * A round-tripped lens filter carries `locations.results` and + * `locations.parents` as denormalized lookup data — names for ids the criteria + * reference, plus the ancestor chain the UI shows as "Limoges ‹ Haute-Vienne ‹ + * Nouvelle-Aquitaine ‹ France". The country almost always appears in that + * chain, because every French admin area has France as an ancestor. + * + * The echoed blocks were added for one narrow job: a country selected as a bare + * numeric id is invisible in the criteria, and only the echoed row puts a name + * on it. Scanning every row instead of the selected ones inverted the guard — + * a filter legitimately scoped to Île-de-France was rejected because its + * breadcrumb mentions France, blocking a valid update_lens_filter outright. + * + * So a row participates only when a location_ids criterion actually selects its + * id. A country passed by NAME inside a criterion never depended on this path; + * criteriaHits catches it directly. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { updateLensFilter } from "../../../src/tools/update-lens-filter.js"; +import { + detectCountryLocationsInFilter, + filterCarriesOtherScope, +} from "../../../src/composite/_country-guard.js"; + +const frClient = () => new LeadbayClient("https://api-fr.leadbay.app", "u.test-token", "fr"); + +beforeEach(() => resetHttpMock()); + +/** What the backend really echoes for a filter scoped to one région. */ +const IDF_FILTER = { + lens_filter: { + items: [{ criteria: [{ type: "location_ids", is_excluded: false, locations: ["416102"] }] }], + }, + locations: { + results: [{ id: "416102", name: "Île-de-France", level: 5, parent_ids: ["1"] }], + parents: [{ id: "1", name: "France", level: 2 }], + }, +}; + +describe("parent breadcrumbs do not make a filter country-level", () => { + it("a région-scoped filter whose ancestor is France passes", () => { + expect(detectCountryLocationsInFilter(IDF_FILTER, "fr")).toEqual([]); + }); + + it("and update_lens_filter writes it instead of throwing", async () => { + mockHttp([{ method: "POST", path: "/1.6/lenses/4242/filter", status: 200, body: {} }]); + await expect( + updateLensFilter.execute(frClient(), { lensId: 4242, filter: IDF_FILTER as any }) + ).resolves.toBeDefined(); + expect(getHttpRequests().length).toBeGreaterThan(0); + }); + + it("a deep breadcrumb — city ‹ département ‹ région ‹ country — passes too", () => { + const filter = { + lens_filter: { + items: [{ criteria: [{ type: "location_ids", locations: ["27925"] }] }], + }, + locations: { + results: [{ id: "27925", name: "Limoges", level: 7 }], + parents: [ + { id: "87", name: "Haute-Vienne", level: 6 }, + { id: "75", name: "Nouvelle-Aquitaine", level: 5 }, + { id: "1", name: "France", level: 2 }, + ], + }, + }; + expect(detectCountryLocationsInFilter(filter, "fr")).toEqual([]); + }); + + it("an unreferenced row in results is lookup data too, not a selection", () => { + // Same rule, same reason: results is denormalized, and a row nothing + // selects is not a filter value. + const filter = { + lens_filter: { + items: [{ criteria: [{ type: "location_ids", locations: ["416102"] }] }], + }, + locations: { + results: [ + { id: "416102", name: "Île-de-France" }, + { id: "1", name: "France" }, + ], + parents: [], + }, + }; + expect(detectCountryLocationsInFilter(filter, "fr")).toEqual([]); + }); +}); + +describe("what the narrowing must NOT let through", () => { + it("a country SELECTED by bare id is still caught by its echoed name", () => { + // The reason the echoed blocks are consulted at all: "1" says nothing, and + // the criterion carries no name. Correlating it to the row is what makes + // the id-only ingress visible. + const filter = { + lens_filter: { items: [{ criteria: [{ type: "location_ids", locations: ["1"] }] }] }, + locations: { results: [{ id: "1", name: "France", level: 2 }], parents: [] }, + }; + const hits = detectCountryLocationsInFilter(filter, "fr"); + expect(hits).toHaveLength(1); + expect(hits[0].kind).toBe("home_country"); + }); + + it("a selected country appearing only in the PARENTS block is still caught", () => { + // Which block the name lives in is a backend detail; selection is what + // matters. A criterion naming this id means the country IS filtered on. + const filter = { + lens_filter: { items: [{ criteria: [{ type: "location_ids", locations: ["1"] }] }] }, + locations: { results: [], parents: [{ id: "1", name: "France", level: 2 }] }, + }; + expect(detectCountryLocationsInFilter(filter, "fr")).toHaveLength(1); + }); + + it("a selected country keeps the criterion's exclude polarity", () => { + const filter = { + lens_filter: { + items: [{ criteria: [{ type: "location_ids", is_excluded: true, locations: ["1"] }] }], + }, + locations: { results: [{ id: "1", name: "France" }], parents: [] }, + }; + expect(detectCountryLocationsInFilter(filter, "fr")[0].axis).toBe("exclude"); + }); + + it("a country passed by NAME never depended on the echoed blocks", () => { + const filter = { + lens_filter: { items: [{ criteria: [{ type: "location_ids", locations: ["France"] }] }] }, + locations: { results: [], parents: [] }, + }; + const hits = detectCountryLocationsInFilter(filter, "fr"); + expect(hits).toHaveLength(1); + expect(hits[0].param).toContain("criteria[].locations"); + }); + + it("numeric and string ids correlate across the two shapes", async () => { + // The criterion may carry 1 and the row "1"; both are the same selection. + const filter = { + lens_filter: { items: [{ criteria: [{ type: "location_ids", locations: [1] }] }] }, + locations: { results: [{ id: "1", name: "France" }], parents: [] }, + }; + expect(detectCountryLocationsInFilter(filter, "fr")).toHaveLength(1); + }); +}); + +describe("an id the echoed block names as a country is not surviving scope", () => { + const filterWith = (criteria: unknown[], results: unknown[], parents: unknown[] = []) => ({ + lens_filter: { items: [{ criteria }] }, + locations: { results, parents }, + }); + + it("a country selected by bare id leaves nothing to write", () => { + // Everywhere else in this module an opaque id counts as real scope — + // nothing can tell "416102" from a country (product#3939). That limit does + // not apply when the SAME payload names it: the id is known to be a + // country, and calling it surviving scope produced "remove the country and + // re-call with the remainder", where the remainder is nothing. The + // corrected call would replace the lens with an empty filter. + expect( + filterCarriesOtherScope( + filterWith([{ type: "location_ids", locations: ["1"] }], [{ id: "1", name: "France" }]), + "fr" + ) + ).toBe(false); + }); + + it("a real place beside it still counts", () => { + expect( + filterCarriesOtherScope( + filterWith( + [{ type: "location_ids", locations: ["1", "416102"] }], + [ + { id: "1", name: "France" }, + { id: "416102", name: "Île-de-France" }, + ] + ), + "fr" + ) + ).toBe(true); + }); + + it("an id nothing names still counts — that limit is unchanged", () => { + expect( + filterCarriesOtherScope(filterWith([{ type: "location_ids", locations: ["999"] }], []), "fr") + ).toBe(true); + }); + + it("a country named only in the parents block is discounted too", () => { + expect( + filterCarriesOtherScope( + filterWith([{ type: "location_ids", locations: ["1"] }], [], [{ id: "1", name: "France" }]), + "fr" + ) + ).toBe(false); + }); + + it("update_lens_filter tells the agent to write nothing, not to empty the filter", async () => { + mockHttp([]); + let thrown: any; + try { + await updateLensFilter.execute(frClient(), { + lensId: 4242, + filter: filterWith( + [{ type: "location_ids", locations: ["1"], is_excluded: false }], + [{ id: "1", name: "France" }] + ) as any, + }); + } catch (err) { + thrown = err; + } + expect(thrown.code).toBe("COUNTRY_LEVEL_LOCATION"); + expect(thrown.hint).toMatch(/Write NOTHING/); + expect( + thrown.hint, + "re-calling with the remainder replaces the lens with an empty filter" + ).not.toMatch(/re-call ONCE with the rest of the request intact/); + expect(getHttpRequests()).toHaveLength(0); + }); +}); diff --git a/packages/core/test/unit/composite/country-exclude-polarity.test.ts b/packages/core/test/unit/composite/country-exclude-polarity.test.ts new file mode 100644 index 00000000..a9afb937 --- /dev/null +++ b/packages/core/test/unit/composite/country-exclude-polarity.test.ts @@ -0,0 +1,392 @@ +/** + * Exclusion polarity, region normalization, and the tour payload's schema + * validity (review follow-up, product#3951). + * + * The polarity bug is the interesting one: every recovery in this guard was + * written for an INCLUDE, and each one inverts when the value arrives on an + * exclude axis. + * + * exclude the HOME country -> "omit the argument and the result covers the + * whole workspace" is the exact OPPOSITE of + * what was asked: the user wanted those + * companies gone, and omitting the exclusion + * returns every one of them. + * exclude a FOREIGN country -> a harmless NO-OP (nothing here is in it), not + * an "unsupported request". + * + * Also covered: a known regional base URL with a trailing slash must still + * derive its region (the guard reads client.region, so a mislabel silently + * downgrades a real home/foreign verdict to country_indeterminate), and the + * tour rejection payload must satisfy its own declared outputSchema. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, +} from "../../harness.js"; +vi.mock("node:https", () => httpsMockFactory()); + +import { createClient, LeadbayClient } from "../../../src/client.js"; +import { + countryLocationEnvelope, + detectCountryLocations, + detectCountryLocationsIn, + detectCountryLocationsInFilter, + detectCountryLocationsInSetFilter, +} from "../../../src/composite/_country-guard.js"; +import { newLens } from "../../../src/composite/new-lens.js"; +import { tourPlan } from "../../../src/composite/tour-plan.js"; + +const usClient = () => new LeadbayClient("https://api-us.leadbay.app", "u.t", "us"); +const frClient = () => new LeadbayClient("https://api-fr.leadbay.app", "u.t", "fr"); + +const excludeHint = (value: string, region: "us" | "fr" | "custom") => { + const hits = detectCountryLocations(value, "exclude_locations", region, "exclude"); + return countryLocationEnvelope(hits, region).hint; +}; +const includeHint = (value: string, region: "us" | "fr" | "custom") => { + const hits = detectCountryLocations(value, "locations", region, "include"); + return countryLocationEnvelope(hits, region).hint; +}; + +beforeEach(() => resetHttpMock()); + +describe("axis is carried on every hit", () => { + it("defaults to include and records exclude when asked", () => { + expect(detectCountryLocations("France", "locations", "fr")[0].axis).toBe("include"); + expect( + detectCountryLocations("France", "exclude_locations", "fr", "exclude")[0].axis + ).toBe("exclude"); + }); + + it("reads is_excluded off a location_ids criterion", () => { + const excluded = detectCountryLocationsInSetFilter( + { criteria: [{ type: "location_ids", is_excluded: true, locations: ["France"] }] }, + "set_filter", + "fr" + ); + expect(excluded[0].axis).toBe("exclude"); + + const included = detectCountryLocationsInSetFilter( + { criteria: [{ type: "location_ids", is_excluded: false, locations: ["France"] }] }, + "set_filter", + "fr" + ); + expect(included[0].axis).toBe("include"); + }); + + it("treats a missing is_excluded as an include", () => { + const hits = detectCountryLocationsInSetFilter( + { criteria: [{ type: "location_ids", locations: ["France"] }] }, + "set_filter", + "fr" + ); + expect(hits[0].axis).toBe("include"); + }); +}); + +describe("EXCLUDING the home country", () => { + it("never tells the agent to omit the exclusion", () => { + // Omitting it returns every French company — the reverse of the request. + const hint = excludeHint("France", "fr"); + expect(hint).not.toMatch(/OMIT `?exclude_locations/i); + expect(hint).not.toMatch(/covers the whole workspace/i); + }); + + it("says the exclusion would empty the workspace, and that dropping it inverts the ask", () => { + const hint = excludeHint("France", "fr"); + expect(hint).toMatch(/entire workspace/i); + expect(hint).toMatch(/result would be empty/i); + expect(hint).toMatch(/reverse of what was asked/i); + }); + + it("differs from the INCLUDE recovery for the same value", () => { + // The include recovery is legitimately "omit and answer"; the exclude one + // must not be. + expect(includeHint("France", "fr")).toMatch(/OMIT/); + expect(excludeHint("France", "fr")).not.toMatch(/OMIT `?exclude_locations/i); + }); +}); + +describe("EXCLUDING a foreign country", () => { + it("is reported as a no-op, not as unsupported", () => { + const hint = excludeHint("Germany", "us"); + expect(hint).toMatch(/no-op/i); + expect(hint).toMatch(/changes nothing|unaffected/i); + // The include wording — "there are no Germany leads to return" — is the + // wrong frame for an exclusion the user can simply drop. + expect(hint).not.toMatch(/does NOT answer a question about/i); + }); + + it("still differs from the include recovery", () => { + expect(includeHint("Germany", "us")).toMatch(/does NOT answer a question about/i); + }); +}); + +describe("EXCLUDING on other kinds", () => { + it("supra-national exclusion warns that dropping it includes everything", () => { + const hint = excludeHint("EMEA", "fr"); + expect(hint).toMatch(/cannot be excluded as an admin area/i); + expect(hint).toMatch(/would instead include everything/i); + }); + + it("custom backend refuses to guess which way the exclusion cuts", () => { + const hint = excludeHint("France", "custom"); + expect(hint).toMatch(/unknown/i); + expect(hint).toMatch(/everything or nothing/i); + }); +}); + +describe("leadbay_new_lens — exclude_locations carries the exclude axis", () => { + it("marks the hit as an exclusion and gives the exclusion recovery", async () => { + mockHttp([]); + const result: any = await newLens.execute(frClient(), { + name: "Not France", + exclude_locations: ["France"], + confirm: true, + }); + expect(result.status).toBe("country_level_location"); + expect(result.country_locations[0].axis).toBe("exclude"); + expect(result.hint).not.toMatch(/OMIT `?exclude_locations/i); + // new_lens WRITES, so the recovery stops rather than re-calls: dropping an + // exclusion of the home country and writing the rest persists the OPPOSITE + // of the request. It still has to say why the exclusion is impossible. + expect(result.hint).toMatch(/asks for an empty audience/i); + expect(result.hint).toMatch(/persists the opposite/i); + expect(result.hint).toMatch(/Write NOTHING/); + }); + + it("keeps the include recovery on the include axis", async () => { + mockHttp([]); + const result: any = await newLens.execute(frClient(), { + name: "All France", + locations: ["France"], + confirm: true, + }); + expect(result.country_locations[0].axis).toBe("include"); + // Not "OMIT locations and re-call": on a lens-writing tool with nothing + // else in the argument, that re-call IS the forbidden mutation. The include + // axis still shows through in what it says — the audience already covers + // the whole country, which the exclude axis never claims. + expect(result.hint).toMatch(/do NOT re-call this tool with locations omitted/i); + expect(result.hint).toMatch(/already covers all of France/); + }); + + it("reports both axes distinctly in one envelope", async () => { + mockHttp([]); + const result: any = await newLens.execute(usClient(), { + name: "Mixed", + locations: ["Canada"], + exclude_locations: ["Mexico"], + confirm: true, + }); + expect(result.country_locations.map((h: any) => h.axis)).toEqual([ + "include", + "exclude", + ]); + }); +}); + +describe("echoed resolved-areas block inherits the criterion's polarity", () => { + // The dangerous shape: the criterion carries only a numeric id, so the country + // is visible ONLY via the echoed name. Defaulting that hit to "include" made + // the recovery tell the caller to omit the location — returning the whole + // workspace instead of explaining that the exclusion would empty it. + const roundTripped = (isExcluded: boolean) => ({ + lens_filter: { + items: [ + { criteria: [{ type: "location_ids", is_excluded: isExcluded, locations: ["27925"] }] }, + ], + }, + locations: { + results: [{ id: "27925", name: "France", country: "FR", level: 2, parent_ids: [] }], + parents: [], + }, + }); + + it("marks an echoed row as exclude when its criterion excludes it", () => { + const hits = detectCountryLocationsInFilter(roundTripped(true), "fr"); + expect(hits).toHaveLength(1); + // The param points at the CRITERION, not at the echoed name that revealed + // it: the name is not what selects the country, so a recovery aimed at the + // name leaves the id in place and the filter in force. + expect(hits[0].param).toContain("lens_filter.items[].criteria[].locations"); + expect(hits[0].selectedId).toBe("27925"); + expect(hits[0].axis).toBe("exclude"); + }); + + it("keeps include polarity when the criterion includes it", () => { + const hits = detectCountryLocationsInFilter(roundTripped(false), "fr"); + expect(hits[0].axis).toBe("include"); + }); + + it("gives the exclusion recovery for the excluded round-trip", () => { + const hits = detectCountryLocationsInFilter(roundTripped(true), "fr"); + const hint = countryLocationEnvelope(hits, "fr").hint; + expect(hint).not.toMatch(/\bOMIT\b/i); + expect(hint).toMatch(/entire workspace/i); + }); + + it("matches a numeric id against a string-keyed criterion", () => { + // The wire shape is loose: ids arrive as numbers or strings. + const filter = { + lens_filter: { + items: [{ criteria: [{ type: "location_ids", is_excluded: true, locations: [27925] }] }], + }, + locations: { results: [{ id: "27925", name: "France" }], parents: [] }, + }; + expect(detectCountryLocationsInFilter(filter, "fr")[0].axis).toBe("exclude"); + }); + + it("ignores an echoed row that no criterion references", () => { + // There is no polarity to inherit because there is no selection: the row is + // denormalized lookup data. This originally asserted a default of + // "include", which is how a parent breadcrumb came to be read as a filter + // value — see country-echoed-parents.test.ts. + const filter = { + lens_filter: { items: [] }, + locations: { results: [{ id: "27925", name: "France" }], parents: [] }, + }; + expect(detectCountryLocationsInFilter(filter, "fr")).toEqual([]); + }); + + it("lets the destructive reading win when both axes name the same id", () => { + const filter = { + lens_filter: { + items: [ + { criteria: [{ type: "location_ids", is_excluded: false, locations: ["27925"] }] }, + { criteria: [{ type: "location_ids", is_excluded: true, locations: ["27925"] }] }, + ], + }, + locations: { results: [{ id: "27925", name: "France" }], parents: [] }, + }; + const echoed = detectCountryLocationsInFilter(filter, "fr").filter( + (h) => h.selectedId !== undefined + ); + expect(echoed[0].axis).toBe("exclude"); + }); +}); + +describe("dependent territories stay valid on a custom endpoint", () => { + // With no known home country a strict sovereign===home test exempted nothing, + // so Martinique on an FR staging backend and Puerto Rico on a US one were + // rejected — blocking real prospecting on the documented LEADBAY_BASE_URL path. + for (const territory of [ + "Martinique", + "Guadeloupe", + "La Réunion", + "Mayotte", + "Guyane", + "Puerto Rico", + "Guam", + "American Samoa", + ]) { + it(`allows ${JSON.stringify(territory)} on a custom endpoint`, () => { + expect(detectCountryLocations(territory, "city", "custom")).toEqual([]); + }); + } + + it("still rejects a sovereign country on a custom endpoint", () => { + // The permissive branch is for DEPENDENT territories only — a sovereign + // state is still refused (as indeterminate, claiming nothing). + expect(detectCountryLocations("France", "city", "custom")[0]?.kind).toBe( + "country_indeterminate" + ); + expect(detectCountryLocations("Germany", "city", "custom")[0]?.kind).toBe( + "country_indeterminate" + ); + }); + + it("keeps the strict per-region behaviour where the home country IS known", () => { + // Guadeloupe is in-universe on FR and out-of-universe on US. + expect(detectCountryLocations("Guadeloupe", "city", "fr")).toEqual([]); + expect(detectCountryLocations("Guadeloupe", "city", "us")[0]?.kind).toBe( + "foreign_country" + ); + }); +}); + +describe("the country MESSAGE respects polarity too", () => { + it("does not claim an exclusion 'removes nothing'", () => { + // Message and hint are surfaced together; an un-branched message said + // filtering by France "removes nothing" while the hint said the exclusion + // would empty the workspace. + const hits = detectCountryLocations("France", "exclude_locations", "fr", "exclude"); + const message = countryLocationEnvelope(hits, "fr").message; + expect(message).not.toMatch(/removes nothing/i); + expect(message).toMatch(/remove every company/i); + }); + + it("still says an INCLUDE of the home country removes nothing", () => { + const hits = detectCountryLocations("France", "locations", "fr", "include"); + expect(countryLocationEnvelope(hits, "fr").message).toMatch(/removes nothing/i); + }); + + it("says excluding a foreign country removes nothing to exclude", () => { + const hits = detectCountryLocations("Germany", "exclude_locations", "us", "exclude"); + const message = countryLocationEnvelope(hits, "us").message; + expect(message).toMatch(/nothing here to exclude/i); + expect(message).not.toMatch(/holds no Germany companies/i); + }); +}); + +describe("createClient — region survives a trailing slash", () => { + it("derives fr from a known regional URL written with a trailing slash", () => { + // A perfectly ordinary env-var spelling. Comparing it raw labelled the + // tenant "custom", which downgraded the guard to country_indeterminate. + const client = createClient({ token: "u.t", baseUrl: "https://api-fr.leadbay.app/" }); + expect(client.region).toBe("fr"); + expect(detectCountryLocations("France", "city", client.region)[0]?.kind).toBe( + "home_country" + ); + }); + + it("derives us with a trailing slash too", () => { + expect(createClient({ token: "u.t", baseUrl: "https://api-us.leadbay.app/" }).region).toBe("us"); + }); + + it("tolerates several trailing slashes", () => { + expect(createClient({ token: "u.t", baseUrl: "https://api-fr.leadbay.app///" }).region).toBe("fr"); + }); + + it("still calls a genuinely custom endpoint custom", () => { + expect( + createClient({ token: "u.t", baseUrl: "https://api-staging.leadbay.app/" }).region + ).toBe("custom"); + }); + + it("setBaseUrl derives the same way", () => { + const client = createClient({ token: "u.t" }); + client.setBaseUrl("https://api-fr.leadbay.app/"); + expect(client.region).toBe("fr"); + }); +}); + +describe("leadbay_tour_plan — the rejection payload is schema-valid", () => { + it("returns discover_filter_note as a STRING, never null", async () => { + // outputSchema declares `type: "string"`, and the happy path always returns + // one. A null here made a validating client reject the whole rejection — + // hiding the recovery hint it exists to deliver. + mockHttp([]); + const result: any = await tourPlan.execute(frClient(), { city: "France" }); + expect(typeof result.discover_filter_note).toBe("string"); + expect(result.discover_filter_note.length).toBeGreaterThan(0); + }); + + it("satisfies every required output field", async () => { + mockHttp([]); + const result: any = await tourPlan.execute(frClient(), { city: "France" }); + for (const field of ["monitor_leads", "discover_leads", "map_locations"]) { + expect(Array.isArray(result[field]), field).toBe(true); + } + expect(typeof result.map_summary.total_leads).toBe("number"); + }); + + it("still refuses to advise omitting the city", async () => { + mockHttp([]); + const result: any = await tourPlan.execute(frClient(), { city: "France" }); + expect(result.hint).toMatch(/do NOT re-call without `city`/i); + }); +}); diff --git a/packages/core/test/unit/composite/country-exclusion-blocks-write.test.ts b/packages/core/test/unit/composite/country-exclusion-blocks-write.test.ts new file mode 100644 index 00000000..52dba7cd --- /dev/null +++ b/packages/core/test/unit/composite/country-exclusion-blocks-write.test.ts @@ -0,0 +1,175 @@ +/** + * A write must never be talked into the OPPOSITE of an exclusion + * (product#3951). + * + * Dropping an INCLUDE of a country widens the result — at worst imprecise, and + * for the home country it is exactly right. Dropping an EXCLUDE inverts it. + * `newLens({sectors: ["Healthcare"], exclude_locations: ["France"]})` on FR + * asks for an audience with nothing in it; re-calling without the exclusion + * persists a lens of French healthcare companies — every company the user + * asked to remove, written to the lens and reported as done. + * + * The previous round's `otherScope` carve-out reached exactly this case: a + * surviving sector made the guard say "drop the geo argument and re-call with + * the rest intact". A surviving criterion does not make the inversion less + * wrong; it only decides how much of it gets written. So an un-droppable + * exclusion blocks the write whatever else survives. + * + * Only a FOREIGN exclusion is provably a no-op — there is nothing here to + * remove — so only that one may be dropped and carried on with. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { newLens } from "../../../src/composite/new-lens.js"; +import { adjustAudience } from "../../../src/composite/adjust-audience.js"; +import { pullFollowups } from "../../../src/composite/pull-followups.js"; +import { + detectCountryLocations, + countryLocationEnvelope, +} from "../../../src/composite/_country-guard.js"; + +const frClient = () => new LeadbayClient("https://api-fr.leadbay.app", "u.test-token", "fr"); +const customClient = () => + new LeadbayClient("https://staging.internal.example", "u.test-token", "custom"); + +beforeEach(() => resetHttpMock()); + +const INVERTS = /persists the opposite|persists the OPPOSITE/; +const CARRY = /re-call ONCE with the rest of the request intact/; + +describe("a home-country exclusion stops the write, whatever else survives", () => { + it("new_lens: a sector does not license dropping the exclusion", async () => { + mockHttp([]); + const result: any = await newLens.execute(frClient(), { + name: "Not France", + sectors: ["Healthcare"], + exclude_locations: ["France"], + confirm: true, + }); + expect(result.status).toBe("country_level_location"); + expect( + result.hint, + "re-calling here writes French healthcare companies — the opposite of the ask" + ).not.toMatch(CARRY); + expect(result.hint).toMatch(INVERTS); + expect(result.hint).toMatch(/Write NOTHING/); + expect(result.hint, "and it must offer the carve-out that WOULD work").toMatch( + /carved out/ + ); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("adjust_audience: same, on the tool whose criteria merge irreversibly", async () => { + mockHttp([]); + const result: any = await adjustAudience.execute(frClient(), { + sectors: ["Healthcare"], + exclude_locations: ["France"], + }); + expect(result.hint).not.toMatch(CARRY); + expect(result.hint).toMatch(INVERTS); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("a surviving city in the same argument does not license it either", async () => { + // Excluding Paris is honourable; excluding France is not, and writing the + // Paris half alone still leaves the lens holding French companies. + mockHttp([]); + const result: any = await newLens.execute(frClient(), { + name: "Neither", + sectors: ["Healthcare"], + exclude_locations: ["Paris", "France"], + confirm: true, + }); + expect(result.hint).toMatch(/Write NOTHING/); + expect(result.hint).not.toMatch(/other exclusions still apply/i); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("a group mixing a home and a foreign exclusion still stops on the home one", async () => { + mockHttp([]); + const result: any = await newLens.execute(frClient(), { + name: "Both", + sectors: ["Healthcare"], + exclude_locations: ["France", "Canada"], + confirm: true, + }); + // One instruction for the whole request, and it carries no re-call + // directive at all — a "remove these and re-call" prefix beside a STOP is + // an instruction to perform the mutation the STOP forbids. + expect(result.hint).toMatch(/do NOT re-call this tool in any form/i); + expect(result.hint).toMatch(/not without "France"/); + expect(result.hint).toMatch(/Write NOTHING/); + expect(result.hint, "the foreign value still has to come off eventually").toMatch( + /"Canada" must come off it too/ + ); + }); + + it("a supra-national exclusion stops too — it may cover this workspace", () => { + const text = countryLocationEnvelope( + detectCountryLocations(["EMEA"], "exclude_locations", "fr", "exclude"), + "fr", + "write", + true + ).hint; + expect(text).toMatch(/may well cover this whole workspace/); + expect(text).toMatch(/Write NOTHING/); + }); + + it("an unknown-country exclusion on a custom backend stops too", async () => { + mockHttp([]); + const result: any = await newLens.execute(customClient(), { + name: "Not France", + sectors: ["Healthcare"], + exclude_locations: ["France"], + confirm: true, + }); + expect(result.hint).toMatch(/Write NOTHING/); + expect(result.hint).toMatch(/unknown/i); + }); +}); + +describe("what the exclusion block must NOT stop", () => { + it("a FOREIGN exclusion is a provable no-op, so the write goes through", async () => { + // Nothing in an FR workspace is in Canada, so dropping this exclusion + // changes nothing at all — the sector criterion is still what the user + // asked for and must be written. + mockHttp([]); + const result: any = await newLens.execute(frClient(), { + name: "Healthcare", + sectors: ["Healthcare"], + exclude_locations: ["Canada"], + confirm: true, + }); + expect(result.hint).toMatch(CARRY); + expect(result.hint).not.toMatch(/Write NOTHING/); + expect(result.hint).toMatch(/no Canada audience to add/); + }); + + it("an INCLUDE of the home country beside a sector still re-calls", async () => { + mockHttp([]); + const result: any = await newLens.execute(frClient(), { + name: "Healthcare", + sectors: ["Healthcare"], + locations: ["France"], + confirm: true, + }); + expect(result.hint).toMatch(CARRY); + expect(result.hint).not.toMatch(INVERTS); + }); + + it("the READ tools are untouched — nothing is being written there", async () => { + mockHttp([]); + const result: any = await pullFollowups.execute(frClient(), { city: "France" }); + expect(result.hint).toMatch(/Whole-workspace intent = OMIT city entirely/); + expect(result.hint).not.toMatch(/Write NOTHING/); + }); +}); diff --git a/packages/core/test/unit/composite/country-geo-scope-survives.test.ts b/packages/core/test/unit/composite/country-geo-scope-survives.test.ts new file mode 100644 index 00000000..1311fd9e --- /dev/null +++ b/packages/core/test/unit/composite/country-geo-scope-survives.test.ts @@ -0,0 +1,181 @@ +/** + * Scope is a property of the REQUEST, not of one argument (product#3951). + * + * `CountryHit.kept` only ever sees the argument its own value arrived on. So + * `newLens({locations: ["France"], exclude_locations: ["Paris"]})` on FR + * produced `kept: []`, and the previous `otherScope` calculation looked only at + * sectors / sizes / base — geo fields were invisible to it. The guard therefore + * declared the country the only scope passed and told the agent to write + * nothing, discarding a perfectly good Paris exclusion. + * + * `geoScopeSurvives` counts usable values across ALL the geo arguments, and + * `filterCarriesOtherScope` does the same inside a lens-filter payload, where + * a location criterion naming a real place beside the country is scope too — + * that filter is replaced wholesale, so stopping loses it. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { newLens } from "../../../src/composite/new-lens.js"; +import { adjustAudience } from "../../../src/composite/adjust-audience.js"; +import { updateLensFilter } from "../../../src/tools/update-lens-filter.js"; +import { + geoScopeSurvives, + filterCarriesOtherScope, +} from "../../../src/composite/_country-guard.js"; + +const frClient = () => new LeadbayClient("https://api-fr.leadbay.app", "u.test-token", "fr"); + +beforeEach(() => resetHttpMock()); + +const CARRY = /re-call ONCE with the rest of the request intact/; +const STOP = /Write NOTHING/; + +describe("geoScopeSurvives", () => { + const P = (input: unknown, param = "locations") => [{ input, param }]; + + it("sees a real place on a DIFFERENT argument", () => { + expect( + geoScopeSurvives( + [ + { input: ["France"], param: "locations" }, + { input: ["Paris"], param: "exclude_locations" }, + ], + "fr" + ) + ).toBe(true); + }); + + it("reports none when every geo value is country-level", () => { + expect( + geoScopeSurvives( + [ + { input: ["France"], param: "locations" }, + { input: ["Canada"], param: "exclude_locations" }, + ], + "fr" + ) + ).toBe(false); + }); + + it("counts a resolved numeric id — unclassifiable here, but a place all the same", () => { + expect(geoScopeSurvives(P([416102], "location_ids"), "fr")).toBe(true); + expect(geoScopeSurvives(P(["416102"], "location_ids"), "fr")).toBe(true); + }); + + it("ignores absent and empty arguments", () => { + expect(geoScopeSurvives(P(undefined), "fr")).toBe(false); + expect(geoScopeSurvives(P(null), "fr")).toBe(false); + expect(geoScopeSurvives(P([]), "fr")).toBe(false); + expect(geoScopeSurvives(P([" "]), "fr")).toBe(false); + }); + + it("is region-aware: Georgia is a place on US and a country on FR", () => { + expect(geoScopeSurvives(P(["Georgia"]), "us")).toBe(true); + expect(geoScopeSurvives(P(["Georgia"]), "fr")).toBe(false); + }); +}); + +describe("the write-stop respects geography on another argument", () => { + it("new_lens: an exclusion of a real city keeps the write alive", async () => { + mockHttp([]); + const result: any = await newLens.execute(frClient(), { + name: "France minus Paris", + locations: ["France"], + exclude_locations: ["Paris"], + confirm: true, + }); + expect(result.status).toBe("country_level_location"); + expect( + result.hint, + "stopping here throws away the Paris exclusion the user asked for" + ).not.toMatch(STOP); + expect(result.hint).toMatch(CARRY); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("adjust_audience: a location_id beside the country counts", async () => { + mockHttp([]); + const result: any = await adjustAudience.execute(frClient(), { + locations: ["France"], + location_ids: ["416102"], + }); + expect(result.hint).toMatch(CARRY); + expect(result.hint).not.toMatch(STOP); + }); + + it("but a country on EVERY geo argument still stops", async () => { + mockHttp([]); + const result: any = await newLens.execute(frClient(), { + name: "Nothing real", + locations: ["France"], + exclude_locations: ["Canada"], + confirm: true, + }); + expect(result.hint).toMatch(STOP); + expect(getHttpRequests()).toHaveLength(0); + }); +}); + +describe("filterCarriesOtherScope sees a surviving location value", () => { + const filterWith = (criteria: unknown[]) => ({ + lens_filter: { items: [{ criteria }] }, + locations: { results: [], parents: [] }, + }); + + it("a location criterion naming a real place is scope", () => { + expect( + filterCarriesOtherScope( + filterWith([{ type: "location_ids", locations: ["France", "Paris"] }]), + "fr" + ) + ).toBe(true); + }); + + it("a location criterion of nothing but countries is not", () => { + expect( + filterCarriesOtherScope( + filterWith([{ type: "location_ids", locations: ["France"] }]), + "fr" + ) + ).toBe(false); + }); + + it("a second criterion naming a place counts even when the first is country-only", () => { + expect( + filterCarriesOtherScope( + filterWith([ + { type: "location_ids", locations: ["France"] }, + { type: "location_ids", locations: ["416102"], is_excluded: true }, + ]), + "fr" + ) + ).toBe(true); + }); + + it("update_lens_filter does not discard that place", async () => { + mockHttp([]); + let thrown: any; + try { + await updateLensFilter.execute(frClient(), { + lensId: 4242, + filter: filterWith([ + { type: "location_ids", locations: ["France", "Paris"], is_excluded: false }, + ]) as any, + }); + } catch (err) { + thrown = err; + } + expect(thrown.code).toBe("COUNTRY_LEVEL_LOCATION"); + expect(thrown.hint).not.toMatch(STOP); + expect(getHttpRequests()).toHaveLength(0); + }); +}); diff --git a/packages/core/test/unit/composite/country-guard-helpers.test.ts b/packages/core/test/unit/composite/country-guard-helpers.test.ts new file mode 100644 index 00000000..2623a722 --- /dev/null +++ b/packages/core/test/unit/composite/country-guard-helpers.test.ts @@ -0,0 +1,636 @@ +/** + * Unit matrix for the single-country-universe guard (product#3951). + * + * The sweeps at the bottom are the point of this file. A country blocklist is + * easy to write and easy to get catastrophically wrong: the raw ISO 3166-1 list + * contains Guadeloupe, Martinique, Réunion and Guyane (legitimate FR admin + * areas), Puerto Rico and Guam (legitimate US ones), and 26 alpha-2 codes that + * are also US state postal codes. Asserting every US state and every French + * région/département still resolves is what forces those exemptions to exist + * rather than being trusted to a comment. + */ +import { describe, it, expect } from "vitest"; + +import { REGIONS } from "../../../src/client.js"; +import { + COUNTRIES, + COUNTRY_KEY_COLLISIONS, + HOME_COUNTRY_BY_REGION, + REGION_EXEMPT_KEYS, + countryKey, +} from "../../../src/composite/_country-names.js"; +import { + COUNTRY_LEVEL_LOCATION, + countryLocationEnvelope, + countryLocationStatus, + detectCountryLocations, + detectCountryLocationsIn, + detectCountryLocationsInFilter, + rejectCountryLocations, + type GuardRegion, +} from "../../../src/composite/_country-guard.js"; + +const hitsFor = (value: unknown, region: GuardRegion = "us") => + detectCountryLocations(value, "locations", region); + +const rejects = (value: string, region: GuardRegion = "us") => + hitsFor(value, region).length > 0; + +describe("_country-names dataset integrity", () => { + it("carries the full ISO 3166-1 list with unique codes", () => { + expect(COUNTRIES.length).toBeGreaterThanOrEqual(249); + expect(new Set(COUNTRIES.map((c) => c.iso2)).size).toBe(COUNTRIES.length); + expect(new Set(COUNTRIES.map((c) => c.iso3)).size).toBe(COUNTRIES.length); + }); + + it("no two countries fold to the same key", () => { + // A collision means one country silently shadows another. + expect(COUNTRY_KEY_COLLISIONS).toEqual([]); + }); + + it("every entry has both an English and a French name", () => { + const missing = COUNTRIES.filter((c) => !c.name?.trim() || !c.nameFr?.trim()); + expect(missing.map((c) => c.iso2)).toEqual([]); + }); + + it("every backend region has a home country and an exemption set", () => { + // Adding a third region (say `de`) without wiring these tables would give + // that backend NO home country: "Germany" would be classified foreign on a + // German universe, and every German admin area homonym would be refused. + // Fail here rather than shipping a silently-wrong guard. + const missingHome = Object.keys(REGIONS).filter( + (region) => !(region in HOME_COUNTRY_BY_REGION) + ); + const missingExempt = Object.keys(REGIONS).filter( + (region) => !(region in REGION_EXEMPT_KEYS) + ); + expect(missingHome, "add the region to HOME_COUNTRY_BY_REGION").toEqual([]); + expect(missingExempt, "add the region to REGION_EXEMPT_KEYS").toEqual([]); + }); + + it("every home country resolves to a real entry in the dataset", () => { + for (const iso2 of Object.values(HOME_COUNTRY_BY_REGION)) { + expect(COUNTRIES.some((c) => c.iso2 === iso2)).toBe(true); + } + }); + + it("marks the French and US dependent territories with their sovereign", () => { + const fr = COUNTRIES.filter((c) => c.sovereign === "FR").map((c) => c.iso2); + const us = COUNTRIES.filter((c) => c.sovereign === "US").map((c) => c.iso2); + // Without these the guard would reject legitimate in-universe admin areas. + expect(fr).toEqual( + expect.arrayContaining(["GP", "MQ", "RE", "YT", "GF", "MF", "BL", "NC", "PF", "PM", "WF"]) + ); + expect(us).toEqual( + expect.arrayContaining(["PR", "GU", "VI", "AS", "MP"]) + ); + }); +}); + +describe("countryKey normalization", () => { + it("folds articles, accents, punctuation and case", () => { + expect(countryKey("la France")).toBe("france"); + expect(countryKey("les États-Unis")).toBe("etats unis"); + expect(countryKey("U.S.")).toBe("us"); + expect(countryKey("U.S")).toBe("us"); + expect(countryKey(" FRANCE ")).toBe("france"); + expect(countryKey("France?")).toBe("france"); + expect(countryKey("(France)")).toBe("france"); + }); + + it("folds the ELIDED French article — the bug the earlier guard had", () => { + // Deleting apostrophes before the article strip left "lallemagne", so the + // leading-article branch was dead for every elided French country name. + expect(countryKey("l'Allemagne")).toBe("allemagne"); + expect(countryKey("l'Espagne")).toBe("espagne"); + expect(countryKey("l'Italie")).toBe("italie"); + }); + + it("keeps qualified place names distinct from the bare country", () => { + // This is the user's override path AND the anti-false-positive guarantee. + expect(countryKey("Île-de-France")).not.toBe(countryKey("France")); + expect(countryKey("China, ME")).not.toBe(countryKey("China")); + expect(countryKey("Mexico, MO")).not.toBe(countryKey("Mexico")); + expect(countryKey("Val-d'Oise")).toBe("val d oise"); + }); +}); + +describe("rejected values — US universe", () => { + const homeLabels = [ + "United States", + "the United States", + "United States of America", + "U.S", + "U.S.", + "U.S.A.", + "USA", + "America", + "us", + " US ", + "États-Unis", + "les États-Unis", + "etats-unis", + "Etats Unis", + ]; + for (const value of homeLabels) { + it(`rejects the home country ${JSON.stringify(value)}`, () => { + const hits = hitsFor(value, "us"); + expect(hits).toHaveLength(1); + expect(hits[0].kind).toBe("home_country"); + }); + } + + const foreignLabels = [ + "France", + "la France", + "République Française", + "FRA", + "Germany", + "Deutschland", + "l'Allemagne", + "DEU", + "United Kingdom", + "UK", + "Great Britain", + "Netherlands", + "Holland", + "United Arab Emirates", + "UAE", + "China", + "Mexico", + "Brazil", + "Luxembourg", + "Monaco", + "Chad", + "Bosnia and Herzegovina", + "Bosnia & Herzegovina", + "Côte d'Ivoire", + "Ivory Coast", + "Guadeloupe", + ]; + for (const value of foreignLabels) { + it(`rejects the foreign country ${JSON.stringify(value)}`, () => { + const hits = hitsFor(value, "us"); + expect(hits).toHaveLength(1); + expect(hits[0].kind).toBe("foreign_country"); + }); + } +}); + +describe("rejected values — FR universe", () => { + for (const value of ["France", "la France", "République française", "FRA", "fr", "FR"]) { + it(`rejects the home country ${JSON.stringify(value)}`, () => { + expect(hitsFor(value, "fr")[0]?.kind).toBe("home_country"); + }); + } + + for (const value of [ + "United States", + "USA", + "États-Unis", + "Georgia", + "Géorgie", + "Jersey", + "Puerto Rico", + "Suisse", + "Switzerland", + "Belgique", + "Espagne", + "Allemagne", + "US", + ]) { + it(`rejects the out-of-universe ${JSON.stringify(value)}`, () => { + expect(hitsFor(value, "fr")[0]?.kind).toBe("foreign_country"); + }); + } +}); + +describe("whole-workspace phrasings are HOME intent, not supra-national", () => { + // These mean "the whole of MY country", so the recovery is the home one + // (omit and answer) rather than report-the-scope. Grouping them with + // EMEA/APAC gave the wrong advice for the commonest phrasing of all. + for (const value of [ + "nationwide", "Nation-wide", "countrywide", "the whole country", + "entire country", "everywhere", "anywhere", "all regions", + ]) { + it(`classifies ${JSON.stringify(value)} as home_country on US`, () => { + const hits = hitsFor(value, "us"); + expect(hits).toHaveLength(1); + expect(hits[0].kind).toBe("home_country"); + expect(hits[0].country).toBe("United States"); + }); + } + + for (const value of ["Toute la France", "partout", "partout en France", "Échelle nationale"]) { + it(`classifies ${JSON.stringify(value)} as home_country on FR`, () => { + expect(hitsFor(value, "fr")[0]?.kind).toBe("home_country"); + }); + } + + it("their hint is the omit-and-answer one", () => { + const envelope = countryLocationEnvelope(hitsFor("nationwide", "us"), "us"); + expect(envelope.hint).toMatch(/OMIT/); + }); + + it("still means the whole workspace when there is no home country", () => { + // A custom backend's universe is unnamed, NOT unknown in size: every + // backend covers exactly one country, and "everywhere" names none, so the + // request is unambiguous and the unfiltered read answers it exactly. + // Classifying it supra-national produced a hint that FORBADE that read. + const hit = hitsFor("everywhere", "custom")[0]; + expect(hit?.kind).toBe("country_indeterminate"); + // No country is named, which is the only thing a custom backend withholds. + expect(hit?.country).toBeNull(); + }); + + it("the no-home-country hint affirms the omit, and names no country", () => { + const envelope = countryLocationEnvelope(hitsFor("nationwide", "custom"), "custom"); + expect(envelope.hint).toMatch(/OMIT/); + expect(envelope.hint).toMatch(/do NOT name which country/i); + // The supra-national hint's refusal must NOT appear here. + expect(envelope.hint).not.toMatch(/Do NOT drop/i); + }); +}); + +describe("rejected values — supra-national scopes", () => { + for (const value of [ + "EU", + "Europe", + "European Union", + "EMEA", + "Worldwide", + "Global", + "Le monde entier", + ]) { + it(`rejects ${JSON.stringify(value)}`, () => { + const hits = hitsFor(value, "us"); + expect(hits).toHaveLength(1); + expect(hits[0].kind).toBe("supranational"); + expect(hits[0].country).toBeNull(); + }); + } +}); + +describe("allowed values — must never false-positive", () => { + const usAllowed = [ + "Austin", + "New York", + "New York City", + "Los Angeles", + "Kansas City", + "Paris", + "Île-de-France", + // The US state, which a rep names with the bare word. + "Georgia", + "Jersey", + "Jersey City", + // US territories — in-universe. + "Puerto Rico", + "Guam", + "American Samoa", + "Northern Mariana Islands", + // Qualified homonyms — the documented override path. + "Mexico City", + "Mexico, MO", + "China, ME", + "Lebanon PA", + "Panama City FL", + "Monaco, PA", + "Georgia, US", + "Guadalajara", + "Washington", + "Bay Area", + ]; + for (const value of usAllowed) { + it(`allows ${JSON.stringify(value)} on the US universe`, () => { + expect(rejects(value, "us")).toBe(false); + }); + } + + const frAllowed = [ + "Île-de-France", + "Paris", + "Limoges", + "Indre-et-Loire", + "Val-d'Oise", + "Corse", + // FR overseas territories — in-universe. + "Guadeloupe", + "Martinique", + "La Réunion", + "Réunion", + "Mayotte", + "Guyane", + "Nouvelle-Calédonie", + "Polynésie française", + "Saint-Martin", + "Saint-Barthélemy", + "Saint-Pierre-et-Miquelon", + "Wallis-et-Futuna", + ]; + for (const value of frAllowed) { + it(`allows ${JSON.stringify(value)} on the FR universe`, () => { + expect(rejects(value, "fr")).toBe(false); + }); + } + + // ── The sweeps ────────────────────────────────────────────────────────── + const US_STATES = [ + "Alabama", "Alaska", "Arizona", "Arkansas", "California", "Colorado", + "Connecticut", "Delaware", "Florida", "Georgia", "Hawaii", "Idaho", + "Illinois", "Indiana", "Iowa", "Kansas", "Kentucky", "Louisiana", "Maine", + "Maryland", "Massachusetts", "Michigan", "Minnesota", "Mississippi", + "Missouri", "Montana", "Nebraska", "Nevada", "New Hampshire", "New Jersey", + "New Mexico", "New York", "North Carolina", "North Dakota", "Ohio", + "Oklahoma", "Oregon", "Pennsylvania", "Rhode Island", "South Carolina", + "South Dakota", "Tennessee", "Texas", "Utah", "Vermont", "Virginia", + "Washington", "West Virginia", "Wisconsin", "Wyoming", + ]; + const US_POSTAL = [ + "AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "DC", "FL", "GA", "HI", + "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME", "MD", "MA", "MI", "MN", + "MS", "MO", "MT", "NE", "NV", "NH", "NJ", "NM", "NY", "NC", "ND", "OH", + "OK", "OR", "PA", "RI", "SC", "SD", "TN", "TX", "UT", "VT", "VA", "WA", + "WV", "WI", "WY", + ]; + + it("allows every US state NAME on the US universe", () => { + expect(US_STATES.filter((s) => rejects(s, "us"))).toEqual([]); + }); + + it("allows every US state POSTAL CODE on the US universe", () => { + // 26 of these are also ISO alpha-2 country codes (CA, IN, LA, PA, ME, DE…). + expect(US_POSTAL.filter((s) => rejects(s, "us"))).toEqual([]); + }); + + const FR_REGIONS = [ + "Auvergne-Rhône-Alpes", "Bourgogne-Franche-Comté", "Bretagne", + "Centre-Val de Loire", "Corse", "Grand Est", "Hauts-de-France", + "Île-de-France", "Normandie", "Nouvelle-Aquitaine", "Occitanie", + "Pays de la Loire", "Provence-Alpes-Côte d'Azur", + ]; + const FR_DEPARTEMENTS = [ + "Ain", "Aisne", "Allier", "Alpes-de-Haute-Provence", "Hautes-Alpes", + "Alpes-Maritimes", "Ardèche", "Ardennes", "Ariège", "Aube", "Aude", + "Aveyron", "Bouches-du-Rhône", "Calvados", "Cantal", "Charente", + "Charente-Maritime", "Cher", "Corrèze", "Corse-du-Sud", "Haute-Corse", + "Côte-d'Or", "Côtes-d'Armor", "Creuse", "Dordogne", "Doubs", "Drôme", + "Eure", "Eure-et-Loir", "Finistère", "Gard", "Haute-Garonne", "Gers", + "Gironde", "Hérault", "Ille-et-Vilaine", "Indre", "Indre-et-Loire", + "Isère", "Jura", "Landes", "Loir-et-Cher", "Loire", "Haute-Loire", + "Loire-Atlantique", "Loiret", "Lot", "Lot-et-Garonne", "Lozère", + "Maine-et-Loire", "Manche", "Marne", "Haute-Marne", "Mayenne", + "Meurthe-et-Moselle", "Meuse", "Morbihan", "Moselle", "Nièvre", "Nord", + "Oise", "Orne", "Pas-de-Calais", "Puy-de-Dôme", "Pyrénées-Atlantiques", + "Hautes-Pyrénées", "Pyrénées-Orientales", "Bas-Rhin", "Haut-Rhin", + "Rhône", "Haute-Saône", "Saône-et-Loire", "Sarthe", "Savoie", + "Haute-Savoie", "Seine-Maritime", "Seine-et-Marne", "Yvelines", + "Deux-Sèvres", "Somme", "Tarn", "Tarn-et-Garonne", "Var", "Vaucluse", + "Vendée", "Vienne", "Haute-Vienne", "Vosges", "Yonne", + "Territoire de Belfort", "Essonne", "Hauts-de-Seine", "Seine-Saint-Denis", + "Val-de-Marne", "Val-d'Oise", + ]; + + it("allows every French région on the FR universe", () => { + expect(FR_REGIONS.filter((r) => rejects(r, "fr"))).toEqual([]); + }); + + it("allows every French département on the FR universe", () => { + expect(FR_DEPARTEMENTS.filter((d) => rejects(d, "fr"))).toEqual([]); + }); +}); + +describe("input tolerance", () => { + it("treats a bare scalar as a one-item list", () => { + // The server does not validate inputSchema before dispatch, so a scalar + // reaches the tool — letting it through was a real prior regression. + expect(hitsFor("United States", "us")).toHaveLength(1); + }); + + it("ignores null, undefined, empty and non-string members", () => { + for (const input of [null, undefined, [], [""], [" "], [123], [{}], [["x"]], 42, {}]) { + expect(detectCountryLocations(input, "locations", "us")).toEqual([]); + } + }); + + it("reports EVERY offending value, not just the first", () => { + const hits = hitsFor(["France", "Germany"], "us"); + expect(hits).toHaveLength(2); + const envelope = countryLocationEnvelope(hits, "us"); + expect(envelope.message).toContain("France"); + expect(envelope.message).toContain("Germany"); + }); + + it("leaves valid values in a mixed array untouched", () => { + const hits = hitsFor(["France", "Berlin", "Austin"], "us"); + expect(hits.map((h) => h.value)).toEqual(["France"]); + }); + + it("names the param each value arrived on", () => { + const hits = detectCountryLocationsIn( + [ + { input: ["France"], param: "locations" }, + { input: "Germany", param: "exclude_locations" }, + { input: ["Spain"], param: "city" }, + ], + "us" + ); + expect(hits.map((h) => h.param)).toEqual([ + "locations", + "exclude_locations", + "city", + ]); + const envelope = countryLocationEnvelope(hits, "us"); + for (const param of ["locations", "exclude_locations", "city"]) { + expect(envelope.message).toContain(param); + } + }); +}); + +describe("custom region — the home country is UNKNOWN, so claim nothing", () => { + // LEADBAY_BASE_URL is documented config for staging/dev (bin.ts), and a custom + // base URL with no explicit region yields region "custom". Classifying every + // recognized country as `foreign_country` there told the user "this workspace + // holds no French leads" — which on a custom FR staging backend is simply + // false. The value is still refused (the trigram fall-through is a property of + // the admin-area index, not of the region), but the claim is withheld. + for (const value of ["France", "United States", "Germany", "la France"]) { + it(`classifies ${JSON.stringify(value)} as country_indeterminate`, () => { + const hits = hitsFor(value, "custom"); + expect(hits).toHaveLength(1); + expect(hits[0].kind).toBe("country_indeterminate"); + }); + } + + it("never claims the workspace lacks that country's leads", () => { + const envelope = countryLocationEnvelope(hitsFor("France", "custom"), "custom"); + expect(envelope.message).not.toMatch(/holds no/i); + expect(envelope.message).not.toMatch(/no France leads/i); + expect(envelope.message).not.toMatch(/outside this workspace/i); + // It may say "which country it serves is unknown"; what it must never do is + // assert a specific one ("serves the United States only"). + expect(envelope.message).not.toMatch(/serves \w[\w ]* only/i); + // It says WHY the value is unusable, and that the country is unknown. + expect(envelope.message).toMatch(/custom-configured/i); + expect(envelope.message).toMatch(/no country nodes|absent from the admin-area index/i); + }); + + it("offers omission as a CONDITION, never as an instruction", () => { + const envelope = countryLocationEnvelope(hitsFor("France", "custom"), "custom"); + expect(envelope.hint).toMatch(/If you meant this entire workspace/i); + expect(envelope.hint).toMatch(/do NOT re-run unfiltered/i); + }); + + it("still uses the union of regional exemptions", () => { + expect(rejects("Georgia", "custom")).toBe(false); + }); + + it("a real region still gets the definite foreign verdict", () => { + // The indeterminate kind must not leak into us/fr, where we DO know. + expect(hitsFor("France", "us")[0]?.kind).toBe("foreign_country"); + expect(hitsFor("Germany", "fr")[0]?.kind).toBe("foreign_country"); + }); +}); + +describe("envelope shapes", () => { + it("rejectCountryLocations throws exactly the 4-field business envelope", () => { + let thrown: any; + try { + rejectCountryLocations([{ input: ["France"], param: "locations" }], "us"); + } catch (err) { + thrown = err; + } + expect(thrown).toBeDefined(); + expect(Object.keys(thrown).sort()).toEqual(["code", "error", "hint", "message"]); + expect(thrown.error).toBe(true); + expect(thrown.code).toBe(COUNTRY_LEVEL_LOCATION); + // No _meta: formatErrorForLLM would append a "(region=…, endpoint=…)" + // diagnostic for a guard that never made a request. + expect(thrown._meta).toBeUndefined(); + }); + + it("rejectCountryLocations is a no-op for clean input", () => { + expect(() => + rejectCountryLocations([{ input: ["Austin", "Texas"], param: "locations" }], "us") + ).not.toThrow(); + }); + + it("countryLocationStatus carries NO error flag", () => { + // server.ts collapses any result with `error: true` to a bare text + // isError, dropping structuredContent and filing a Sentry event. + const status = countryLocationStatus(hitsFor("France", "us"), "us"); + expect("error" in status).toBe(false); + expect(status.status).toBe("country_level_location"); + expect(status.code).toBe(COUNTRY_LEVEL_LOCATION); + expect(status.country_locations).toHaveLength(1); + }); + + it("the HOME-country hint says omit, and not to re-spell", () => { + const envelope = countryLocationEnvelope(hitsFor("United States", "us"), "us"); + expect(envelope.hint).toMatch(/OMIT/); + expect(envelope.hint).toMatch(/not retry with another spelling/i); + }); + + it("the FOREIGN-country hint must NOT say to drop the argument and re-run", () => { + // The accuracy bug this pins: "leads in France" on a US workspace is + // UNSUPPORTED, not equivalent to "all US leads". Telling the agent to drop + // the argument and retry produces whole-workspace data presented as an + // answer about another country — the same confidently-wrong-result class + // the guard exists to prevent. + const envelope = countryLocationEnvelope(hitsFor("France", "us"), "us"); + expect(envelope.hint).toMatch(/do NOT simply drop/i); + expect(envelope.hint).not.toMatch(/\bOMIT\b/); + // It must say what the workspace actually holds, and name the country asked for. + expect(envelope.hint).toMatch(/United States/); + expect(envelope.hint).toMatch(/France/); + // The qualified-town override survives. + expect(envelope.hint).toMatch(/qualify the value/i); + }); + + it("the SUPRA-NATIONAL hint must NOT say to drop the argument and re-run", () => { + // "EMEA" is not answered by one country's leads either. + const envelope = countryLocationEnvelope(hitsFor("EMEA", "fr"), "fr"); + expect(envelope.hint).toMatch(/Do NOT drop/i); + expect(envelope.hint).not.toMatch(/\bOMIT\b/); + expect(envelope.hint).toMatch(/France/); + }); + + it("the foreign message states the workspace scope, not a retry", () => { + const envelope = countryLocationEnvelope(hitsFor("Germany", "us"), "us"); + expect(envelope.message).toMatch(/outside this workspace/i); + expect(envelope.message).toMatch(/no Germany companies/i); + }); +}); + +describe("detectCountryLocationsInFilter", () => { + const criterionFilter = (locations: unknown) => ({ + lens_filter: { + items: [{ criteria: [{ type: "location_ids", is_excluded: false, locations }] }], + }, + locations: { results: [], parents: [] }, + }); + + it("finds a country name in a location_ids criterion", () => { + const hits = detectCountryLocationsInFilter(criterionFilter(["France"]), "fr"); + expect(hits).toHaveLength(1); + expect(hits[0].param).toContain("criteria[].locations"); + }); + + it("finds a country in the echoed resolved-areas block", () => { + const hits = detectCountryLocationsInFilter( + { + // The echoed row's id MUST be the one the criterion selects — that + // correlation is the whole mechanism. A row nothing references is + // lookup data, not a filter value (see country-echoed-parents.test.ts). + lens_filter: { items: [{ criteria: [{ type: "location_ids", locations: ["27925"] }] }] }, + locations: { results: [{ id: "27925", name: "France", level: 2 }], parents: [] }, + }, + "fr" + ); + expect(hits).toHaveLength(1); + // Discovered through the echoed name, but attributed to the criterion that + // actually selects it — and carrying the id, because removing the name + // alone would leave "27925" selected and the country filter live. + expect(hits[0].param).toContain("lens_filter.items[].criteria[].locations"); + expect(hits[0].selectedId).toBe("27925"); + }); + + it("passes a clean filter through", () => { + const hits = detectCountryLocationsInFilter( + { + lens_filter: { items: [{ criteria: [{ type: "location_ids", locations: ["416102"] }] }] }, + locations: { results: [{ id: "416102", name: "Île-de-France", level: 5 }], parents: [] }, + }, + "fr" + ); + expect(hits).toEqual([]); + }); + + it("tolerates malformed filters without throwing", () => { + for (const filter of [ + null, + undefined, + {}, + 42, + "nope", + { lens_filter: {} }, + { lens_filter: { items: "no" } }, + { lens_filter: { items: [null] } }, + { lens_filter: { items: [{ criteria: "no" }] } }, + { lens_filter: { items: [{ criteria: [null] }] } }, + { locations: { results: "no" } }, + { locations: { results: [null, { name: 42 }] } }, + ]) { + expect(() => detectCountryLocationsInFilter(filter, "us")).not.toThrow(); + expect(detectCountryLocationsInFilter(filter, "us")).toEqual([]); + } + }); + + it("ignores criteria of other types", () => { + const hits = detectCountryLocationsInFilter( + { lens_filter: { items: [{ criteria: [{ type: "keywords", locations: ["France"] }] }] } }, + "fr" + ); + expect(hits).toEqual([]); + }); +}); diff --git a/packages/core/test/unit/composite/country-mixed-array-recovery.test.ts b/packages/core/test/unit/composite/country-mixed-array-recovery.test.ts new file mode 100644 index 00000000..f5a0b119 --- /dev/null +++ b/packages/core/test/unit/composite/country-mixed-array-recovery.test.ts @@ -0,0 +1,184 @@ +/** + * Two ways the guard could still hand back a wrong-shaped world (product#3951). + * + * 1. MIXED ARRAYS. `locations: ["Paris", "France"]` on the FR backend flags + * only "France", and every tool returns BEFORE resolving anything. So the + * recovery text is the only thing standing between the caller and a re-run + * that lost Paris: "omit `locations`" is destructive here, and widens the + * request it was meant to correct. The snippet's own tiebreak has always + * been "keep the city, drop the country" — these tests hold the runtime to + * it, and they read the hint STRING because that string is the whole + * product: nothing downstream enforces it. + * + * 2. "THE WHOLE OF ". `countryKey` renders it "whole of france", and + * the wrapper list stripped the shorter `whole ` first, leaving "of france" + * — a key matching no country. The guard then found nothing, the caller went + * on to /geo/search, and the same-named-town fence closed exactly as if the + * guard did not exist. Ordering inside a regex array is invisible at review + * time, so every containing/contained pair is pinned by behaviour here. + */ + +import { describe, it, expect } from "vitest"; + +import { + detectCountryLocations, + countryLocationEnvelope, +} from "../../../src/composite/_country-guard.js"; + +const hintFor = (input: unknown, region: "us" | "fr" | "custom", param = "locations") => + countryLocationEnvelope(detectCountryLocations(input, param, region), region).hint; + +describe("scope wrappers — 'the whole of ' resolves to the country", () => { + // Each of these is a phrasing a rep actually types. The left column is what + // the user said; the right is the classification it must reach. + const CASES: ReadonlyArray<[string, "us" | "fr", string]> = [ + ["the whole of France", "fr", "home_country"], + ["whole of France", "fr", "home_country"], + ["The Whole Of France", "fr", "home_country"], + ["the whole of France", "us", "foreign_country"], + ["whole of the US", "us", "home_country"], + ["whole of the United States", "us", "home_country"], + ["whole of the US", "fr", "foreign_country"], + // The shorter wrappers this one shadows must keep working. + ["whole France", "fr", "home_country"], + ["the whole US", "us", "home_country"], + ["all of France", "fr", "home_country"], + ["the entire US", "us", "home_country"], + ["across the United States", "us", "home_country"], + ]; + + it.each(CASES)("%s on %s → %s", (value, region, kind) => { + const hits = detectCountryLocations(value, "city", region); + expect( + hits, + `"${value}" produced no hit on ${region}, so the caller proceeds to /geo/search and gets fenced to a same-named town — the exact failure this guard exists to prevent` + ).toHaveLength(1); + expect(hits[0].kind).toBe(kind); + }); + + it("still leaves ordinary place names alone", () => { + // The wrapper strip only ever fires when what remains is a real country, so + // these must stay invisible to the guard. + // NOT "Isle of Man": that IS ISO 3166-1 IM, and on the FR backend it is + // correctly foreign — the guard catching it is the feature, not a bug. + for (const value of ["Whole Foods", "Isle of Wight", "Val-d'Oise", "Île-de-France"]) { + expect( + detectCountryLocations(value, "locations", "fr"), + `${value} must not be treated as a country-level scope` + ).toEqual([]); + } + }); +}); + +describe("mixed arrays — the valid values survive the recovery", () => { + it("records the siblings that must be kept", () => { + const hits = detectCountryLocations(["Paris", "France"], "locations", "fr"); + expect(hits).toHaveLength(1); + expect(hits[0].value).toBe("France"); + expect(hits[0].kept).toEqual(["Paris"]); + }); + + it("keeps every non-offending value, across several offenders", () => { + const hits = detectCountryLocations( + ["Paris", "Lyon", "France", "Germany"], + "locations", + "fr" + ); + expect(hits.map((h) => h.value)).toEqual(["France", "Germany"]); + // Both hits carry the same survivors: the agent removes two values and + // keeps two, in ONE turn, rather than discovering them one at a time. + for (const hit of hits) expect(hit.kept).toEqual(["Paris", "Lyon"]); + }); + + it("keeps a resolved numeric id, which is not classifiable but is still wanted", () => { + const hits = detectCountryLocations(["416102", "France"], "location_ids", "fr"); + expect(hits[0].kept).toEqual(["416102"]); + }); + + it("keeps a non-string member rather than silently dropping it", () => { + const hits = detectCountryLocations([416102, "France"], "location_ids", "fr"); + expect(hits[0].kept).toEqual(["416102"]); + }); + + it("a scalar argument has no siblings, so the OMIT recovery is unchanged", () => { + const hits = detectCountryLocations("France", "city", "fr"); + expect(hits[0].kept).toEqual([]); + expect(hintFor("France", "fr", "city")).toMatch(/OMIT city entirely/); + }); + + it("a lone country in an array is still a plain omit", () => { + expect(hintFor(["France"], "fr")).toMatch(/OMIT locations entirely/); + }); +}); + +describe("mixed arrays — the hint says remove, never omit", () => { + it("home country beside a city: keep the city, and do not call it workspace-wide", () => { + const hint = hintFor(["Paris", "France"], "fr"); + expect(hint, "the destructive instruction must be gone").not.toMatch( + /OMIT locations entirely/ + ); + expect(hint).toMatch(/Do NOT omit locations/); + expect(hint, "it must name the value to strip").toMatch(/Remove ONLY "France"/); + expect(hint, "it must name what survives").toContain('"Paris"'); + // The result is Paris, not the country — saying otherwise is the same + // confidently-wrong answer in a new costume. + expect(hint).toMatch(/NOT as the whole workspace/i); + }); + + it("foreign country beside a city: keep the city AND report the scope", () => { + const hint = hintFor(["Paris", "Germany"], "fr"); + expect(hint).toMatch(/Remove ONLY "Germany"/); + expect(hint).toContain('"Paris"'); + expect(hint, "the foreign case still has to say there is no Germany data").toMatch( + /no Germany leads/i + ); + }); + + it("supra-national beside a city: keep the city, do not present it as the region", () => { + const hint = hintFor(["Paris", "EMEA"], "fr"); + expect(hint).toMatch(/Remove ONLY "EMEA"/); + expect(hint).toContain('"Paris"'); + expect(hint).not.toMatch(/OMIT locations entirely/); + }); + + it("custom backend beside a city: keep the city, claim nothing about the country", () => { + const hint = hintFor(["Paris", "France"], "custom"); + expect(hint).toMatch(/Remove ONLY "France"/); + expect(hint).toMatch(/custom-configured/i); + expect(hint).not.toMatch(/OMIT locations entirely/); + }); + + it("excluding the home country beside a real exclusion fails closed", () => { + // This asserted "Remove ONLY France and re-call, the other exclusions still + // apply" until review caught what following that produces: a Paris-only + // exclusion, returning the rest of France. The user asked for an EMPTY + // result; Paris was a detail inside the thing being excluded, not a + // separable second request. Answering a much narrower question with nothing + // in the output to show the substitution is the failure mode this whole + // guard exists to prevent — so a mixed non-foreign exclusion has no partial + // version to run, and must ask instead of re-calling. + const hits = detectCountryLocations( + ["Paris", "France"], + "exclude_locations", + "fr", + "exclude" + ); + const { hint } = countryLocationEnvelope(hits, "fr"); + expect(hits[0].kept).toEqual(["Paris"]); + expect(hint).toMatch(/excludes this ENTIRE workspace/i); + expect(hint).toMatch(/Do NOT re-call with only "Paris" excluded/); + expect(hint).not.toMatch(/Remove ONLY/); + expect(hint, "and it must send the agent to ask what was really meant").toMatch( + /Ask what was actually meant to be carved out/i + ); + }); + + it("excluding a foreign country beside a real exclusion is still a no-op, not a drop", () => { + const hint = countryLocationEnvelope( + detectCountryLocations(["Paris", "Germany"], "exclude_locations", "fr", "exclude"), + "fr" + ).hint; + expect(hint).toMatch(/Remove ONLY "Germany"/); + expect(hint).toContain('"Paris"'); + }); +}); diff --git a/packages/core/test/unit/composite/country-mixed-kind-recovery.test.ts b/packages/core/test/unit/composite/country-mixed-kind-recovery.test.ts new file mode 100644 index 00000000..16a9841c --- /dev/null +++ b/packages/core/test/unit/composite/country-mixed-kind-recovery.test.ts @@ -0,0 +1,221 @@ +/** + * Two more ways an agent could still be handed an unusable answer + * (product#3951). + * + * 1. WRAPPED SUPRA-NATIONAL SCOPES. "EU-wide", "all of Europe", "across EMEA" + * are what a rep types, and only the bare labels were ever checked. The + * scope-wrapper strip existed, but it was applied while looking for a + * COUNTRY and nowhere else — so these produced no hit at all, reached + * /geo/search, and got fenced to a same-named town exactly as if the guard + * were absent. Worse than the un-guarded state, because the rule promises + * supra-national scopes get their own recovery. + * + * 2. CONTRADICTORY RECOVERIES. The per-kind hints are correct alone and + * mutually exclusive together — only the home country licenses an + * unfiltered re-run. `["France", "Canada"]` on FR concatenated "OMIT + * locations entirely" with "Do NOT simply drop locations and re-run", + * leaving no safe move. These tests read the joined hint STRING, because a + * contradiction is a property of the whole string and nothing downstream + * checks it. + */ + +import { describe, it, expect } from "vitest"; + +import { + detectCountryLocations, + detectCountryLocationsIn, + countryLocationEnvelope, +} from "../../../src/composite/_country-guard.js"; + +type Region = "us" | "fr" | "custom"; + +const hits = (input: unknown, region: Region, param = "locations", axis: "include" | "exclude" = "include") => + detectCountryLocations(input, param, region, axis); + +const hint = (input: unknown, region: Region, param = "locations", axis: "include" | "exclude" = "include") => + countryLocationEnvelope(hits(input, region, param, axis), region).hint; + +describe("wrapped supra-national scopes are caught before /geo/search", () => { + const WRAPPED = [ + "EU-wide", + "EU wide", + "all of Europe", + "the whole of Europe", + "across EMEA", + "throughout EMEA", + "APAC-wide", + "all of Asia", + "anywhere in Europe", + "entire EU", + ] as const; + + it.each(WRAPPED)("%s classifies as supranational", (value) => { + const found = hits(value, "fr", "city"); + expect( + found, + `"${value}" produced no hit, so the caller sends it to /geo/search and gets fenced to a same-named town` + ).toHaveLength(1); + expect(found[0].kind).toBe("supranational"); + }); + + it("the bare labels still work, on both backends", () => { + for (const region of ["us", "fr"] as const) { + for (const value of ["EU", "Europe", "EMEA", "Worldwide", "LATAM"]) { + expect(hits(value, region, "city")[0]?.kind, `${value} on ${region}`).toBe( + "supranational" + ); + } + } + }); + + it("a named country inside a wrapper still beats the supra-national reading", () => { + // "all of France" is France — home on FR, FOREIGN on US. Reading it as a + // region would answer a France question with US leads, which is the failure + // the kinds exist to separate. + expect(hits("all of France", "fr", "city")[0].kind).toBe("home_country"); + expect(hits("all of France", "us", "city")[0].kind).toBe("foreign_country"); + }); + + it("generic whole-workspace phrasings keep their own verdict", () => { + // These name no country and no region: they mean THIS workspace, which is + // the one case where omitting the filter is the whole answer. + for (const value of ["the whole country", "nationwide", "everywhere"]) { + expect(hits(value, "fr", "city")[0].kind, value).toBe("home_country"); + } + }); + + it("ordinary place names are still invisible to the wrapper strip", () => { + for (const value of ["Whole Foods", "across the Bay", "Bay Area", "Asia Center"]) { + expect(hits(value, "us", "locations"), value).toEqual([]); + } + }); +}); + +describe("one argument, several kinds — one reconciled recovery", () => { + it("home + foreign does not tell the agent both to omit and not to omit", () => { + const text = hint(["France", "Canada"], "fr"); + // The two per-kind instructions, verbatim. Neither may survive. + expect(text, "the bare OMIT instruction is unsafe here").not.toMatch( + /Whole-workspace intent = OMIT locations entirely/ + ); + expect(text, "and so is the bare do-not-drop instruction").not.toMatch( + /Do NOT simply drop locations and re-run/ + ); + // What replaces them: one pass over the argument, then what may be claimed. + expect(text).toMatch(/Remove every one of "France", "Canada" from locations/); + expect(text, "the home half IS answerable — say exactly how far it goes").toMatch( + /answers the France part of the ask and nothing else/i + ); + expect(text, "the foreign half is not").toMatch(/says nothing about Canada/i); + }); + + it("names each kind once, however many values it has", () => { + const text = hint(["France", "Canada", "Germany"], "fr"); + expect(text).toMatch(/holds no Canada, Germany companies/); + expect((text.match(/Remove every one of/g) ?? []).length).toBe(1); + }); + + it("keeps a valid sibling instead of emptying the argument", () => { + const text = hint(["Paris", "France", "Canada"], "fr"); + expect(text).toMatch(/Do NOT omit locations/); + expect(text).toMatch(/Remove ALL of "France", "Canada" in ONE re-call/); + expect(text).toMatch(/covers "Paris"/); + expect(text).not.toMatch(/Omitting locations entirely/); + }); + + it("supranational + foreign licenses no unfiltered re-run at all", () => { + const text = hint(["EU", "Canada"], "fr"); + expect( + text, + "with no home-country value there is nothing an unfiltered result answers" + ).toMatch(/Do NOT re-run with locations omitted/); + expect(text).toMatch(/"EU" is a supra-national scope/); + expect(text).toMatch(/says nothing about Canada/i); + }); + + it("on the exclude axis it says why each exclusion fails, and applies none", () => { + const text = hint(["France", "Canada"], "fr", "exclude_locations", "exclude"); + expect(text).toMatch(/excluding France would empty the ENTIRE workspace/); + expect(text).toMatch(/excluding Canada removes nothing/); + expect( + text, + "the agent must not report a carve-out that never happened" + ).toMatch(/Do NOT present the result as though any of these exclusions had been applied/); + }); + + it("an exclusion beside a valid one keeps the valid one", () => { + const text = hint(["Paris", "France", "Canada"], "fr", "exclude_locations", "exclude"); + expect(text).toMatch(/Remove ALL of "France", "Canada" in ONE re-call/); + expect(text).toMatch(/other exclusions still apply/i); + }); + + it("a custom backend claims nothing while still reconciling", () => { + const text = hint(["France", "EMEA"], "custom"); + expect(text).toMatch(/claim nothing about whether France is inside it/); + expect(text).toMatch(/"EMEA" is a supra-national scope/); + }); +}); + +describe("what reconciliation must NOT change", () => { + it("a single kind on one argument keeps its own per-kind text", () => { + expect(hint(["France"], "fr")).toMatch(/Whole-workspace intent = OMIT locations entirely/); + expect(hint(["Canada"], "fr")).toMatch(/Do NOT simply drop locations and re-run/); + }); + + it("several offenders of the SAME kind get one instruction, naming both", () => { + // Two per-hit hints each said `Remove ONLY "Canada"` / `Remove ONLY + // "Germany"` and re-call: following either literally left the other country + // in place, and "ONLY" made that read as deliberate. One argument, one + // instruction — but neither value may be dropped from what it SAYS. + const text = hint(["Canada", "Germany"], "fr"); + expect((text.match(/Remove every one of/g) ?? []).length).toBe(1); + expect(text).toMatch(/"Canada", "Germany"/); + expect(text).toMatch(/holds no Canada, Germany companies/); + expect(text, "the per-hit instruction must be gone").not.toMatch( + /Do NOT simply drop locations and re-run/ + ); + }); + + it("two DIFFERENT arguments still get their own instruction", () => { + // Not a contradiction: locations and exclude_locations are separate asks, + // and collapsing them would lose one of the two fixes. + const found = detectCountryLocationsIn( + [ + { input: ["Canada"], param: "locations" }, + { input: ["France"], param: "exclude_locations", axis: "exclude" }, + ], + "fr" + ); + const text = countryLocationEnvelope(found, "fr").hint; + expect(text).toMatch(/Do NOT simply drop locations and re-run/); + expect(text).toMatch(/Excluding France excludes this ENTIRE workspace/); + }); + + it("the same value arriving twice on one argument yields one instruction", () => { + const found = detectCountryLocationsIn( + [ + { input: ["Canada"], param: "locations" }, + { input: ["Canada"], param: "locations" }, + ], + "fr" + ); + const text = countryLocationEnvelope(found, "fr").hint; + expect((text.match(/Remove every one of/g) ?? []).length).toBe(1); + }); + + it("the same country on two DIFFERENT arguments gets an instruction each", () => { + // Grouping is per argument, so this is two groups, and each instruction + // names the argument it applies to. Collapsing them would leave one of the + // two values unaddressed. + const found = detectCountryLocationsIn( + [ + { input: ["Canada"], param: "locations" }, + { input: ["Canada"], param: "city" }, + ], + "fr" + ); + const text = countryLocationEnvelope(found, "fr").hint; + expect(text).toMatch(/Do NOT simply drop locations and re-run/); + expect(text).toMatch(/Do NOT simply drop city and re-run/); + }); +}); diff --git a/packages/core/test/unit/composite/country-other-scope.test.ts b/packages/core/test/unit/composite/country-other-scope.test.ts new file mode 100644 index 00000000..6b8a846c --- /dev/null +++ b/packages/core/test/unit/composite/country-other-scope.test.ts @@ -0,0 +1,246 @@ +/** + * The write-stop must not eat the rest of the request (product#3951). + * + * "Write nothing" is right for a country-ONLY ask: `new_lens({name, locations: + * ["France"]})` on FR is a lens created to express a scope the workspace + * already has, and WORKFLOWS.md forbids writing it. It is wrong the moment a + * real criterion rides along. `new_lens({sectors: ["Healthcare"], locations: + * ["France"]})` is a Healthcare lens with a redundant country attached, and + * refusing it discards the only part the user cared about — a strictly worse + * outcome than the bug the stop was added for. + * + * `hit.kept` cannot see this: it holds siblings from the SAME argument, and + * `sectors` is a different argument entirely. So the call sites compute + * `otherScope` across the whole request and hand it to the guard. + * + * Every case still asserts an empty `getHttpRequests()` — the guard runs before + * any I/O either way; what changes is what it tells the agent to do next. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { newLens } from "../../../src/composite/new-lens.js"; +import { adjustAudience } from "../../../src/composite/adjust-audience.js"; +import { updateLensFilter } from "../../../src/tools/update-lens-filter.js"; +import { filterCarriesOtherScope } from "../../../src/composite/_country-guard.js"; + +const frClient = () => new LeadbayClient("https://api-fr.leadbay.app", "u.test-token", "fr"); + +beforeEach(() => resetHttpMock()); + +const CARRY = /re-call ONCE with the rest of the request intact/; +const STOP = /Write NOTHING/; + +describe("new_lens — a real criterion beside the country is still written", () => { + it("sectors survive: the recovery re-calls, it does not stop", async () => { + mockHttp([]); + const result: any = await newLens.execute(frClient(), { + name: "Healthcare", + sectors: ["Healthcare"], + locations: ["France"], + confirm: true, + }); + expect(result.status).toBe("country_level_location"); + expect(result.hint, "refusing this discards the Healthcare criterion").not.toMatch(STOP); + expect(result.hint).toMatch(CARRY); + expect(result.hint).toMatch(/already covers all of France/); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("sizes count as scope too", async () => { + mockHttp([]); + const result: any = await newLens.execute(frClient(), { + name: "Mid-market", + sizes: [{ min: 30, max: 300 }], + locations: ["France"], + confirm: true, + }); + expect(result.hint).toMatch(CARRY); + expect(result.hint).not.toMatch(STOP); + }); + + it("excluded sectors count as scope too", async () => { + mockHttp([]); + const result: any = await newLens.execute(frClient(), { + name: "Not retail", + exclude_sectors: ["Retail"], + locations: ["France"], + confirm: true, + }); + expect(result.hint).toMatch(CARRY); + }); + + it("a name alone is NOT scope — that is the country-only lens", async () => { + // A display name says nothing about which companies belong in the lens, so + // this is exactly the write WORKFLOWS.md forbids. + mockHttp([]); + const result: any = await newLens.execute(frClient(), { + name: "All France", + locations: ["France"], + confirm: true, + }); + expect(result.hint).toMatch(STOP); + expect(result.hint).not.toMatch(CARRY); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("a FOREIGN country beside a real criterion stops the write entirely", async () => { + // This asserted the opposite until review caught it. "Canadian healthcare" + // on an FR workspace does not decompose into a country half that fails and + // a sector half that succeeds: "Healthcare" was qualifying "Canada". Writing + // it alone persists a real, saved FRENCH-healthcare lens that nobody asked + // for — and it reads, in the product, exactly like the request being + // honoured. A sector is only independently valid scope when the territory + // it qualifies is one this workspace actually has. + mockHttp([]); + const result: any = await newLens.execute(frClient(), { + name: "Canadian healthcare", + sectors: ["Healthcare"], + locations: ["Canada"], + confirm: true, + }); + expect(result.hint).toMatch(STOP); + expect(result.hint).not.toMatch(CARRY); + expect(result.hint).toMatch(/no such audience to create/); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("a foreign country mixed with the home one stops on the foreign half", async () => { + // "France" alone would be droppable here; "Canada" is not, and the presence + // of a droppable value must not soften the verdict on the other. + mockHttp([]); + const result: any = await newLens.execute(frClient(), { + name: "Both", + sectors: ["Healthcare"], + locations: ["France", "Canada"], + confirm: true, + }); + expect(result.hint).toMatch(STOP); + expect(result.hint).not.toMatch(/re-call ONCE with the rest of the request intact/); + // The droppable one is still named, so the eventual corrected call is right + // the first time rather than one value per turn. + expect(result.hint).toMatch(/"France"/); + expect(getHttpRequests()).toHaveLength(0); + }); +}); + +describe("adjust_audience — same narrowing", () => { + it("a sector adjustment beside a country is a legitimate write", async () => { + mockHttp([]); + const result: any = await adjustAudience.execute(frClient(), { + sectors: ["Healthcare"], + locations: ["France"], + }); + expect(result.hint).toMatch(CARRY); + expect(result.hint).not.toMatch(STOP); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("resolved sector ids count as scope", async () => { + mockHttp([]); + const result: any = await adjustAudience.execute(frClient(), { + sector_ids: ["42"], + locations: ["France"], + }); + expect(result.hint).toMatch(CARRY); + }); + + it("a country on its own still stops", async () => { + mockHttp([]); + const result: any = await adjustAudience.execute(frClient(), { + locations: ["France"], + }); + expect(result.hint).toMatch(STOP); + }); +}); + +describe("update_lens_filter — scope is read off the criteria", () => { + const filterWith = (criteria: unknown[]) => ({ + lens_filter: { items: [{ criteria }] }, + locations: { results: [], parents: [] }, + }); + + it("filterCarriesOtherScope sees a non-location criterion", () => { + expect( + filterCarriesOtherScope( + filterWith([ + { type: "location_ids", locations: ["France"] }, + { type: "sector_ids", sectors: ["42"] }, + ]) + ) + ).toBe(true); + }); + + it("and reports none when only locations are present", () => { + expect( + filterCarriesOtherScope(filterWith([{ type: "location_ids", locations: ["France"] }])) + ).toBe(false); + expect(filterCarriesOtherScope(undefined)).toBe(false); + expect(filterCarriesOtherScope({})).toBe(false); + }); + + it("a sector criterion beside the country is not discarded", async () => { + mockHttp([]); + let thrown: any; + try { + await updateLensFilter.execute(frClient(), { + lensId: 4242, + filter: filterWith([ + { type: "location_ids", locations: ["France"], is_excluded: false }, + { type: "sector_ids", sectors: ["42"] }, + ]) as any, + }); + } catch (err) { + thrown = err; + } + expect(thrown.code).toBe("COUNTRY_LEVEL_LOCATION"); + expect( + thrown.hint, + "update_lens_filter REPLACES the filter, so stopping here loses the sector criterion" + ).not.toMatch(STOP); + expect(thrown.hint).toMatch(CARRY); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("a country-only filter still stops", async () => { + mockHttp([]); + let thrown: any; + try { + await updateLensFilter.execute(frClient(), { + lensId: 4242, + filter: filterWith([ + { type: "location_ids", locations: ["France"], is_excluded: false }, + ]) as any, + }); + } catch (err) { + thrown = err; + } + expect(thrown.hint).toMatch(STOP); + expect(getHttpRequests()).toHaveLength(0); + }); +}); + +describe("what otherScope must NOT change", () => { + it("a country beside a real PLACE keeps the surgical recovery", async () => { + // kept and otherScope are different things: here the argument itself + // survives, so the instruction is to trim it, not to drop it. + mockHttp([]); + const result: any = await newLens.execute(frClient(), { + name: "Paris healthcare", + sectors: ["Healthcare"], + locations: ["Paris", "France"], + confirm: true, + }); + expect(result.hint).toMatch(/Remove ONLY "France"/); + expect(result.hint).not.toMatch(STOP); + expect(result.hint).not.toMatch(CARRY); + }); +}); diff --git a/packages/core/test/unit/composite/country-recovery-per-tool.test.ts b/packages/core/test/unit/composite/country-recovery-per-tool.test.ts new file mode 100644 index 00000000..97a0c4f4 --- /dev/null +++ b/packages/core/test/unit/composite/country-recovery-per-tool.test.ts @@ -0,0 +1,208 @@ +/** + * Recoveries that were wrong because the TOOL was wrong for them (product#3951). + * + * The shared hint says "omit the geo argument and the result covers the whole + * workspace". That sentence is only true of a tool that reads leads and whose + * unfiltered read really is the whole workspace. Four tools are not that: + * + * - `adjust_audience` MERGES into an existing lens filter, so the geography + * already on the lens survives the re-call. "The lens then carries no geo + * criterion" is a claim about a filter nobody has read. + * - `scan_portfolio_signals` scans UNFILTERED when its filter POST fails, so a + * filter that loses or breaks the caller's other criteria degrades silently. + * - `list_locations` is a taxonomy lookup with a REQUIRED `q`; omitting it + * fails validation, and its empty-`q` branch returns no results rather than + * workspace-wide data. + * - and on the echoed-ID path a criterion can still select a real place, so + * "remove the whole criterion" would discard it. + */ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mockHttp, resetHttpMock, httpsMockFactory, getHttpRequests } from "../../harness.js"; +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { + countryLocationEnvelope, + detectCountryLocationsInFilter, +} from "../../../src/composite/_country-guard.js"; +import { adjustAudience } from "../../../src/composite/adjust-audience.js"; +import { scanPortfolioSignals } from "../../../src/composite/scan-portfolio-signals.js"; +import { listLocations } from "../../../src/tools/list-locations.js"; + +const frClient = () => new LeadbayClient("https://api-fr.leadbay.app", "u.test-token", "fr"); + +beforeEach(() => resetHttpMock()); + +describe("adjust_audience warns that the lens keeps its own geography", () => { + it("names the merge, and where the geography can actually be read", async () => { + mockHttp([]); + const result: any = await adjustAudience.execute(frClient(), { + lensId: 7, + sectors: ["Healthcare"], + locations: ["France"], + }); + expect(result.status).toBe("country_level_location"); + expect(result.hint).toMatch(/re-call ONCE/); + expect(result.hint).toMatch(/lens:\/\/7\/definition/); + expect(result.hint).toMatch(/location criteria MERGE here rather than replace/); + // The two sources that cannot answer it are named, because both look like + // they should. + expect(result.hint).toMatch(/returns only `lens: \{id\}`/); + expect(result.hint).toMatch(/no filter/); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("falls back to a readable reference when no lensId was passed", async () => { + mockHttp([]); + const result: any = await adjustAudience.execute(frClient(), { + sectors: ["Healthcare"], + locations: ["France"], + }); + expect(result.hint).toMatch(/lens:\/\/\/definition/); + }); + + it("a write-stop gets no re-call warning", async () => { + // Nothing else was passed, so the recovery forbids a re-call outright and + // must not carry text implying one is on the table. + mockHttp([]); + const result: any = await adjustAudience.execute(frClient(), { + lensId: 7, + locations: ["France"], + }); + expect(result.hint).toMatch(/Write NOTHING here/); + expect(result.hint).not.toMatch(/Before that re-call/); + }); +}); + +describe("scan_portfolio_signals preserves the caller's other criteria", () => { + it("says to re-send the surviving criteria, and why an invalid filter is not a no-op", async () => { + mockHttp([]); + const result: any = await scanPortfolioSignals.execute(frClient(), { + set_filter: { + criteria: [ + { type: "location_ids", locations: ["France"] }, + { type: "last_action_date", last_days: 30 }, + ], + }, + }); + expect(result.status).toBe("country_level_location"); + expect(result.hint).toMatch(/SURVIVING criteria/); + expect(result.hint).toMatch(/scan UNFILTERED/); + // The two destructive shortcuts are named as things NOT to do… + expect(result.hint).toMatch(/do NOT send an empty `criteria` array/); + // …and pull_followups' stale-filter advice must NOT leak in: this tool has + // no persisted-filter problem, and `filtered:false` is not its lever. + expect(result.hint).not.toMatch(/filtered:false/); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("adds no caveat when nothing else was requested", async () => { + // Dropping the geo argument really does scan unfiltered here: this tool + // sends `filtered` only when it stored the filter itself. Unlike + // pull_followups, there is no stale-filter half to warn about. + mockHttp([]); + const result: any = await scanPortfolioSignals.execute(frClient(), { city: "France" }); + expect(result.status).toBe("country_level_location"); + expect(result.hint).toMatch(/OMIT city/); + expect(result.hint).not.toMatch(/SURVIVING criteria/); + expect(result.hint).not.toMatch(/filtered:false/); + }); +}); + +describe("list_locations refuses without offering a retry", () => { + it("does not tell the caller to omit a required argument", async () => { + mockHttp([]); + const result: any = await listLocations.execute(frClient(), { q: "France" }); + expect(result.status).toBe("country_level_location"); + expect(result.results).toEqual([]); + expect(result.hint).toMatch(/There is no country to look up/); + expect(result.hint).toMatch(/Do NOT re-call this tool with `q` omitted/); + // The shared omit-and-claim-coverage recovery must not survive here. + expect(result.hint).not.toMatch(/OMIT q entirely/); + expect(result.hint).not.toMatch(/covers everything/); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("still points somewhere useful", async () => { + mockHttp([]); + const result: any = await listLocations.execute(frClient(), { q: "France" }); + expect(result.hint).toMatch(/look up that place instead/); + expect(result.hint).toMatch(/no location id is needed at all/); + }); + + it("a real place is untouched", async () => { + mockHttp([ + { method: "GET", path: "/1.6/geo/search?q=Limoges", status: 200, body: { results: [{ id: "1" }], parents: [] } }, + ]); + const result: any = await listLocations.execute(frClient(), { q: "Limoges" }); + expect(result.status).toBeUndefined(); + expect(result.results).toHaveLength(1); + }); +}); + +describe("an echoed country beside a real place keeps the place", () => { + const filter = (locs: string[]) => ({ + lens_filter: { items: [{ criteria: [{ type: "location_ids", locations: locs }] }] }, + locations: { + results: [ + { id: "27925", name: "France" }, + { id: "99", name: "Paris" }, + { id: "55", name: "Germany" }, + ], + parents: [], + }, + }); + + it("carries the surviving id, labelled with its echoed name", () => { + const hits = detectCountryLocationsInFilter(filter(["27925", "99"]), "fr"); + expect(hits).toHaveLength(1); + expect(hits[0].kept).toEqual(["99 (Paris)"]); + }); + + it("the recovery is surgical, not an omission", () => { + const hits = detectCountryLocationsInFilter(filter(["27925", "99"]), "fr"); + const { hint } = countryLocationEnvelope(hits, "fr"); + expect(hint).toMatch(/Remove ONLY "France"/); + expect(hint).not.toMatch(/OMIT/); + // …and the id note still says which id to actually delete. + expect(hint).toMatch(/remove "27925"/); + }); + + it("a SECOND country is never listed as something to keep", () => { + // Otherwise one hit tells the caller to preserve exactly what the other + // hit is telling them to remove. + const hits = detectCountryLocationsInFilter(filter(["27925", "55"]), "fr"); + expect(hits).toHaveLength(2); + for (const hit of hits) expect(hit.kept).toEqual([]); + }); + + it("keeps the criterion when a place survives, removes it when none does", () => { + const withSibling = (locs: string[]) => ({ + lens_filter: { + items: [ + { + criteria: [ + { type: "location_ids", locations: locs }, + { type: "size", sizes: [{ min: 1, max: 9 }] }, + ], + }, + ], + }, + locations: filter([]).locations, + }); + + const survives = countryLocationEnvelope( + detectCountryLocationsInFilter(withSibling(["27925", "99"]), "fr"), + "fr" + ).hint; + expect(survives).toMatch(/Keep the `location_ids` criterion itself/); + expect(survives).not.toMatch(/remove the WHOLE criterion/); + + const empties = countryLocationEnvelope( + detectCountryLocationsInFilter(withSibling(["27925"]), "fr"), + "fr" + ).hint; + expect(empties).toMatch(/remove the WHOLE criterion/); + expect(empties).not.toMatch(/Keep the `location_ids` criterion itself/); + }); +}); diff --git a/packages/core/test/unit/composite/country-recovery-preserves-request.test.ts b/packages/core/test/unit/composite/country-recovery-preserves-request.test.ts new file mode 100644 index 00000000..8c19275e --- /dev/null +++ b/packages/core/test/unit/composite/country-recovery-preserves-request.test.ts @@ -0,0 +1,198 @@ +/** + * The recovery must not destroy what the caller actually asked for (product#3951). + * + * Three findings from the round after the sibling / stale-filter / write-stop + * fixes landed — each one a place where two of those fixes met and the advice + * they produced together was worse than either alone: + * + * 1. Siblings were attached by `criteriaHits` only. A country arriving as a + * bare ID is discovered through its echoed name on a separate path, and that + * path built a hit with no siblings — so the recovery said "remove the id" + * without "and remove the now-empty criterion", authorizing a retry that + * carries an invalid `location_ids` criterion holding nothing. + * 2. `pull_followups` appended "pass `filtered:false`" unconditionally. With a + * surviving `last_action_date` criterion that bypasses the very criterion the + * sibling note had just promised would survive — and the offered alternative, + * `set_filter:{criteria:[]}`, deletes it outright. + * 3. `new_lens` counted a bare `base` id as surviving scope. Every new lens is a + * CLONE, so the authorized retry inherits the base's geography: a Paris- + * scoped base becomes a lens named "Nationwide" holding Paris. + */ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mockHttp, resetHttpMock, httpsMockFactory, getHttpRequests } from "../../harness.js"; +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { + countryLocationEnvelope, + detectCountryLocationsInFilter, +} from "../../../src/composite/_country-guard.js"; +import { newLens } from "../../../src/composite/new-lens.js"; +import { pullFollowups } from "../../../src/composite/pull-followups.js"; + +const frClient = () => new LeadbayClient("https://api-fr.leadbay.app", "u.test-token", "fr"); + +beforeEach(() => resetHttpMock()); + +describe("a country found by echoed ID carries its criterion's siblings", () => { + const filterWithSibling = { + lens_filter: { + items: [ + { + criteria: [ + { type: "location_ids", locations: ["27925"] }, + { type: "size", sizes: [{ min: 10, max: 50 }] }, + ], + }, + ], + }, + locations: { results: [{ id: "27925", name: "France" }], parents: [] }, + }; + + it("records the siblings on the echoed hit", () => { + const hits = detectCountryLocationsInFilter(filterWithSibling, "fr"); + expect(hits).toHaveLength(1); + expect(hits[0].selectedId).toBe("27925"); + expect(hits[0].siblingCriteria).toEqual(["size"]); + }); + + it("says to remove the whole criterion, not just the id inside it", () => { + const hits = detectCountryLocationsInFilter(filterWithSibling, "fr"); + const { hint } = countryLocationEnvelope(hits, "fr"); + // Both halves must be present: which id selects it, AND that the criterion + // itself goes. Either alone leaves a broken or a still-filtered retry. + expect(hint).toMatch(/remove "27925"/); + // The criterion selects nothing else here, so removing the country empties + // it — and an empty `location_ids` criterion is invalid, not neutral. + expect(hint).toMatch(/remove the WHOLE criterion/); + expect(hint).toMatch(/`size`/); + }); + + it("a lone echoed country still gets the id note and no sibling note", () => { + const hits = detectCountryLocationsInFilter( + { + lens_filter: { items: [{ criteria: [{ type: "location_ids", locations: ["27925"] }] }] }, + locations: { results: [{ id: "27925", name: "France" }], parents: [] }, + }, + "fr" + ); + expect(hits[0].siblingCriteria).toBeUndefined(); + const { hint } = countryLocationEnvelope(hits, "fr"); + expect(hint).toMatch(/remove "27925"/); + expect(hint).not.toMatch(/Remove the WHOLE/); + }); +}); + +describe("pull_followups keeps the criteria the caller asked for", () => { + it("does NOT offer filtered:false when other criteria survive", async () => { + mockHttp([]); + const result: any = await pullFollowups.execute(frClient(), { + set_filter: { + criteria: [ + { type: "location_ids", locations: ["France"] }, + { type: "last_action_date", last_days: 30 }, + ], + }, + }); + expect(result.status).toBe("country_level_location"); + // Both destructive options must be named as things NOT to do. + expect(result.hint).toMatch(/Do NOT pass `filtered:false`/); + expect(result.hint).toMatch(/do NOT send `set_filter:\{criteria:\[\]\}`/); + // And the safe route stated: re-send the corrected filter. + expect(result.hint).toMatch(/SURVIVING criteria/); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("still offers filtered:false when nothing else was requested", async () => { + mockHttp([]); + const result: any = await pullFollowups.execute(frClient(), { city: "France" }); + expect(result.hint).toMatch(/pass `filtered:false`/); + expect(result.hint).toMatch(/Nothing else was requested/); + }); + + it("says nothing about filtered when the recovery re-sends the filter anyway", async () => { + mockHttp([]); + const result: any = await pullFollowups.execute(frClient(), { + set_filter: { + criteria: [{ type: "location_ids", locations: ["France", "Paris"] }], + }, + }); + // "Paris" survives, so the recovery is surgical: remove France, re-call + // with the rest. That re-POSTs the corrected filter and overwrites the + // stored one, so no stale filter can leak in and there is nothing to + // caveat. Advice about `filtered` here would be noise at best — and + // `filtered:false` would discard Paris. + expect(result.hint).toMatch(/Remove ONLY "France"/); + expect(result.hint).not.toMatch(/filtered:false/); + expect(result.hint).not.toMatch(/set_filter:\{criteria:\[\]\}/); + }); +}); + +describe("new_lens does not treat an unread base as scope", () => { + it("a base id alone does not license dropping the country", async () => { + mockHttp([]); + const result: any = await newLens.execute(frClient(), { + name: "Nationwide", + base: 4242, + locations: ["France"], + confirm: true, + }); + expect(result.status).toBe("country_level_location"); + // The write-stop, not the carry-the-rest recovery: there is no "rest". + expect(result.hint).toMatch(/Write NOTHING here/); + expect(result.hint).not.toMatch(/re-call ONCE/); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("when a retry IS authorized, it warns the clone inherits geography", async () => { + mockHttp([]); + const result: any = await newLens.execute(frClient(), { + name: "Nationwide healthcare", + sectors: ["Healthcare"], + locations: ["France"], + confirm: true, + }); + expect(result.hint).toMatch(/re-call ONCE/); + expect(result.hint).toMatch(/lens:\/\//); + expect(result.hint).toMatch(/A clone INHERITS that geography/); + // And it names why the obvious sources cannot answer it. + expect(result.hint).toMatch(/returns only `lens: \{id\}`/); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("names the explicit base in the resource URI when one was given", async () => { + mockHttp([]); + const result: any = await newLens.execute(frClient(), { + name: "Nationwide healthcare", + base: 77, + sectors: ["Healthcare"], + locations: ["France"], + confirm: true, + }); + expect(result.hint).toMatch(/lens:\/\/77\/definition/); + }); + + it("falls back to the active lens when no base was passed", async () => { + mockHttp([]); + const result: any = await newLens.execute(frClient(), { + name: "Nationwide healthcare", + sectors: ["Healthcare"], + locations: ["France"], + confirm: true, + }); + // Every new lens clones something — the active lens when base is omitted — + // so the warning must still point somewhere real. + expect(result.hint).toMatch(/lens:\/\/\/definition/); + }); + + it("a write-stop is NOT given a re-call warning that implies a re-call", async () => { + mockHttp([]); + const result: any = await newLens.execute(frClient(), { + name: "Nationwide", + locations: ["France"], + confirm: true, + }); + expect(result.hint).toMatch(/Write NOTHING here/); + expect(result.hint).not.toMatch(/Before that re-call/); + }); +}); diff --git a/packages/core/test/unit/composite/country-scope-phrases.test.ts b/packages/core/test/unit/composite/country-scope-phrases.test.ts new file mode 100644 index 00000000..2bf7dbda --- /dev/null +++ b/packages/core/test/unit/composite/country-scope-phrases.test.ts @@ -0,0 +1,225 @@ +/** + * Scope PHRASES, custom-region reachability, and the tour-specific recovery + * (review follow-up, product#3951). + * + * Three defects, all of them mine, all invisible to typecheck and to a green + * suite because they lived in classification and in guidance text: + * + * 1. The guard matched keys EXACTLY, so the canonical phrasings a user actually + * types — "whole US", "the whole US", "all of France", "across the United + * States" — matched nothing and sailed through to /geo/search and the + * same-named-town fence. + * 2. Worse in the other direction: "partout en France" and "toute la France" + * sat in WHOLE_WORKSPACE_LABELS, so on a US workspace they were classified + * as the HOME country and the guidance recommended answering with US leads. + * A phrase that NAMES a country must be judged by that country. + * 3. `createClient({baseUrl})` defaulted region to "us", so the + * country_indeterminate branch added for custom backends was unreachable in + * the documented LEADBAY_BASE_URL configuration. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; +vi.mock("node:https", () => httpsMockFactory()); + +import { createClient, LeadbayClient } from "../../../src/client.js"; +import { detectCountryLocations } from "../../../src/composite/_country-guard.js"; +import { embeddedCountryKey, countryKey } from "../../../src/composite/_country-names.js"; +import { tourPlan } from "../../../src/composite/tour-plan.js"; + +type Region = "us" | "fr" | "custom"; +const kindOf = (value: string, region: Region) => + detectCountryLocations(value, "city", region)[0]?.kind; +const countryOf = (value: string, region: Region) => + detectCountryLocations(value, "city", region)[0]?.country; +const allowed = (value: string, region: Region) => + detectCountryLocations(value, "city", region).length === 0; + +beforeEach(() => resetHttpMock()); + +describe("embeddedCountryKey", () => { + it("peels generic scope wrappers down to the country", () => { + for (const [phrase, expected] of [ + ["whole us", "us"], + ["all of france", "france"], + ["across the united states", "united states"], + ["us wide", "us"], + ["partout en france", "france"], + ["toute la france", "france"], + ["entire germany", "germany"], + ["throughout spain", "spain"], + ] as const) { + expect(embeddedCountryKey(phrase), phrase).toBe(expected); + } + }); + + it("finds nothing in ordinary place names", () => { + // The wrappers only ever fire when a COUNTRY is left behind, so real places + // that happen to start with a scope word are untouched. + for (const phrase of [ + "whole foods", + "across the bay", + "ile de france", + "all regions", + "nationwide", + "partout", + "austin", + "val d oise", + ]) { + expect(embeddedCountryKey(phrase), phrase).toBeUndefined(); + } + }); + + it("is a no-op for a bare country key", () => { + expect(embeddedCountryKey(countryKey("France"))).toBe("france"); + }); +}); + +describe("whole-country phrasings naming THIS workspace", () => { + const usPhrases = [ + "whole US", + "the whole US", + "all of the US", + "across the United States", + "US-wide", + "all of the United States", + "throughout the US", + ]; + for (const value of usPhrases) { + it(`${JSON.stringify(value)} is home intent on a US workspace`, () => { + // Previously matched nothing at all and reached /geo/search. + expect(kindOf(value, "us")).toBe("home_country"); + expect(countryOf(value, "us")).toBe("United States"); + }); + } + + it("generic phrasings with no country named still mean this workspace", () => { + for (const value of ["nationwide", "everywhere", "all regions", "entire country"]) { + expect(kindOf(value, "us"), value).toBe("home_country"); + } + }); +}); + +describe("whole-country phrasings naming ANOTHER country", () => { + // The accuracy bug: these name France, so on a US workspace they are FOREIGN. + // Classifying them as home intent told the agent to answer with US leads. + for (const value of ["all of France", "partout en France", "toute la France", "throughout France"]) { + it(`${JSON.stringify(value)} is FOREIGN on a US workspace`, () => { + expect(kindOf(value, "us")).toBe("foreign_country"); + expect(countryOf(value, "us")).toBe("France"); + }); + + it(`${JSON.stringify(value)} is HOME on an FR workspace`, () => { + expect(kindOf(value, "fr")).toBe("home_country"); + }); + } + + it("mirrors for a US phrase on an FR workspace", () => { + expect(kindOf("whole US", "fr")).toBe("foreign_country"); + expect(countryOf("whole US", "fr")).toBe("United States"); + }); +}); + +describe("scope phrases do not create false positives", () => { + for (const value of [ + "Île-de-France", + "all of Georgia", // the US state, wrapped in a scope word + "Whole Foods", + "across the Bay", + "Austin", + "Val-d'Oise", + "New York", + ]) { + it(`allows ${JSON.stringify(value)} on the US workspace`, () => { + expect(allowed(value, "us")).toBe(true); + }); + } + + it("keeps the state exemption working through a wrapper", () => { + // "Georgia" is exempt on US as the state; the wrapper must not turn it into + // the country. + expect(allowed("all of Georgia", "us")).toBe(true); + // …and on FR, where Georgia can only be the country, it still rejects. + expect(kindOf("Georgia", "fr")).toBe("foreign_country"); + }); +}); + +describe("createClient — a custom baseUrl must not inherit the US region", () => { + it("reports region 'custom' for a staging URL with no region pinned", () => { + // This is the documented LEADBAY_BASE_URL path (bin.ts honours a baseUrl + // "exactly", passing no region). Defaulting to "us" made every custom + // endpoint look like a US tenant. + const client = createClient({ token: "u.t", baseUrl: "https://api-staging.leadbay.app" }); + expect(client.region).toBe("custom"); + }); + + it("still derives us / fr from the known regional URLs", () => { + expect(createClient({ token: "u.t", baseUrl: "https://api-us.leadbay.app" }).region).toBe("us"); + expect(createClient({ token: "u.t", baseUrl: "https://api-fr.leadbay.app" }).region).toBe("fr"); + }); + + it("still honours an explicitly pinned region over the URL", () => { + const client = createClient({ + token: "u.t", + baseUrl: "https://api-staging.leadbay.app", + region: "fr", + }); + expect(client.region).toBe("fr"); + }); + + it("still defaults to us when neither baseUrl nor region is given", () => { + expect(createClient({ token: "u.t" }).region).toBe("us"); + }); + + it("makes the indeterminate verdict reachable on a custom backend", () => { + // The whole point: a French staging backend must NOT be told it holds no + // French leads. + const client = createClient({ token: "u.t", baseUrl: "https://api-staging.leadbay.app" }); + expect(detectCountryLocations("France", "city", client.region)[0]?.kind).toBe( + "country_indeterminate" + ); + }); +}); + +describe("leadbay_tour_plan — the recovery is tour-specific", () => { + const frClient = () => new LeadbayClient("https://api-fr.leadbay.app", "u.t", "fr"); + + it("does NOT tell the agent to omit the city", async () => { + // The shared home-country hint says "omit the geo argument and the result + // covers the whole workspace". For a tour that is wrong twice over: + // tour_plan accepts no city and then returns arbitrary nationwide leads as + // an itinerary, and the prompt contract requires asking for a city. + mockHttp([]); + const result: any = await tourPlan.execute(frClient(), { city: "France" }); + expect(result.status).toBe("country_level_location"); + // It may SAY "there is nothing to omit here"; what it must never do is + // instruct omission the way the generic home-country hint does. + expect(result.hint).not.toMatch(/OMIT `?city/i); + expect(result.hint).not.toMatch(/covers the whole workspace/i); + expect(result.hint).toMatch(/nothing to omit/i); + expect(result.hint).toMatch(/do NOT re-call without `city`/i); + expect(result.hint).toMatch(/which city or region/i); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("keeps the shared code, message and country_locations", async () => { + // Only the recovery is overridden — the diagnosis stays single-sourced. + mockHttp([]); + const result: any = await tourPlan.execute(frClient(), { city: "France" }); + expect(result.code).toBe("COUNTRY_LEVEL_LOCATION"); + expect(result.message).toContain("France"); + expect(result.country_locations).toHaveLength(1); + }); + + it("catches a wrapped phrase too", async () => { + mockHttp([]); + const result: any = await tourPlan.execute(frClient(), { city: "toute la France" }); + expect(result.status).toBe("country_level_location"); + expect(getHttpRequests()).toHaveLength(0); + }); +}); diff --git a/packages/core/test/unit/composite/country-wrapped-nationwide.test.ts b/packages/core/test/unit/composite/country-wrapped-nationwide.test.ts new file mode 100644 index 00000000..bb8065fa --- /dev/null +++ b/packages/core/test/unit/composite/country-wrapped-nationwide.test.ts @@ -0,0 +1,134 @@ +/** + * Wrapped whole-country phrasings, and what "nationwide" means on a backend + * whose own country we cannot name (product#3951). + * + * Two defects motivated this file, both found by review on the PR that added + * the guard: + * + * 1. The whole-workspace labels were the ONLY one of the three vocabularies + * matched by exact key. `embeddedCountryKey` and `embeddedSupranationalKey` + * both peel scope wrappers first; this one did not. So "country-wide", + * "across the country" and "across the whole country" matched nothing, the + * guard returned no hit, and the value went on to /geo/search — landing in + * the same-named-town fence this module exists to prevent. + * + * 2. On `region: "custom"` a generic "nationwide" was classified supra-national. + * That is a category error with a user-visible cost: a supra-national scope + * is one no single backend can satisfy, so its hint FORBIDS the unfiltered + * read. But "nationwide" names no country, and every backend covers exactly + * one — the unfiltered read is precisely the right answer. Users on the + * documented LEADBAY_BASE_URL path were told their request had no answer. + */ +import { describe, it, expect } from "vitest"; + +import { + countryLocationEnvelope, + detectCountryLocations, + type GuardRegion, +} from "../../../src/composite/_country-guard.js"; + +const hitsFor = (value: string, region: GuardRegion, axis: "include" | "exclude" = "include") => + detectCountryLocations(value, "city", region, axis); + +/** Wrapped forms that reached /geo/search before the wrapper-aware lookup. */ +const WRAPPED = [ + "country-wide", + "country wide", + "Country-Wide", + "across the country", + "across the whole country", + "throughout the country", + "all of the country", + "the entire country", +]; + +describe("wrapped whole-country phrasings are caught", () => { + for (const value of WRAPPED) { + it(`${JSON.stringify(value)} is home_country on a known region`, () => { + const hit = hitsFor(value, "us")[0]; + expect(hit?.kind).toBe("home_country"); + // The value is echoed verbatim so the agent can find it in its own call. + expect(hit?.value).toBe(value); + }); + + it(`${JSON.stringify(value)} gets the omit-and-answer recovery`, () => { + const envelope = countryLocationEnvelope(hitsFor(value, "us"), "us"); + expect(envelope.hint).toMatch(/OMIT/); + expect(envelope.hint).toMatch(/Do NOT retry with another spelling/i); + }); + } + + it("a named country inside a wrapper still wins over the generic reading", () => { + // "all of France" must stay FOREIGN on US — the generic branch would have + // called it "this whole workspace" and answered it with US leads. + expect(hitsFor("all of France", "us")[0]?.kind).toBe("foreign_country"); + expect(hitsFor("across the United States", "us")[0]?.kind).toBe("home_country"); + }); +}); + +describe("sub-country scope words are NOT whole-country", () => { + // The bare noun "country" is in the vocabulary so the wrapper strip can reach + // it. That must not spill onto words that merely contain or resemble it. + for (const value of ["statewide", "citywide", "county-wide", "Country Club Hills", "Countryside"]) { + it(`${JSON.stringify(value)} is left alone`, () => { + expect(hitsFor(value, "us")).toHaveLength(0); + expect(hitsFor(value, "fr")).toHaveLength(0); + }); + } +}); + +describe("nationwide on a backend with no home country", () => { + const GENERIC = ["nationwide", "everywhere", "the whole country", "across the country", "partout"]; + + for (const value of GENERIC) { + it(`${JSON.stringify(value)} is indeterminate with NO country named`, () => { + const hit = hitsFor(value, "custom")[0]; + expect(hit?.kind).toBe("country_indeterminate"); + expect(hit?.country).toBeNull(); + }); + } + + it("the hint affirms the unfiltered read rather than forbidding it", () => { + const envelope = countryLocationEnvelope(hitsFor("nationwide", "custom"), "custom"); + expect(envelope.hint).toMatch(/OMIT city entirely/); + expect(envelope.hint).toMatch(/covers everything in this workspace/i); + // The supra-national refusal is what this used to emit. It must be gone. + expect(envelope.hint).not.toMatch(/Do NOT drop city and re-run/i); + expect(envelope.hint).not.toMatch(/supra-national/i); + }); + + it("it withholds the country NAME, which is the only unknown", () => { + const envelope = countryLocationEnvelope(hitsFor("nationwide", "custom"), "custom"); + expect(envelope.hint).toMatch(/do NOT name which country/i); + expect(envelope.message).toMatch(/WHICH country the workspace covers is unknown/i); + // No "null" leaked from the absent country into either string. + expect(`${envelope.hint}${envelope.message}`).not.toMatch(/\bnull\b/); + }); + + it("a NAMED country on custom keeps its own hedged recovery", () => { + // Unchanged behaviour: "France" on custom may or may not be home, so the + // omit is offered as a condition, not an instruction. + const envelope = countryLocationEnvelope(hitsFor("France", "custom"), "custom"); + expect(envelope.hint).toMatch(/If you meant this entire workspace/i); + expect(envelope.hint).toMatch(/France specifically/); + }); + + it("EXCLUDING the whole workspace is still refused, not omitted", () => { + const envelope = countryLocationEnvelope( + hitsFor("nationwide", "custom", "exclude"), + "custom", + "read" + ); + expect(envelope.hint).toMatch(/Excluding the whole workspace leaves nothing/i); + expect(envelope.hint).not.toMatch(/OMIT/); + }); + + it("a WRITE with nothing else to scope by writes nothing", () => { + const envelope = countryLocationEnvelope( + hitsFor("nationwide", "custom"), + "custom", + "write" + ); + expect(envelope.hint).toMatch(/Write NOTHING here/i); + }); +}); diff --git a/packages/core/test/unit/composite/country-write-scope-domination.test.ts b/packages/core/test/unit/composite/country-write-scope-domination.test.ts new file mode 100644 index 00000000..83a236e6 --- /dev/null +++ b/packages/core/test/unit/composite/country-write-scope-domination.test.ts @@ -0,0 +1,164 @@ +/** + * When a country-level value DOMINATES the rest of the request (product#3951). + * + * Four review findings, one theme: the guard was treating the country as a bad + * value to be removed, when in several cases it is the load-bearing half of the + * request and the "rest" only made sense as a qualifier on it. + * + * 1. `new_lens({sectors:["Healthcare"], locations:["Canada"]})` on US asked for + * CANADIAN healthcare. Dropping the country and writing the rest persisted a + * real US-healthcare lens nobody requested. "Healthcare" was an adjective on + * "Canada", not a second, independently valid request. + * 2. `exclude: ["France","Paris"]` on FR asks for an empty result; Paris is a + * detail inside it. "Remove only France and re-call" silently downgraded that + * to a Paris-only exclusion and returned most of France as though it answered. + * 3. A `set_filter` country criterion beside a `last_action_date` criterion was + * invisible to the recovery, which said "omit it and the result covers + * everything" while the date filter still applied. + * 4. `pull_followups` defaults `filtered` to true, so omitting `city` still + * reads through the previously persisted Monitor filter. + */ +import { describe, it, expect } from "vitest"; + +import { + countryLocationEnvelope, + detectCountryLocations, + detectCountryLocationsIn, + detectCountryLocationsInSetFilter, + countryLocationStatus, +} from "../../../src/composite/_country-guard.js"; + +describe("a foreign include stops the whole write", () => { + const canadianHealthcare = () => + detectCountryLocationsIn( + [{ input: ["Canada"], param: "locations" }], + "us" + ); + + it("does not offer the drop-and-re-call recovery", () => { + // otherScope=true — sectors ARE present, which is exactly the case that + // used to license writing the remainder. + const envelope = countryLocationEnvelope(canadianHealthcare(), "us", "write", true); + expect(envelope.hint).toMatch(/Write NOTHING/); + expect(envelope.hint).not.toMatch(/re-call ONCE with the rest of the request intact/); + }); + + it("says why the remainder is not a request of its own", () => { + const envelope = countryLocationEnvelope(canadianHealthcare(), "us", "write", true); + expect(envelope.hint).toMatch(/were qualifying/i); + expect(envelope.hint).toMatch(/territory nobody asked about/i); + }); + + it("a supra-national include stops a write the same way", () => { + const hits = detectCountryLocations(["EMEA"], "locations", "us"); + const envelope = countryLocationEnvelope(hits, "us", "write", true); + expect(envelope.hint).toMatch(/Write NOTHING/); + }); + + it("but the HOME country still carries the rest through", () => { + // The one genuinely droppable case: the value is redundant, so the other + // criteria really are the whole request. + const hits = detectCountryLocations(["United States"], "locations", "us"); + const envelope = countryLocationEnvelope(hits, "us", "write", true); + expect(envelope.hint).toMatch(/re-call ONCE with the rest of the request intact/); + expect(envelope.hint).not.toMatch(/Write NOTHING/); + }); + + it("and a READ with a foreign include is unaffected", () => { + const envelope = countryLocationEnvelope(canadianHealthcare(), "us", "read", true); + expect(envelope.hint).not.toMatch(/Write NOTHING/); + }); +}); + +describe("a mixed non-foreign EXCLUDE fails closed", () => { + const franceAndParis = (region: "fr" | "us" = "fr") => + detectCountryLocations(["France", "Paris"], "exclude_locations", region, "exclude"); + + it("does not authorize a narrowed re-call", () => { + const envelope = countryLocationEnvelope(franceAndParis(), "fr"); + expect(envelope.hint).not.toMatch(/Remove ONLY/); + expect(envelope.hint).toMatch(/Do NOT re-call with only "Paris" excluded/); + }); + + it("names the substitution that would otherwise go unnoticed", () => { + const envelope = countryLocationEnvelope(franceAndParis(), "fr"); + expect(envelope.hint).toMatch(/much narrower question/i); + expect(envelope.hint).toMatch(/nothing in the result would show the substitution/i); + }); + + it("a FOREIGN exclusion keeps its surgical recovery — it is a provable no-op", () => { + const hits = detectCountryLocations(["Canada", "Paris"], "exclude_locations", "fr", "exclude"); + const envelope = countryLocationEnvelope(hits, "fr"); + expect(envelope.hint).toMatch(/Remove ONLY "Canada"/); + }); + + it("a mixed INCLUDE is unchanged — dropping the country there is correct", () => { + const hits = detectCountryLocations(["France", "Paris"], "locations", "fr"); + const envelope = countryLocationEnvelope(hits, "fr"); + expect(envelope.hint).toMatch(/Remove ONLY "France"/); + expect(envelope.hint).toMatch(/describe it as those places/i); + }); +}); + +describe("sibling criteria survive the recovery and are named", () => { + const setFilter = { + criteria: [ + { type: "location_ids", locations: ["France"] }, + { type: "last_action_date", last_days: 30 }, + ], + }; + + it("records the siblings on the hit", () => { + const hits = detectCountryLocationsInSetFilter(setFilter, "set_filter", "fr"); + expect(hits).toHaveLength(1); + expect(hits[0].siblingCriteria).toEqual(["last_action_date"]); + }); + + it("says to remove the whole criterion, not just its locations", () => { + const hits = detectCountryLocationsInSetFilter(setFilter, "set_filter", "fr"); + const envelope = countryLocationEnvelope(hits, "fr"); + expect(envelope.hint).toMatch(/remove the WHOLE criterion/); + expect(envelope.hint).toMatch(/invalid, not neutral/); + }); + + it("forbids describing the result as covering everything", () => { + const hits = detectCountryLocationsInSetFilter(setFilter, "set_filter", "fr"); + const envelope = countryLocationEnvelope(hits, "fr"); + expect(envelope.hint).toMatch(/`last_action_date`/); + expect(envelope.hint).toMatch(/never as covering everything/i); + }); + + it("a lone country criterion gets no sibling note", () => { + const hits = detectCountryLocationsInSetFilter( + { criteria: [{ type: "location_ids", locations: ["France"] }] }, + "set_filter", + "fr" + ); + expect(hits[0].siblingCriteria).toBeUndefined(); + expect(countryLocationEnvelope(hits, "fr").hint).not.toMatch(/Remove the WHOLE/); + }); +}); + +describe("the omit caveat rides only on an omit recovery", () => { + const CAVEAT = "PASS-FILTERED-FALSE"; + + it("is appended when the recovery says OMIT", () => { + const hits = detectCountryLocations("France", "city", "fr"); + expect(countryLocationStatus(hits, "fr", "read", false, CAVEAT).hint).toContain(CAVEAT); + }); + + it("is NOT appended to a foreign recovery, which forbids the unfiltered re-run", () => { + const hits = detectCountryLocations("Canada", "city", "fr"); + expect(countryLocationStatus(hits, "fr", "read", false, CAVEAT).hint).not.toContain(CAVEAT); + }); + + it("is NOT appended to an exclusion recovery", () => { + const hits = detectCountryLocations("France", "city", "fr", "exclude"); + expect(countryLocationStatus(hits, "fr", "read", false, CAVEAT).hint).not.toContain(CAVEAT); + }); + + it("is absent entirely when no caveat is passed", () => { + const hits = detectCountryLocations("France", "city", "fr"); + expect(countryLocationStatus(hits, "fr").hint).not.toContain(CAVEAT); + }); +}); diff --git a/packages/core/test/unit/composite/country-write-stop.test.ts b/packages/core/test/unit/composite/country-write-stop.test.ts new file mode 100644 index 00000000..312427c6 --- /dev/null +++ b/packages/core/test/unit/composite/country-write-stop.test.ts @@ -0,0 +1,152 @@ +/** + * A country-wide scope must not be answered by WRITING (product#3951). + * + * The shared recovery was written for the read tools, where it is exactly + * right: `pull_followups` re-called without `city` returns every follow-up, + * which is what a whole-country ask meant. On a lens-writing tool the same + * instruction inverts. `new_lens` re-called without `locations` CREATES a lens + * with no geography; `adjust_audience` REWRITES the active lens's criteria; + * `update_lens_filter` replaces the whole filter. All three persist a change + * that expresses no scope at all, in order to say something the workspace + * already is — and WORKFLOWS.md's "Country-wide scope — omit the location + * filter" row names those exact three tools in `forbidden_calls` and requires + * that NOTHING be written. + * + * So the split is not read-vs-write in general: it is "would the re-call leave + * the argument EMPTY". A country beside a real place is a perfectly good lens, + * and those cases must keep the surgical remove-and-re-call recovery. + * + * Every case also asserts `getHttpRequests()` is empty, which is what makes + * "wrote nothing" a fact rather than a claim about the wording. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { newLens } from "../../../src/composite/new-lens.js"; +import { adjustAudience } from "../../../src/composite/adjust-audience.js"; +import { updateLensFilter } from "../../../src/tools/update-lens-filter.js"; +import { pullFollowups } from "../../../src/composite/pull-followups.js"; + +const frClient = () => new LeadbayClient("https://api-fr.leadbay.app", "u.test-token", "fr"); + +beforeEach(() => resetHttpMock()); + +/** A round-tripped lens filter carrying one country as a location criterion. */ +const criterionFilter = (locations: string[]) => ({ + lens_filter: { + items: [{ criteria: [{ type: "location_ids", locations, is_excluded: false }] }], + }, + locations: { results: [], parents: [] }, +}); + +describe("lens-writing tools stop instead of re-calling", () => { + it("new_lens: a country-only request is answered by writing nothing", async () => { + mockHttp([]); + const result: any = await newLens.execute(frClient(), { + name: "All France", + locations: ["France"], + confirm: true, + }); + expect(result.status).toBe("country_level_location"); + expect( + result.hint, + "the read recovery would have the agent create the lens WORKFLOWS.md forbids" + ).not.toMatch(/Whole-workspace intent = OMIT/); + expect(result.hint).toMatch(/do NOT re-call this tool with locations omitted/i); + expect(result.hint).toMatch(/Write NOTHING/); + // And it must still DELIVER — the workflow requires an answer, not a stall. + expect(result.hint).toMatch(/already covers all of France/); + expect(result.hint).toMatch(/sector, size/); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("adjust_audience: same, on the tool whose criteria merge irreversibly", async () => { + mockHttp([]); + const result: any = await adjustAudience.execute(frClient(), { + locations: ["France"], + }); + expect(result.hint).toMatch(/Write NOTHING/); + expect(result.hint).not.toMatch(/Whole-workspace intent = OMIT/); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("update_lens_filter: the thrown envelope stops too", async () => { + mockHttp([]); + let thrown: any; + try { + await updateLensFilter.execute(frClient(), { + lensId: 4242, + filter: criterionFilter(["France"]) as any, + }); + } catch (err) { + thrown = err; + } + expect(thrown.code).toBe("COUNTRY_LEVEL_LOCATION"); + expect(thrown.hint).toMatch(/Write NOTHING/); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("a foreign country on a write tool also stops, and says why", async () => { + mockHttp([]); + const result: any = await newLens.execute(frClient(), { + name: "Canada", + locations: ["Canada"], + confirm: true, + }); + expect(result.hint).toMatch(/Write NOTHING/); + // Reworded when the foreign INCLUDE was promoted to a request-level block: + // it now dominates the whole call rather than being one argument's verdict, + // so the reason is stated once for the request. + expect(result.hint).toMatch(/outside this workspace, so there is no such audience to create/); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("several country values on a write tool still stop exactly once", async () => { + mockHttp([]); + const result: any = await newLens.execute(frClient(), { + name: "Both", + locations: ["France", "Canada"], + confirm: true, + }); + expect((result.hint.match(/Write NOTHING/g) ?? []).length).toBe(1); + // One instruction for the request, and the droppable value still named so + // the eventual corrected call is right the first time. + expect(result.hint).toMatch(/"France" must come off it too/); + expect(result.hint).not.toMatch(/re-call ONCE/); + expect(getHttpRequests()).toHaveLength(0); + }); +}); + +describe("what the write-stop must NOT swallow", () => { + it("a country beside a real place keeps the remove-and-re-call recovery", async () => { + // "Paris" is a lens worth writing. Stopping here would refuse a legitimate + // request — the stop is for an argument that would be left EMPTY. + mockHttp([]); + const result: any = await newLens.execute(frClient(), { + name: "Paris", + locations: ["Paris", "France"], + confirm: true, + }); + expect(result.hint).toMatch(/Remove ONLY "France"/); + expect(result.hint).not.toMatch(/Write NOTHING/); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("the READ tools keep the omit-and-re-call recovery unchanged", async () => { + // pull_followups with no `city` is every follow-up, which IS the answer to + // a whole-country ask. Nothing about this may change. + mockHttp([]); + const result: any = await pullFollowups.execute(frClient(), { city: "France" }); + expect(result.hint).toMatch(/Whole-workspace intent = OMIT city entirely/); + expect(result.hint).not.toMatch(/Write NOTHING/); + expect(getHttpRequests()).toHaveLength(0); + }); +}); diff --git a/packages/core/test/unit/composite/followups-country-stale-filter.test.ts b/packages/core/test/unit/composite/followups-country-stale-filter.test.ts new file mode 100644 index 00000000..345ae510 --- /dev/null +++ b/packages/core/test/unit/composite/followups-country-stale-filter.test.ts @@ -0,0 +1,67 @@ +/** + * Omitting `city` does not widen `pull_followups` on its own (product#3951). + * + * The shared recovery for a home-country value is "OMIT the geo argument, then + * say the result covers everything". True for a tool that reads unfiltered by + * default — and `pull_followups` is not one. `filtered` defaults to TRUE, so the + * re-call still reads the Monitor view through whatever filter was persisted by + * an earlier call. On a tenant carrying an old Paris filter, "leads across + * France" would come back as the Paris cohort, described as the whole + * workspace: a confident, plausible, wrong answer — the exact class this guard + * exists to prevent, reached by following the guard's own advice. + */ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mockHttp, resetHttpMock, httpsMockFactory, getHttpRequests } from "../../harness.js"; +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { pullFollowups } from "../../../src/composite/pull-followups.js"; + +const BASE = "https://api-fr.leadbay.app"; +const newClient = () => new LeadbayClient(BASE, "u.test-token", "fr"); + +beforeEach(() => resetHttpMock()); + +describe("pull_followups country recovery covers the persisted filter", () => { + it("tells the caller to pass filtered:false, not merely to omit city", async () => { + mockHttp([]); + const result = await pullFollowups.execute(newClient(), { city: "France" }); + + expect(result.status).toBe("country_level_location"); + expect(result.hint).toMatch(/OMIT city/); + expect(result.hint).toMatch(/`filtered` defaults to true/); + expect(result.hint).toMatch(/filtered:false/); + // The alternative — actually clearing the stored filter — is named too, + // because a caller may want the filter gone rather than bypassed. + expect(result.hint).toMatch(/set_filter:\{criteria:\[\]\}/); + // And it points at the field that reports what was really applied. + expect(result.hint).toMatch(/active_filters/); + }); + + it("still spends no HTTP proving it", async () => { + mockHttp([]); + await pullFollowups.execute(newClient(), { city: "France" }); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("does not attach the caveat to a foreign country", async () => { + // "Canada" on FR must NOT be re-run unfiltered at all, so advice about how + // to widen correctly would be advice to do the wrong thing thoroughly. + mockHttp([]); + const result = await pullFollowups.execute(newClient(), { city: "Canada" }); + expect(result.status).toBe("country_level_location"); + expect(result.hint).not.toMatch(/filtered:false/); + expect(result.hint).toMatch(/does NOT answer a question about Canada/); + }); + + it("does not attach it to an excluded home country either", async () => { + mockHttp([]); + const result = await pullFollowups.execute(newClient(), { + set_filter: { + criteria: [{ type: "location_ids", is_excluded: true, locations: ["France"] }], + }, + }); + expect(result.status).toBe("country_level_location"); + expect(result.hint).not.toMatch(/filtered:false/); + }); +}); diff --git a/packages/core/test/unit/composite/lens-country-guard.test.ts b/packages/core/test/unit/composite/lens-country-guard.test.ts new file mode 100644 index 00000000..c4e12e6f --- /dev/null +++ b/packages/core/test/unit/composite/lens-country-guard.test.ts @@ -0,0 +1,231 @@ +/** + * The lens-writing tools refuse country-level locations before any I/O + * (product#3951). + * + * `leadbay_new_lens` and `leadbay_adjust_audience` both persist geography as a + * `location_ids` criterion, so a country label does lasting damage: the + * admin-area index has no country nodes (product#3885), the value + * trigram-matches a same-named commune ("France" → Francs), and the lens stays + * fenced to one village for every later pull. adjust_audience is the worse of + * the two — its criteria MERGE as a union, so the bad fence cannot be undone by + * re-calling with the right value. + * + * Every assertion here pairs the named status with `getHttpRequests()` being + * empty. That is the load-bearing half: it proves the guard ran before the + * sector taxonomy, before /users/me, before GET /lenses/:id/filter and before + * /geo/search — so a doomed call writes nothing and costs nothing. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { adjustAudience } from "../../../src/composite/adjust-audience.js"; +import { newLens } from "../../../src/composite/new-lens.js"; + +const US_BASE = "https://api-us.leadbay.app"; +const FR_BASE = "https://api-fr.leadbay.app"; +const usClient = () => new LeadbayClient(US_BASE, "u.test-token", "us"); +const frClient = () => new LeadbayClient(FR_BASE, "u.test-token", "fr"); + +beforeEach(() => resetHttpMock()); + +const ME = { + id: "u-1", + email: "u@example.com", + organization: { id: "org-1", name: "Acme" }, + admin: false, + last_requested_lens: 4242, + language: "en", +}; + +// A lens OWNED by the caller — an org-default lens would branch into the +// draft/clone path and pull in endpoints unrelated to this guard. +const USER_LENS = { id: 4242, name: "Mine", user_id: "u-1", is_default: false, default: false }; +const EMPTY_FILTER = { + lens_filter: { items: [{ criteria: [] }] }, + locations: { results: [], parents: [] }, +}; + +/** Every endpoint a successful adjust_audience geo write touches, so the + * "guard did NOT fire" cases can run the flow to completion and assert the + * real outcome rather than merely "didn't throw". */ +const applyFlow = (geo: RegExp, area: Record) => [ + { method: "GET" as const, path: geo, status: 200, body: { results: [area], parents: [] } }, + { method: "GET" as const, path: "/1.6/users/me", status: 200, body: ME }, + { method: "GET" as const, path: "/1.6/lenses/4242", status: 200, body: USER_LENS }, + { method: "GET" as const, path: "/1.6/lenses/4242/filter", status: 200, body: EMPTY_FILTER }, + { method: "POST" as const, path: "/1.6/lenses/4242/filter", status: 200, body: {} }, +]; + +describe("leadbay_new_lens — country guard", () => { + it("refuses a home-country location and creates nothing", async () => { + mockHttp([]); + const result: any = await newLens.execute(usClient(), { + name: "US-wide", + locations: ["United States"], + confirm: true, + }); + expect(result.status).toBe("country_level_location"); + expect(result.code).toBe("COUNTRY_LEVEL_LOCATION"); + expect(result.country_locations).toHaveLength(1); + expect(result.country_locations[0].param).toBe("locations"); + expect(result.message).toContain("United States"); + // No taxonomy fetch, no /geo/search, no POST /lenses. + expect(getHttpRequests()).toHaveLength(0); + }); + + it("refuses a country on the EXCLUDE axis and names that param", async () => { + mockHttp([]); + const result: any = await newLens.execute(frClient(), { + name: "Not France", + exclude_locations: ["France"], + confirm: true, + }); + expect(result.status).toBe("country_level_location"); + expect(result.country_locations[0].param).toBe("exclude_locations"); + // The recovery must NOT be "omit it" — this assertion originally demanded + // exactly that, encoding the polarity bug: omitting an exclusion of the home + // country returns every company the user asked to remove. Exclusion-specific + // wording is covered in country-exclude-polarity.test.ts. + expect(result.hint).not.toMatch(/OMIT `?exclude_locations/i); + // And because new_lens WRITES, the recovery stops instead of re-calling: + // re-calling without the exclusion writes what the user asked to remove. + expect(result.hint).toMatch(/asks for an empty audience/i); + expect(result.hint).toMatch(/Write NOTHING/); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("refuses a supra-national scope", async () => { + mockHttp([]); + const result: any = await newLens.execute(usClient(), { + name: "Everywhere", + locations: ["Worldwide"], + confirm: true, + }); + expect(result.status).toBe("country_level_location"); + expect(result.country_locations[0].kind).toBe("supranational"); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("reports both axes in one envelope", async () => { + mockHttp([]); + const result: any = await newLens.execute(usClient(), { + name: "Mixed", + locations: ["Canada"], + exclude_locations: ["Mexico"], + confirm: true, + }); + expect(result.country_locations.map((h: any) => h.param)).toEqual([ + "locations", + "exclude_locations", + ]); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("does NOT fire on a legitimate sub-country location", async () => { + // Île-de-France must survive: it contains "France" as a substring, so a + // sloppier matcher would break the single most common French territory. + mockHttp([ + { + method: "GET", + path: /\/1\.6\/geo\/search\?q=/, + status: 200, + body: { + results: [ + { id: "416102", name: "Île-de-France", country: "FR", level: 5, parent_ids: [] }, + ], + parents: [], + }, + }, + ]); + const result: any = await newLens.execute(frClient(), { + name: "IDF", + locations: ["Île-de-France"], + }); + expect(result.status).not.toBe("country_level_location"); + // It got as far as resolving the area, which is the point. + expect(getHttpRequests().length).toBeGreaterThan(0); + }); +}); + +describe("leadbay_adjust_audience — country guard", () => { + it("refuses a country and neither reads nor writes the lens filter", async () => { + mockHttp([]); + const result: any = await adjustAudience.execute(usClient(), { + locations: ["USA"], + }); + expect(result.status).toBe("country_level_location"); + expect(result.code).toBe("COUNTRY_LEVEL_LOCATION"); + // The union-merge never ran: no /users/me, no GET filter, no POST filter. + expect(getHttpRequests()).toHaveLength(0); + }); + + it("refuses a country NAME routed through the id param", async () => { + // Agents put names in id params routinely; location_ids skips the resolver + // entirely, so this is the path that would otherwise reach the backend. + mockHttp([]); + const result: any = await adjustAudience.execute(usClient(), { + location_ids: ["United States"], + }); + expect(result.status).toBe("country_level_location"); + expect(result.country_locations[0].param).toBe("location_ids"); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("does NOT reject a numeric admin-area id — documents the known gap", async () => { + // A country passed as a resolved numeric id stays invisible to this layer: + // deciding whether "416102" is a country needs a backend lookup the client + // does not have. Narrowing the ingress is all this guard can do; closing it + // is server-side (product#3939). Asserted so the limit is recorded, not + // assumed. + mockHttp(applyFlow(/\/1\.6\/geo\/search/, {}).slice(1)); + const result: any = await adjustAudience.execute(usClient(), { + location_ids: ["416102"], + }); + expect(result.status).toBe("applied"); + expect(getHttpRequests().length).toBeGreaterThan(0); + }); + + it("allows Georgia on the US universe but refuses it on FR", async () => { + // A US rep prospecting the STATE writes the bare word; on the FR backend + // the same word can only mean the country, which is out of universe. + mockHttp( + applyFlow(/\/1\.6\/geo\/search\?q=Georgia/, { + id: "9", name: "Georgia", country: "US", level: 4, parent_ids: [], + }) + ); + const onUs: any = await adjustAudience.execute(usClient(), { + locations: ["Georgia"], + }); + expect(onUs.status).toBe("applied"); + + resetHttpMock(); + mockHttp([]); + const onFr: any = await adjustAudience.execute(frClient(), { + locations: ["Georgia"], + }); + expect(onFr.status).toBe("country_level_location"); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("allows a French overseas département on the FR universe", async () => { + // Guadeloupe is ISO 3166-1 GP, so a naive country list would refuse a + // legitimate in-universe French territory. + mockHttp( + applyFlow(/\/1\.6\/geo\/search\?q=Guadeloupe/, { + id: "77", name: "Guadeloupe", country: "FR", level: 4, parent_ids: [], + }) + ); + const result: any = await adjustAudience.execute(frClient(), { + locations: ["Guadeloupe"], + }); + expect(result.status).toBe("applied"); + }); +}); diff --git a/packages/core/test/unit/composite/monitor-country-guard.test.ts b/packages/core/test/unit/composite/monitor-country-guard.test.ts new file mode 100644 index 00000000..b9242254 --- /dev/null +++ b/packages/core/test/unit/composite/monitor-country-guard.test.ts @@ -0,0 +1,162 @@ +/** + * The Monitor / `city` family refuses country-level locations before any I/O + * (product#3951). + * + * This is the family where the failure was actually observed: in the + * 2026-08-02 acceptance eval an FR session passed a country label, the + * resolver trigram-matched the commune of Francs, and six search variants were + * burned inside that invisible fence before the user got a confident wrong + * diagnosis. + * + * Four tools, three shapes of coverage: + * - pull_followups owns the guard; + * - followups_map inherits `pullFollowups.execute` VERBATIM, so its test is + * the only thing keeping that free ride honest if the wiring ever changes; + * - tour_plan needs its OWN guard because it fans out to pull_leads in + * parallel — delegating alone would still spend that request; + * - scan_portfolio_signals guards only when it is actually scoping by city. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { pullFollowups } from "../../../src/composite/pull-followups.js"; +import { followupsMap } from "../../../src/composite/followups-map.js"; +import { tourPlan } from "../../../src/composite/tour-plan.js"; +import { scanPortfolioSignals } from "../../../src/composite/scan-portfolio-signals.js"; + +const US_BASE = "https://api-us.leadbay.app"; +const FR_BASE = "https://api-fr.leadbay.app"; +const usClient = () => new LeadbayClient(US_BASE, "u.test-token", "us"); +const frClient = () => new LeadbayClient(FR_BASE, "u.test-token", "fr"); + +beforeEach(() => resetHttpMock()); + +describe("leadbay_pull_followups — country guard", () => { + it("refuses a country in `city` without persisting a filter", async () => { + mockHttp([]); + const result: any = await pullFollowups.execute(frClient(), { city: "France" }); + expect(result.status).toBe("country_level_location"); + expect(result.code).toBe("COUNTRY_LEVEL_LOCATION"); + expect(result.country_locations[0].param).toBe("city"); + // The store-then-apply mechanism POSTs /monitor/filter server-side, so a + // guard that ran too late would leave a poisoned persisted filter behind. + expect(getHttpRequests()).toHaveLength(0); + }); + + it("returns a schema-valid empty envelope", async () => { + mockHttp([]); + const result: any = await pullFollowups.execute(frClient(), { city: "France" }); + // outputSchema requires `leads`; the agent must not see a malformed result. + expect(result.leads).toEqual([]); + expect(result.active_filters).toBeNull(); + expect(result.pagination).toBeNull(); + expect(result.total_excluded_by_pushback).toBe(0); + expect(result._meta.region).toBe("fr"); + }); + + it("carries no `error` flag, so the structured detail survives", async () => { + // server.ts collapses any result with error:true to a bare text isError, + // dropping country_locations and filing a Sentry event. + mockHttp([]); + const result: any = await pullFollowups.execute(frClient(), { city: "France" }); + expect("error" in result).toBe(false); + }); + + it("refuses a country routed through `city_id`", async () => { + mockHttp([]); + const result: any = await pullFollowups.execute(usClient(), { + city_id: "United States", + }); + expect(result.status).toBe("country_level_location"); + expect(result.country_locations[0].param).toBe("city_id"); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("refuses a supra-national scope", async () => { + mockHttp([]); + const result: any = await pullFollowups.execute(usClient(), { city: "EMEA" }); + expect(result.status).toBe("country_level_location"); + expect(result.country_locations[0].kind).toBe("supranational"); + expect(getHttpRequests()).toHaveLength(0); + }); +}); + +describe("leadbay_followups_map — inherits the guard", () => { + it("refuses the same value through the inherited execute", async () => { + // followups_map reuses pullFollowups.execute verbatim. If that wiring is + // ever replaced with its own implementation, this test is what catches the + // silently-unguarded copy. + mockHttp([]); + const result: any = await followupsMap.execute(frClient(), { city: "France" }); + expect(result.status).toBe("country_level_location"); + expect(result.code).toBe("COUNTRY_LEVEL_LOCATION"); + expect(getHttpRequests()).toHaveLength(0); + }); +}); + +describe("leadbay_tour_plan — country guard", () => { + it("refuses a country before the parallel discover pull", async () => { + // tour_plan fires pullFollowups and pullLeads with Promise.allSettled, so + // relying on the delegate alone would still burn the pullLeads request. + // Zero captured requests is the proof that its own guard ran first. + mockHttp([]); + const result: any = await tourPlan.execute(frClient(), { city: "France" }); + expect(result.status).toBe("country_level_location"); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("returns a schema-valid empty itinerary", async () => { + mockHttp([]); + const result: any = await tourPlan.execute(frClient(), { city: "France" }); + // outputSchema requires monitor_leads, discover_leads and map_locations. + expect(result.monitor_leads).toEqual([]); + expect(result.discover_leads).toEqual([]); + expect(result.map_locations).toEqual([]); + expect(result.map_summary.total_leads).toBe(0); + expect(result.city).toBe("France"); + }); +}); + +describe("leadbay_scan_portfolio_signals — country guard", () => { + it("refuses a country when scoping by city", async () => { + mockHttp([]); + const result: any = await scanPortfolioSignals.execute(frClient(), { + query: "M&A", + city: "France", + }); + expect(result.status).toBe("country_level_location"); + expect(result.matched).toEqual([]); + expect(result.not_researched).toEqual([]); + expect(result.scanned_count).toBe(0); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("IGNORES `city` when explicit leadIds are given", async () => { + // The schema documents `city` as ignored on the leadIds path, so failing on + // it there would be a false alarm on an argument the tool never reads. + mockHttp([ + { + method: "GET", + path: /\/1\.6\/leads\/lead-1/, + status: 200, + body: { id: "lead-1", name: "Acme", location: null }, + }, + ]); + const result: any = await scanPortfolioSignals.execute(frClient(), { + query: "M&A", + city: "France", + leadIds: ["lead-1"], + max_leads: 1, + }); + expect(result.status).not.toBe("country_level_location"); + expect(getHttpRequests().length).toBeGreaterThan(0); + }); +}); diff --git a/packages/core/test/unit/composite/set-filter-country-guard.test.ts b/packages/core/test/unit/composite/set-filter-country-guard.test.ts new file mode 100644 index 00000000..78b803cf --- /dev/null +++ b/packages/core/test/unit/composite/set-filter-country-guard.test.ts @@ -0,0 +1,217 @@ +/** + * The `set_filter` ingress is guarded too (review follow-up, product#3951). + * + * The first pass guarded only `city` / `city_id`, but both Monitor composites + * also accept geography as a raw `location_ids` criterion inside `set_filter` — + * a documented path that never touches those arguments. + * + * Leaving it open was worse than the bug it was meant to fix. The criterion + * reaches `POST /monitor/filter`, and BOTH composites deliberately catch a + * failed POST and carry on reading the Monitor view ("Fall through — still try + * to read the Monitor view with whatever filter is currently stored"). So a + * country in `set_filter` produced a confident, plausible cohort drawn from the + * PREVIOUSLY persisted filter, presented as though it were the requested one — + * a silently wrong answer, which is the whole failure class this guard exists to + * prevent. On the success path it is just as bad: the country-fenced criterion + * gets persisted server-side and survives the session. + * + * Every assertion pairs the named status with zero HTTP, which is what proves + * nothing was persisted and no stale read happened. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { pullFollowups } from "../../../src/composite/pull-followups.js"; +import { followupsMap } from "../../../src/composite/followups-map.js"; +import { scanPortfolioSignals } from "../../../src/composite/scan-portfolio-signals.js"; +import { + detectCountryLocationsInSetFilter, +} from "../../../src/composite/_country-guard.js"; + +const FR_BASE = "https://api-fr.leadbay.app"; +const US_BASE = "https://api-us.leadbay.app"; +const frClient = () => new LeadbayClient(FR_BASE, "u.test-token", "fr"); +const usClient = () => new LeadbayClient(US_BASE, "u.test-token", "us"); + +/** A Monitor set_filter expressing geography the documented way. */ +const geoFilter = (locations: unknown) => ({ + criteria: [{ type: "location_ids", is_excluded: false, locations }], +}); + +beforeEach(() => resetHttpMock()); + +describe("detectCountryLocationsInSetFilter", () => { + it("finds a country in a location_ids criterion and names the path", () => { + const hits = detectCountryLocationsInSetFilter(geoFilter(["France"]), "set_filter", "fr"); + expect(hits).toHaveLength(1); + expect(hits[0].param).toBe("set_filter.criteria[].locations"); + expect(hits[0].kind).toBe("home_country"); + }); + + it("ignores criteria that carry no geography", () => { + const filter = { + criteria: [ + { type: "keywords", keywords: ["France"] }, // a text match, not a geo filter + { type: "liked" }, + { type: "size", sizes: [{ min: 10, max: 50 }] }, + ], + }; + expect(detectCountryLocationsInSetFilter(filter, "set_filter", "fr")).toEqual([]); + }); + + it("passes a sub-country criterion through", () => { + const hits = detectCountryLocationsInSetFilter( + geoFilter(["Île-de-France", "416102"]), + "set_filter", + "fr" + ); + expect(hits).toEqual([]); + }); + + it("tolerates malformed input without throwing", () => { + for (const filter of [ + null, undefined, {}, 42, "nope", + { criteria: "no" }, + { criteria: [null] }, + { criteria: [{ type: "location_ids" }] }, // no locations key + { criteria: [{ type: "location_ids", locations: "France" }] }, // scalar + ]) { + expect(() => detectCountryLocationsInSetFilter(filter, "set_filter", "fr")).not.toThrow(); + } + // …but a SCALAR country still counts: the server does not validate the + // schema before dispatch, so this shape really does arrive. + expect( + detectCountryLocationsInSetFilter( + { criteria: [{ type: "location_ids", locations: "France" }] }, + "set_filter", + "fr" + ) + ).toHaveLength(1); + }); +}); + +describe("leadbay_pull_followups — set_filter ingress", () => { + it("refuses a country in set_filter without persisting a filter", async () => { + mockHttp([]); + const result: any = await pullFollowups.execute(frClient(), { + set_filter: geoFilter(["France"]) as any, + }); + expect(result.status).toBe("country_level_location"); + expect(result.code).toBe("COUNTRY_LEVEL_LOCATION"); + expect(result.country_locations[0].param).toBe("set_filter.criteria[].locations"); + // Zero requests is the load-bearing assertion: no POST /monitor/filter, so + // nothing was persisted — AND no GET /monitor, so no stale-filter cohort was + // read and handed back as though it answered the request. + expect(getHttpRequests()).toHaveLength(0); + }); + + it("returns empty leads rather than a plausible stale page", async () => { + mockHttp([]); + const result: any = await pullFollowups.execute(frClient(), { + set_filter: geoFilter(["France"]) as any, + }); + expect(result.leads).toEqual([]); + expect(result.active_filters).toBeNull(); + }); + + it("catches a country alongside other legitimate criteria", async () => { + mockHttp([]); + const result: any = await pullFollowups.execute(usClient(), { + set_filter: { + criteria: [ + { type: "last_action_date", last_days: 30 }, + { type: "location_ids", is_excluded: false, locations: ["United States"] }, + ], + } as any, + }); + expect(result.status).toBe("country_level_location"); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("reports city AND set_filter offenders in one envelope", async () => { + mockHttp([]); + const result: any = await pullFollowups.execute(usClient(), { + city: "Germany", + set_filter: geoFilter(["Canada"]) as any, + }); + expect(result.country_locations.map((h: any) => h.param)).toEqual([ + "city", + "set_filter.criteria[].locations", + ]); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("still applies a legitimate sub-country set_filter", async () => { + mockHttp([ + { method: "POST", path: "/1.6/monitor/filter", status: 200, body: {} }, + { method: "GET", path: /\/1\.6\/monitor\/filter/, status: 200, body: { criteria: [] } }, + { method: "GET", path: /\/1\.6\/monitor\?/, status: 200, body: { items: [], pagination: null } }, + { method: "GET", path: /\/1\.6\/users\/me/, status: 200, body: { + id: "u-1", email: "u@example.com", organization: { id: "org-1", name: "Acme" }, language: "en", + } }, + ]); + const result: any = await pullFollowups.execute(frClient(), { + set_filter: geoFilter(["416102"]) as any, + }); + expect(result.status).not.toBe("country_level_location"); + // It reached the store-then-apply POST, which is the point. + expect( + getHttpRequests().some((r) => r.method === "POST" && r.path.includes("/monitor/filter")) + ).toBe(true); + }); +}); + +describe("leadbay_followups_map — inherits the set_filter guard", () => { + it("refuses a country in set_filter through the inherited execute", async () => { + mockHttp([]); + const result: any = await followupsMap.execute(frClient(), { + set_filter: geoFilter(["France"]) as any, + }); + expect(result.status).toBe("country_level_location"); + expect(getHttpRequests()).toHaveLength(0); + }); +}); + +describe("leadbay_scan_portfolio_signals — set_filter ingress", () => { + it("refuses a country in set_filter before scanning or persisting", async () => { + mockHttp([]); + const result: any = await scanPortfolioSignals.execute(frClient(), { + query: "M&A", + set_filter: geoFilter(["France"]) as any, + }); + expect(result.status).toBe("country_level_location"); + expect(result.country_locations[0].param).toBe("set_filter.criteria[].locations"); + expect(result.matched).toEqual([]); + expect(result.scanned_count).toBe(0); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("IGNORES set_filter when explicit leadIds are given", async () => { + // Same divergence as `city`: the schema documents set_filter as ignored on + // the leadIds path, so failing on it there would be a false alarm. + mockHttp([ + { + method: "GET", + path: /\/1\.6\/leads\/lead-1/, + status: 200, + body: { id: "lead-1", name: "Acme", location: null }, + }, + ]); + const result: any = await scanPortfolioSignals.execute(frClient(), { + query: "M&A", + set_filter: geoFilter(["France"]) as any, + leadIds: ["lead-1"], + max_leads: 1, + }); + expect(result.status).not.toBe("country_level_location"); + expect(getHttpRequests().length).toBeGreaterThan(0); + }); +}); diff --git a/packages/core/test/unit/composite/set-filter-surviving-scope.test.ts b/packages/core/test/unit/composite/set-filter-surviving-scope.test.ts new file mode 100644 index 00000000..1f0d90b5 --- /dev/null +++ b/packages/core/test/unit/composite/set-filter-surviving-scope.test.ts @@ -0,0 +1,105 @@ +/** + * Whether a Monitor `set_filter` still carries scope is decided by its VALUES, + * not by criterion type (product#3951). + * + * The first version of this predicate lived inline in `pull_followups` and + * rejected every criterion of type `location_ids`, on the assumption that a + * location criterion holding a country holds nothing else. It can — and the + * offending country does not even have to be in the filter: + * + * pull_followups({ city: "France", + * set_filter: { criteria: [{type:"location_ids", + * locations:["99"]}] } }) + * + * puts the offender on `city`, so no hit knows about the Paris id, and the + * type-only test then reported "nothing else was requested" and advised + * `filtered:false` — throwing away exactly the scope the caller had asked for. + */ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mockHttp, resetHttpMock, httpsMockFactory, getHttpRequests } from "../../harness.js"; +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { setFilterCarriesOtherScope } from "../../../src/composite/_country-guard.js"; +import { pullFollowups } from "../../../src/composite/pull-followups.js"; +import { scanPortfolioSignals } from "../../../src/composite/scan-portfolio-signals.js"; + +const frClient = () => new LeadbayClient("https://api-fr.leadbay.app", "u.test-token", "fr"); +const criteria = (c: unknown[]) => ({ criteria: c }); + +beforeEach(() => resetHttpMock()); + +describe("setFilterCarriesOtherScope", () => { + it("a location criterion holding a real place counts", () => { + expect( + setFilterCarriesOtherScope(criteria([{ type: "location_ids", locations: ["99"] }]), "fr") + ).toBe(true); + expect( + setFilterCarriesOtherScope(criteria([{ type: "location_ids", locations: ["Paris"] }]), "fr") + ).toBe(true); + }); + + it("a location criterion holding ONLY a country does not", () => { + expect( + setFilterCarriesOtherScope(criteria([{ type: "location_ids", locations: ["France"] }]), "fr") + ).toBe(false); + }); + + it("a mixed location criterion counts — the real place would be lost", () => { + expect( + setFilterCarriesOtherScope( + criteria([{ type: "location_ids", locations: ["France", "Paris"] }]), + "fr" + ) + ).toBe(true); + }); + + it("any non-geo criterion counts", () => { + expect( + setFilterCarriesOtherScope(criteria([{ type: "last_action_date", last_days: 30 }]), "fr") + ).toBe(true); + expect(setFilterCarriesOtherScope(criteria([{ type: "liked" }]), "fr")).toBe(true); + }); + + it("nothing, junk and an empty array do not", () => { + expect(setFilterCarriesOtherScope(undefined, "fr")).toBe(false); + expect(setFilterCarriesOtherScope("nope", "fr")).toBe(false); + expect(setFilterCarriesOtherScope(criteria([]), "fr")).toBe(false); + expect(setFilterCarriesOtherScope({ criteria: "nope" }, "fr")).toBe(false); + }); +}); + +describe("the offender on `city`, the scope in `set_filter`", () => { + const params = { + city: "France", + set_filter: { criteria: [{ type: "location_ids", locations: ["99"] }] }, + }; + + it("pull_followups does not tell the caller to wipe the filter", async () => { + mockHttp([]); + const result: any = await pullFollowups.execute(frClient(), params); + expect(result.status).toBe("country_level_location"); + // The regression was ADVISING `filtered:false`, which discards the Paris + // criterion. The corrected caveat names it in order to forbid it, so the + // assertion has to be about the instruction, not the substring. + expect(result.hint).toMatch(/Do NOT pass `filtered:false`/); + expect(result.hint).not.toMatch(/Nothing else was requested, so pass `filtered:false`/); + expect(result.hint).toMatch(/SURVIVING criteria/); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("scan_portfolio_signals gets the same treatment", async () => { + mockHttp([]); + const result: any = await scanPortfolioSignals.execute(frClient(), params); + expect(result.status).toBe("country_level_location"); + expect(result.hint).toMatch(/SURVIVING criteria/); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("and with genuinely nothing else, the stale-filter advice still fires", async () => { + mockHttp([]); + const result: any = await pullFollowups.execute(frClient(), { city: "France" }); + expect(result.hint).toMatch(/filtered:false/); + expect(result.hint).toMatch(/Nothing else was requested/); + }); +}); diff --git a/packages/core/test/unit/tools/list-locations-country-guard.test.ts b/packages/core/test/unit/tools/list-locations-country-guard.test.ts new file mode 100644 index 00000000..23c8159a --- /dev/null +++ b/packages/core/test/unit/tools/list-locations-country-guard.test.ts @@ -0,0 +1,104 @@ +/** + * leadbay_list_locations refuses to look up a country (product#3951). + * + * This is the highest-leverage single insertion point in the whole guard, + * because this tool is the one that HANDS OUT the admin-area ids every other + * tool filters on. The admin-area index has no country nodes (product#3885), so + * `q: "France"` cannot return France — it returns the commune of Francs, and an + * id copied from that result fences whatever it is pasted into to one village + * with no visible sign. Refusing the lookup means the bad id is never minted. + * + * Matches this tool's own idiom: it returns an envelope (an empty `q` already + * does) rather than throwing. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { listLocations } from "../../../src/tools/list-locations.js"; + +const US_BASE = "https://api-us.leadbay.app"; +const FR_BASE = "https://api-fr.leadbay.app"; +const usClient = () => new LeadbayClient(US_BASE, "u.test-token", "us"); +const frClient = () => new LeadbayClient(FR_BASE, "u.test-token", "fr"); + +const geoHit = (q: RegExp, ...results: any[]) => ({ + method: "GET" as const, + path: q, + status: 200, + body: { results, parents: [] }, +}); + +beforeEach(() => resetHttpMock()); + +describe("leadbay_list_locations — country guard", () => { + it("refuses the home country and returns no ids to paste", async () => { + mockHttp([]); + const result: any = await listLocations.execute(frClient(), { q: "France" }); + expect(result.status).toBe("country_level_location"); + expect(result.code).toBe("COUNTRY_LEVEL_LOCATION"); + expect(result.results).toEqual([]); + expect(result.parents).toEqual([]); + expect(result.country_locations[0].param).toBe("q"); + // The measured failure: this lookup used to return the commune of Francs. + expect(getHttpRequests()).toHaveLength(0); + }); + + it("refuses the US home country on the US backend", async () => { + mockHttp([]); + const result: any = await listLocations.execute(usClient(), { + q: "United States", + }); + expect(result.status).toBe("country_level_location"); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("refuses a foreign country", async () => { + mockHttp([]); + const result: any = await listLocations.execute(usClient(), { q: "Germany" }); + expect(result.status).toBe("country_level_location"); + expect(result.country_locations[0].kind).toBe("foreign_country"); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("looks up an ordinary city normally", async () => { + mockHttp([ + geoHit(/\/1\.6\/geo\/search\?q=Paris/, { + id: "1", name: "Paris", country: "FR", level: 8, parent_ids: [], + }), + ]); + const result: any = await listLocations.execute(frClient(), { q: "Paris" }); + expect(result.status).toBeUndefined(); + expect(result.results).toHaveLength(1); + expect(result.results[0].name).toBe("Paris"); + }); + + it("looks up Georgia on the US backend but refuses it on FR", async () => { + mockHttp([ + geoHit(/\/1\.6\/geo\/search\?q=Georgia/, { + id: "9", name: "Georgia", country: "US", level: 4, parent_ids: [], + }), + ]); + const onUs: any = await listLocations.execute(usClient(), { q: "Georgia" }); + expect(onUs.results).toHaveLength(1); + + resetHttpMock(); + mockHttp([]); + const onFr: any = await listLocations.execute(frClient(), { q: "Georgia" }); + expect(onFr.status).toBe("country_level_location"); + }); + + it("still returns the empty envelope for a blank query", async () => { + mockHttp([]); + const result: any = await listLocations.execute(frClient(), { q: " " }); + expect(result).toEqual({ results: [], parents: [] }); + expect(getHttpRequests()).toHaveLength(0); + }); +}); diff --git a/packages/core/test/unit/tools/update-lens-filter-country-guard.test.ts b/packages/core/test/unit/tools/update-lens-filter-country-guard.test.ts new file mode 100644 index 00000000..2cf4b590 --- /dev/null +++ b/packages/core/test/unit/tools/update-lens-filter-country-guard.test.ts @@ -0,0 +1,145 @@ +/** + * leadbay_update_lens_filter refuses country-level locations (product#3951). + * + * This is the rawest write path to a `location_ids` criterion — the body is an + * opaque FilterPayload POSTed straight through — so it is the easiest place to + * smuggle a country in, and the damage is persistent: the lens stays fenced to + * a same-named commune for every later pull. + * + * Unlike the composites, this tool THROWS for input problems, so the guard + * matches that idiom rather than inventing a status envelope for one tool. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { updateLensFilter } from "../../../src/tools/update-lens-filter.js"; + +const FR_BASE = "https://api-fr.leadbay.app"; +const frClient = () => new LeadbayClient(FR_BASE, "u.test-token", "fr"); + +const criterionFilter = (locations: unknown) => ({ + lens_filter: { + items: [{ criteria: [{ type: "location_ids", is_excluded: false, locations }] }], + }, + locations: { results: [], parents: [] }, +}); + +beforeEach(() => resetHttpMock()); + +describe("leadbay_update_lens_filter — country guard", () => { + it("throws a named error for a country in a location_ids criterion", async () => { + mockHttp([]); + await expect( + updateLensFilter.execute(frClient(), { + lensId: 4242, + filter: criterionFilter(["France"]) as any, + }) + ).rejects.toMatchObject({ + error: true, + code: "COUNTRY_LEVEL_LOCATION", + }); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("still throws on a DRY RUN", async () => { + // The guard precedes the dry_run short-circuit on purpose: echoing a + // cheerful `would_call` for a country-bearing payload would teach the agent + // the payload is valid, and it would send the real one next. + mockHttp([]); + await expect( + updateLensFilter.execute(frClient(), { + lensId: 4242, + filter: criterionFilter(["France"]) as any, + dry_run: true, + }) + ).rejects.toMatchObject({ code: "COUNTRY_LEVEL_LOCATION" }); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("catches a country that arrived as a resolved id, via the echoed block", async () => { + // The criterion carries only numeric ids, but a filter round-tripped + // through get_lens_filter echoes the resolved areas WITH their names — + // the only client-side way to see a country behind an id. + mockHttp([]); + await expect( + updateLensFilter.execute(frClient(), { + lensId: 4242, + filter: { + lens_filter: { + items: [{ criteria: [{ type: "location_ids", is_excluded: false, locations: ["27925"] }] }], + }, + locations: { + results: [{ id: "27925", name: "France", country: "FR", level: 2, parent_ids: [] }], + parents: [], + }, + } as any, + }) + ).rejects.toMatchObject({ code: "COUNTRY_LEVEL_LOCATION" }); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("names the offending path and states the omit rule", async () => { + mockHttp([]); + let thrown: any; + try { + await updateLensFilter.execute(frClient(), { + lensId: 4242, + filter: criterionFilter(["France"]) as any, + }); + } catch (err) { + thrown = err; + } + expect(thrown.message).toContain("criteria[].locations"); + expect(thrown.message).toContain("France"); + // NOT "omit the criterion and re-call": update_lens_filter replaces the + // whole filter, so re-calling without the criterion rewrites the lens to + // express no scope — the mutation WORKFLOWS.md forbids for a country-wide + // ask. The recovery has to stop. + expect(thrown.hint).toMatch(/Write NOTHING/); + expect(thrown.hint).toMatch(/do NOT re-call this tool/i); + }); + + it("writes a clean filter through untouched", async () => { + mockHttp([ + { method: "POST", path: "/1.6/lenses/4242/filter", status: 200, body: {} }, + ]); + const result: any = await updateLensFilter.execute(frClient(), { + lensId: 4242, + filter: { + lens_filter: { + items: [{ criteria: [{ type: "location_ids", is_excluded: false, locations: ["416102"] }] }], + }, + locations: { + results: [{ id: "416102", name: "Île-de-France", country: "FR", level: 5, parent_ids: [] }], + parents: [], + }, + } as any, + }); + expect(result).toEqual({ updated: true, lens_id: 4242 }); + expect(getHttpRequests()).toHaveLength(1); + }); + + it("tolerates a malformed filter without inventing a country error", async () => { + // A tolerant walk must not turn a shape problem into COUNTRY_LEVEL_LOCATION + // — that would send the agent chasing a location bug it does not have. + for (const filter of [null, {}, { lens_filter: {} }, { lens_filter: { items: "x" } }]) { + resetHttpMock(); + mockHttp([ + { method: "POST", path: "/1.6/lenses/4242/filter", status: 200, body: {} }, + ]); + const result: any = await updateLensFilter.execute(frClient(), { + lensId: 4242, + filter: filter as any, + }); + expect(result).toEqual({ updated: true, lens_id: 4242 }); + } + }); +}); diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 761b1196..00b5b1d1 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,77 @@ # Changelog — @leadbay/mcp +## 0.30.0 — 2026-08-19 + +Encode the **single-country rule** across every location-accepting surface +(product#3951). Each backend serves exactly ONE country, so a country name is +never a location criterion — whole-country intent means omitting the filter. +It never failed loudly: the admin-area index excludes country nodes +(product#3885), so the value trigram-matched a same-named town ("France" → the +commune of Francs, "United States" → Statesboro) and silently fenced the search +to one village. 3/3 sessions in the 2026-08-02 acceptance eval passed one; an FR +session burned six variants inside that fence before answering wrongly. + +- **The descriptions were telling the agent to do it.** This was a + contradiction sweep, not a missing rule. `followups-map.md.tmpl:47` said to + pass `countries ("France", "United States")` and advertised + `level 2 (country)` as searchable; `pull-followups`, `adjust-audience`, + `new-lens`, `tour-plan` and the `leadbay_followup_check_in` prompt each + legitimized country-level values. All rewritten to enumerate the levels the + argument accepts. (Their `"Bavaria"` examples went too — a German region on a + US/FR-only product.) +- **New shared snippet** `heuristics/single-country-universe.md`, included by 8 + tool descriptions and 5 prompts, carrying the rule, the measured failure and + a recovery that branches — because one recovery is wrong for most cases. + `country_locations[].axis` and `[].kind` decide it: only the HOME country on + the INCLUDE axis means "omit the argument". A foreign country is unsupported, + not unfiltered (re-running unfiltered answers a France question with US + leads). On the EXCLUDE axis omitting is the inverse of the request. And when + the argument carries a real place beside the country, only the country comes + off — "keep the city, drop the country". +- **Mechanical rejection** in `_country-guard.ts` + `_country-names.ts` (full + ISO 3166-1, English + French, no new dependency). Delivered in each tool's own + idiom: composites return `status: "country_level_location"` and write nothing, + `update_lens_filter` throws (including on `dry_run`), `list_locations` returns + its empty envelope. Always the first statement of `execute`, so a bad value + costs zero HTTP. +- **Exemptions that keep real prospecting working:** a `sovereign` field + (Guadeloupe/Martinique/Réunion/Guyane valid on FR, Puerto Rico/Guam on US), + region homonyms (Georgia the state, Jersey), and no foreign alpha-2 rejection + on US, where 26 ISO codes double as state postal codes. Test sweeps over all + 50 states + postal codes and all 13 régions + 101 départements hold the line. +- **New audit** `test/audit/single-country-rule.test.ts` — asserts the rule is + present in all 11 surfaces AND that none of them still says a country is a + valid geo value, so the pre-fix state is unmergeable. It imports + `COUNTRY_LEVEL_LOCATION` from core so a rename cannot leave the prose + teaching a recovery for an error that no longer exists. +- **`WORKFLOWS.md`**: row 39 gains the country-is-not-a-territory rule and a + success criterion; new row 52 "Country-wide scope — omit the location filter" + with its contract. +- **Writes stop rather than retry.** On `new_lens` / `adjust_audience` / + `update_lens_filter`, "drop the country and re-call" persists a lens change + expressing a scope the workspace already has — the mutation WORKFLOWS.md + forbids for this ask. The guard stops instead, but only when the country was + the request's ONLY scope: a sector, a size or a real place elsewhere in the + request is written as asked. An exclusion that is not a foreign country + blocks the write outright whatever else survives, since dropping it persists + the opposite of what was asked. +- **The country is read, never inferred.** `_meta.region` is the only evidence + of which country a workspace serves; the user's wording is not, and + `agent_memory_capture` must never store it — a wrong country there is + replayed as remembered fact. +- **Two eval scenarios** under `test/eval/scenarios/country-scope/` (over- and + under-deliver). Gated behind `EVAL=1`; CI protection is the audit. Run live + on FR and US staging tenants: no country value reached a geo argument on any + call, no lens was written, no country was captured to memory, and the country + named in each answer traced to `_meta.region`. +- Freed the budget for the snippet by de-padding the `pull-followups` NEXT + STEPS table (1109 chars of markdown column alignment, no content change) — + that tool was 52 chars from the 17000 cap. + +Known gap: a country passed as an already-resolved numeric admin-area id stays +invisible client-side (deciding whether id "1234" is a country needs a lookup +this client does not have). Tracked in product#3939; a test records it. + ## 0.28.0 — 2026-07-31 Add **`leadbay_top_accounts_to_activate`** — a prompt that builds a ranked diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 2a426558..4a2e6eab 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@leadbay/mcp", - "version": "0.29.0", + "version": "0.30.0", "mcpName": "io.github.leadbay/leadbay-mcp", "description": "Model Context Protocol (MCP) server for Leadbay — AI lead discovery, qualification, and enrichment for Claude Desktop, Cursor, and Claude Code.", "type": "module", diff --git a/packages/mcp/server.json b/packages/mcp/server.json index d253a214..885662fb 100644 --- a/packages/mcp/server.json +++ b/packages/mcp/server.json @@ -3,7 +3,7 @@ "name": "io.github.leadbay/leadbay-mcp", "title": "Leadbay", "description": "AI lead discovery, qualification, and outreach prep on your Leadbay account.", - "version": "0.29.0", + "version": "0.30.0", "repository": { "url": "https://github.com/leadbay/mcp", "source": "github", @@ -24,7 +24,7 @@ "registryType": "npm", "registryBaseUrl": "https://registry.npmjs.org", "identifier": "@leadbay/mcp", - "version": "0.29.0", + "version": "0.30.0", "transport": { "type": "stdio" }, @@ -36,7 +36,7 @@ }, { "type": "positional", - "value": "--package=@leadbay/mcp@0.29" + "value": "--package=@leadbay/mcp@0.30" }, { "type": "positional", diff --git a/packages/mcp/src/prompts.generated.ts b/packages/mcp/src/prompts.generated.ts index 1fcae8a8..28f9c493 100644 --- a/packages/mcp/src/prompts.generated.ts +++ b/packages/mcp/src/prompts.generated.ts @@ -509,7 +509,38 @@ Call \`leadbay_pull_followups\` (NOT \`leadbay_pull_leads\` — those are differ For geo filters specifically: prefer the \`city\` shortcut on \`leadbay_pull_followups({city: "Berlin"})\` — the composite resolves the free-text city via \`/geo/search\`, returns ambiguities to disambiguate when needed (status: "ambiguous_locations" → pick an id → re-call with \`city_id\`), then merges the resolved admin_area into the Monitor filter as \`location_ids\`. If the user has already given you a numeric id, pass it as \`city_id\`. Don't guess admin_area ids — let the resolver do it. -**TRAVEL / IN-PERSON ROUTING** — when the user's intent is geographic and visual ("I'm going to NYC next week", "leads I should visit in person", "this week's trip", "show me followups in ", "plan my itinerary", "trip itinerary", "show on a map", "leads in Texas / California / France", or any phrasing that asks for a map / geographic / trip-planning view — INCLUDING state-, country-, and region-level place names): +**One workspace = one country — a country name is NEVER a location filter.** The admin-area index holds no country nodes, so \`"France"\` matches the *commune of Francs* and \`"United States"\` matches *Statesboro*: the call is silently fenced to one village and every conclusion from it is wrong. City AND country named? Keep the city, drop the country. + +**On \`code: "COUNTRY_LEVEL_LOCATION"\` read \`country_locations[].axis\` and \`[].kind\` — the recovery differs per case and they are NOT interchangeable, and do NOT retry with another spelling or a nearby city.** + +\`axis: "include"\`: + +- \`home_country\`, or "nationwide" / "everywhere" → drop that ONE value. Omit the geo argument (\`city\` / \`locations\` / \`location_ids\`) only if nothing else was on it — then the result covers the whole workspace. If other values remain, keep them and describe the result as those places. +- \`foreign_country\` ("leads in France" on a US workspace) → **unsupported, not unfiltered.** Do NOT re-run without the argument: whole-workspace results are US leads and answer nothing about France. Say the workspace holds only its own country's companies. +- \`supranational\` ("EU", "EMEA") → name what the workspace covers, then offer the whole-workspace view as an explicit choice rather than assuming it. +- \`country_indeterminate\` (custom/staging backend) → its country is unknown, so claim nothing about what it holds. + +\`axis: "exclude"\` reverses all of that — **never "omit the argument"**, which returns the very companies the user asked to remove. Excluding this workspace's own country would empty it; excluding any other country is a harmless no-op. Either way drop the value and ask what to carve out instead. + +On a lens-WRITING tool (\`new_lens\`, \`adjust_audience\`, \`update_lens_filter\`) write NOTHING, with no re-call in any form: when the country was the only scope, and for ANY non-\`foreign_country\` \`exclude\` hit however much else came with it — dropping it and writing the rest inverts the ask. + +**Never infer WHICH country this workspace serves from the user's wording** — "the whole US" does not make it one. Read \`_meta.region\` on any tool result — it outranks any recalled memory; on \`custom\`, claim nothing. + +Place names never go in \`keywords\`, \`sectors\` or \`refine_prompt\` — text matches, not geo filters. + + +⚠ **On this prompt, omitting the geo argument is only HALF of a whole-workspace read.** +\`leadbay_pull_followups\` defaults \`filtered\` to true, so dropping the country still reads +the Monitor view through whatever filter a previous session persisted — an old city +filter comes back as a small, plausible cohort and you would report it as everything. +When the ask covers the whole workspace and nothing else was requested, pass +\`filtered:false\`. When other criteria WERE requested (a sector, a recency window, a real +city), re-send those in \`set_filter\` instead — that overwrites the stored filter, so +\`filtered:false\` would throw away the very scope I asked for. Either way, read +\`active_filters\` off the response and describe the scope from THAT, not from what you +intended to send. + +**TRAVEL / IN-PERSON ROUTING** — when the user's intent is geographic and visual ("I'm going to NYC next week", "leads I should visit in person", "this week's trip", "show me followups in ", "plan my itinerary", "trip itinerary", "show on a map", "leads in Texas / California", or any phrasing that asks for a map / geographic / trip-planning view — INCLUDING state- and region-level place names, but NEVER a country): 1. Call **\`leadbay_followups_map\`** (same params as \`pull_followups\`: \`city\` / \`city_id\` / \`set_filter\`). Same response shape — just the explicit entry-point so the agent and the host know to route geographically. 2. Output a **per-lead place-card block** for each top follow-up, in this exact format — modern chat hosts (Claude / cowork) detect addresses + company names and surface them as a beautiful Google-Place-card carousel with our notes as the "Notes from Claude" section. Lean INTO that surface; don't fight it. @@ -543,16 +574,16 @@ Markdown table with FOUR columns, sorted by \`last_monitor_action_at\` desc. **N **Active-filters line** ABOVE the table, \` · \`-separated chips from \`active_filters.criteria\`: -| Criterion type | Chip | -|-----------------------|----------------------------| -| \`location_ids\` | 📍 \\ | -| \`sector_ids\` | 🏷 \\ | -| \`keywords\` | 🔍 \\ | -| \`size\` | 👥 \\–\\ | -| \`last_action_date\` | 📅 \\ | -| \`last_action\` | 🎯 \\ | -| \`liked\` / \`yc\` | ⭐ liked / 🏅 YC | -| \`custom_field*\` | ⚙ \\ | +| Criterion type | Chip | +| --- | --- | +| \`location_ids\` | 📍 \\ | +| \`sector_ids\` | 🏷 \\ | +| \`keywords\` | 🔍 \\ | +| \`size\` | 👥 \\–\\ | +| \`last_action_date\` | 📅 \\ | +| \`last_action\` | 🎯 \\ | +| \`liked\` / \`yc\` | ⭐ liked / 🏅 YC | +| \`custom_field*\` | ⚙ \\ | Render \`*No filters applied.*\` when empty. @@ -1482,7 +1513,29 @@ Map my answers to the \`leadbay_tour_plan\` call: # PHASE 2 — BUILD THE ITINERARY -Call \`leadbay_tour_plan({city: "{{arg:city}}", …scope from PHASE 1})\`. If the response is \`status: "ambiguous_locations"\`, surface the candidates and ask me to pick one, then re-call with \`city_id\`. +**One workspace = one country — a country name is NEVER a location filter.** The admin-area index holds no country nodes, so \`"France"\` matches the *commune of Francs* and \`"United States"\` matches *Statesboro*: the call is silently fenced to one village and every conclusion from it is wrong. City AND country named? Keep the city, drop the country. + +**On \`code: "COUNTRY_LEVEL_LOCATION"\` read \`country_locations[].axis\` and \`[].kind\` — the recovery differs per case and they are NOT interchangeable, and do NOT retry with another spelling or a nearby city.** + +\`axis: "include"\`: + +- \`home_country\`, or "nationwide" / "everywhere" → drop that ONE value. Omit the geo argument (\`city\` / \`locations\` / \`location_ids\`) only if nothing else was on it — then the result covers the whole workspace. If other values remain, keep them and describe the result as those places. +- \`foreign_country\` ("leads in France" on a US workspace) → **unsupported, not unfiltered.** Do NOT re-run without the argument: whole-workspace results are US leads and answer nothing about France. Say the workspace holds only its own country's companies. +- \`supranational\` ("EU", "EMEA") → name what the workspace covers, then offer the whole-workspace view as an explicit choice rather than assuming it. +- \`country_indeterminate\` (custom/staging backend) → its country is unknown, so claim nothing about what it holds. + +\`axis: "exclude"\` reverses all of that — **never "omit the argument"**, which returns the very companies the user asked to remove. Excluding this workspace's own country would empty it; excluding any other country is a harmless no-op. Either way drop the value and ask what to carve out instead. + +On a lens-WRITING tool (\`new_lens\`, \`adjust_audience\`, \`update_lens_filter\`) write NOTHING, with no re-call in any form: when the country was the only scope, and for ANY non-\`foreign_country\` \`exclude\` hit however much else came with it — dropping it and writing the rest inverts the ask. + +**Never infer WHICH country this workspace serves from the user's wording** — "the whole US" does not make it one. Read \`_meta.region\` on any tool result — it outranks any recalled memory; on \`custom\`, claim nothing. + +Place names never go in \`keywords\`, \`sectors\` or \`refine_prompt\` — text matches, not geo filters. + + +**Gate before calling.** If \`{{arg:city}}\` is a country name or a supra-national scope rather than a city, do NOT call \`leadbay_tour_plan\` with it — a tour of an entire country is not an itinerary, and the value would resolve to a same-named village. Tell me the workspace already covers one country and ask which city or region I'm actually visiting. Otherwise: + +Call \`leadbay_tour_plan({city: "{{arg:city}}", …scope from PHASE 1})\`. If the response is \`status: "ambiguous_locations"\`, surface the candidates and ask me to pick one, then re-call with \`city_id\`. If it is \`status: "country_level_location"\`, do NOT retry with a spelling variant and do NOT re-call without \`city\` — a tour with no city is arbitrary nationwide leads, not an itinerary. Ask me which city or region I am visiting. Split the returned \`monitor_leads\` into two buckets client-side using their engagement-history fields: @@ -1858,8 +1911,76 @@ Recommend the single most-promising lead from this batch and offer to research i export const leadbay_refine_audience: string = ` Refine the Leadbay audience prompt to: {{arg:instruction}} -# PHASE 1 — REFINE -Call \`leadbay_refine_prompt\` with \`prompt=\`. +# PHASE 0 — GATE: RESOLVE THE REGION, STRIP THE COUNTRY, THEN CLASSIFY (may end the run) +A refine prompt shapes the KIND of company, never WHERE it is. Before any tool call: + +**Step 1 — if a COUNTRY is named at all, find out which country this workspace serves, +and do it FIRST.** Every later step turns on whether the country I named is this +workspace's own, and you cannot tell that from my message: "French hospitals across +France" is a redundant clause on an FR backend and an unsupported ask on a US one, and +the language I write in says nothing about it. Do NOT guess from the country I named, +from my language, or from the fact that the request sounds plausible — strip first and +you will have already decided, silently and possibly wrongly, that the country was +redundant. Every Leadbay tool result carries the fact at \`_meta.region\` +(\`us\` | \`fr\` | \`custom\`); if no call this session has returned one, call +\`leadbay_account_status\` — read-only, writes nothing — and read \`_meta.region\` from it. +\`custom\` means the backend's country is unknown: claim nothing about which country it +holds. Only a place BELOW country level ("in Paris", "Texas") skips this step. + +**Step 2 — now strip, and do not stop.** With the region known, if my instruction names +this workspace's own country or a whole-country scope ("nationwide", "the whole US", +"partout en France"), remove that phrase and KEEP THE REST. It is redundant, never a +filter — but it is almost never the whole instruction. "Hospitals running their own IT +nationwide" is a refinement about hospitals; "hospitals in Paris, France" is Paris plus +hospitals. Losing the rest because a country rode along is the worse error of the two. +A country that is NOT this workspace's own is not stripped — it is the whole answer, and +Step 3 handles it. + +**Step 3 — classify what REMAINS**, and act on every part of it: + +- **Nothing remains** (the country was the entire instruction) → **STOP HERE. Call + NOTHING.** Do not continue to PHASE 1: \`leadbay_refine_prompt\` would overwrite my + qualitative audience prompt and kick off an intelligence recompute to express a scope + this workspace already has. Tell me there is nothing to set because the workspace + already covers exactly that, offer the axes that do narrow an audience (sector, size, + or a sub-country region / state / county / city), and end your turn. +- **A DIFFERENT country** ("partout en France" on a US workspace) → **STOP HERE too, but + do not say "there is nothing to set" — that is false.** The ask is UNSUPPORTED, not + already-satisfied: this workspace holds only its own country's companies, so there are + no leads there to scope to. Say so plainly, do not offer an unfiltered view as if it + answered the request, and end your turn. If a qualitative part rode along with it, say + it cannot be applied to a country that is not here either. +- **A supra-national scope** ("EU-wide", "EMEA") → stop as well: name what the workspace + covers and ask whether I want that instead, rather than assuming it. +- **A sub-country place** ("prospects in Texas", "restrict to Indre-et-Loire") → a place + is not a qualitative refinement: route it to \`leadbay_adjust_audience({locations: [...]})\` + and say why. If a qualitative part ALSO remains, continue to PHASE 1 with that part — + do not drop half the request. +- **A qualitative refinement** → continue to PHASE 1, passing the STRIPPED text and never + the raw instruction. + +**One workspace = one country — a country name is NEVER a location filter.** The admin-area index holds no country nodes, so \`"France"\` matches the *commune of Francs* and \`"United States"\` matches *Statesboro*: the call is silently fenced to one village and every conclusion from it is wrong. City AND country named? Keep the city, drop the country. + +**On \`code: "COUNTRY_LEVEL_LOCATION"\` read \`country_locations[].axis\` and \`[].kind\` — the recovery differs per case and they are NOT interchangeable, and do NOT retry with another spelling or a nearby city.** + +\`axis: "include"\`: + +- \`home_country\`, or "nationwide" / "everywhere" → drop that ONE value. Omit the geo argument (\`city\` / \`locations\` / \`location_ids\`) only if nothing else was on it — then the result covers the whole workspace. If other values remain, keep them and describe the result as those places. +- \`foreign_country\` ("leads in France" on a US workspace) → **unsupported, not unfiltered.** Do NOT re-run without the argument: whole-workspace results are US leads and answer nothing about France. Say the workspace holds only its own country's companies. +- \`supranational\` ("EU", "EMEA") → name what the workspace covers, then offer the whole-workspace view as an explicit choice rather than assuming it. +- \`country_indeterminate\` (custom/staging backend) → its country is unknown, so claim nothing about what it holds. + +\`axis: "exclude"\` reverses all of that — **never "omit the argument"**, which returns the very companies the user asked to remove. Excluding this workspace's own country would empty it; excluding any other country is a harmless no-op. Either way drop the value and ask what to carve out instead. + +On a lens-WRITING tool (\`new_lens\`, \`adjust_audience\`, \`update_lens_filter\`) write NOTHING, with no re-call in any form: when the country was the only scope, and for ANY non-\`foreign_country\` \`exclude\` hit however much else came with it — dropping it and writing the rest inverts the ask. + +**Never infer WHICH country this workspace serves from the user's wording** — "the whole US" does not make it one. Read \`_meta.region\` on any tool result — it outranks any recalled memory; on \`custom\`, claim nothing. + +Place names never go in \`keywords\`, \`sectors\` or \`refine_prompt\` — text matches, not geo filters. + + +# PHASE 1 — REFINE (only when PHASE 0 classified the instruction as qualitative) +Call \`leadbay_refine_prompt\` with \`prompt=\` — the text with any country phrase removed, never the raw instruction. # PHASE 2 — CLARIFICATION ROUND-TRIP (if needed) @@ -2002,7 +2123,59 @@ If the prompt's body and the tool's RENDERING appear to conflict, the tool's REN # PHASE 1 — INTERPRET INTENT INTO A LENS -Call \`leadbay_refine_prompt({user_prompt: "{{arg:audience}}"})\`. This handles the clarification protocol natively — if the system needs more info (e.g. industry disambiguation, geography precision), it returns \`status: "clarification_needed"\` with options. Surface those to me; on my answer, re-call \`leadbay_refine_prompt\` until the prompt converges. +**One workspace = one country — a country name is NEVER a location filter.** The admin-area index holds no country nodes, so \`"France"\` matches the *commune of Francs* and \`"United States"\` matches *Statesboro*: the call is silently fenced to one village and every conclusion from it is wrong. City AND country named? Keep the city, drop the country. + +**On \`code: "COUNTRY_LEVEL_LOCATION"\` read \`country_locations[].axis\` and \`[].kind\` — the recovery differs per case and they are NOT interchangeable, and do NOT retry with another spelling or a nearby city.** + +\`axis: "include"\`: + +- \`home_country\`, or "nationwide" / "everywhere" → drop that ONE value. Omit the geo argument (\`city\` / \`locations\` / \`location_ids\`) only if nothing else was on it — then the result covers the whole workspace. If other values remain, keep them and describe the result as those places. +- \`foreign_country\` ("leads in France" on a US workspace) → **unsupported, not unfiltered.** Do NOT re-run without the argument: whole-workspace results are US leads and answer nothing about France. Say the workspace holds only its own country's companies. +- \`supranational\` ("EU", "EMEA") → name what the workspace covers, then offer the whole-workspace view as an explicit choice rather than assuming it. +- \`country_indeterminate\` (custom/staging backend) → its country is unknown, so claim nothing about what it holds. + +\`axis: "exclude"\` reverses all of that — **never "omit the argument"**, which returns the very companies the user asked to remove. Excluding this workspace's own country would empty it; excluding any other country is a harmless no-op. Either way drop the value and ask what to carve out instead. + +On a lens-WRITING tool (\`new_lens\`, \`adjust_audience\`, \`update_lens_filter\`) write NOTHING, with no re-call in any form: when the country was the only scope, and for ANY non-\`foreign_country\` \`exclude\` hit however much else came with it — dropping it and writing the rest inverts the ask. + +**Never infer WHICH country this workspace serves from the user's wording** — "the whole US" does not make it one. Read \`_meta.region\` on any tool result — it outranks any recalled memory; on \`custom\`, claim nothing. + +Place names never go in \`keywords\`, \`sectors\` or \`refine_prompt\` — text matches, not geo filters. + + +**Before calling, find out which country this workspace serves.** You cannot tell from +my \`audience\` argument: "plumbers across France" is a redundant clause on an FR backend +and an unsupported ask on a US one, and this prompt hands you nothing that says which. +Guessing here creates a lens plus per-rep campaigns in the wrong country. Every Leadbay +tool result carries it at \`_meta.region\` (\`us\` | \`fr\` | \`custom\`); if no call this +session has returned one, call \`leadbay_account_status\` first — read-only, writes +nothing — and read \`_meta.region\` from it. On \`custom\` the backend's country is unknown, +so claim nothing about it: ask me which country this workspace covers before creating +anything. + +**Then classify any country in EITHER free-text argument — \`audience\` AND \`rep_split\`.** +Both reach the workspace, by different routes: \`audience\` becomes the lens, \`rep_split\` +becomes the campaigns in PHASE 3. "Split France to Alice and Germany to Bob" partitions a +single-country cohort along an axis that does not exist here, and PHASE 3 will persist +those campaigns without ever looking again. The three cases do NOT get the same +treatment: + +- **This workspace's own country** ("plumbers across the US" on a US workspace) → drop only that clause and keep everything else. Say you dropped it, then continue: the lens covers the whole workspace anyway. **Unless dropping it leaves NOTHING** — \`audience: "France"\` on an FR workspace is entirely that clause, and what remains is an empty string. Do NOT continue into PHASE 1 with it: \`leadbay_refine_prompt({user_prompt: ""})\` would overwrite my refinement prompt with nothing and \`leadbay_create_lens\` + \`leadbay_promote_lens\` would then persist and ACTIVATE a scopeless lens, to express something this workspace already is. Write nothing at all: tell me the workspace already covers exactly that, and ask for a real sector, size, or sub-country criterion before anything is created. Same for \`rep_split\` — if the sanitized split is empty, there is no split to make. +- **A different country** ("plumbers across France" on a US workspace) → **STOP. Create nothing.** Do NOT drop the country and build a lens for this workspace instead — that would hand me a US lens, plus campaigns, presented as the answer to a France request. Say this workspace holds only its own country's companies, so the ask cannot be filled here, and end your turn. +- **A supra-national scope** ("plumbers across EMEA") → also stop: name what the workspace covers and ask whether I want that instead, rather than assuming it. + +Keep any sub-country place (state, *région*, *département*, county, city) exactly as-is — +those are real splits and real audience clauses. + +For \`rep_split\` specifically, apply the same verdict to the SPLIT AXIS: the home country +is not a split (every lead is in it, so one rep would get everything and the others +nothing) — say so and ask me to split by region / sector / size instead. A different +country or a supra-national scope is not a split either, and there is no cohort to give +that rep: stop rather than silently handing them an empty campaign or, worse, a slice of +the home country's leads labelled with another country's name. Carry only the sanitized +split into PHASE 3. + +Call \`leadbay_refine_prompt({user_prompt: ""})\` — pass the SANITIZED text, not the raw argument, or the country label reaches the lens anyway and fences it to a same-named village. This handles the clarification protocol natively — if the system needs more info (e.g. industry disambiguation, geography precision), it returns \`status: "clarification_needed"\` with options. Surface those to me; on my answer, re-call \`leadbay_refine_prompt\` until the prompt converges. When the prompt has converged, call \`leadbay_create_lens({user_prompt: , name: ""})\` to create a draft lens, then \`leadbay_promote_lens({lensId})\` to make it the active lens. @@ -2016,7 +2189,7 @@ Then ask me ONCE: "Which of these should we drop?" If I name leads to drop, excl # PHASE 3 — DECIDE THE CAMPAIGN SHAPE -If I provided a \`rep_split\` ("one campaign per rep: John gets Tulsa, Sarah gets OKC"), partition the validated leads accordingly. If I didn't, ask ONCE: "Create one campaign for the whole batch, or split per rep / region / sector?" — surface 2-4 options via your host's choice widget (\`ask_user_input_v0\` or \`AskUserQuestion\`) when available, else as a bulleted list. +If I provided a \`rep_split\` ("one campaign per rep: John gets Tulsa, Sarah gets OKC"), partition the validated leads by the SANITIZED split from PHASE 1 — never by the raw argument, and never along a country axis it classified as unusable. If I didn't, ask ONCE: "Create one campaign for the whole batch, or split per rep / region / sector?" — surface 2-4 options via your host's choice widget (\`ask_user_input_v0\` or \`AskUserQuestion\`) when available, else as a bulleted list. For each campaign-shape decision, derive a name. Templates: - Whole batch: \`""\` @@ -2153,7 +2326,7 @@ Call \`leadbay_account_status\` for my quota and active lens. Say that scope in one line up front, so nobody reads the ranking as a money sort. If I ask for a cash-ranked plan, tell me plainly that it needs my invoicing extract and that the MCP has no path to it today — then deliver this plan anyway rather than stopping. -**DELIVER FIRST, ASK ALONGSIDE — never gate the plan on a missing input.** Only ONE thing can stop you before you have shipped a ranked list of real accounts: not knowing **whose** plan this is (a company-identity mismatch you genuinely cannot resolve). Everything else is a question you carry *next to* the delivered plan, not a reason to withhold it: +**DELIVER FIRST, ASK ALONGSIDE — never gate the plan on a missing input.** Only TWO things can stop you before you have shipped a ranked list of real accounts: not knowing **whose** plan this is (a company-identity mismatch you genuinely cannot resolve), and a \`territory\` naming a country that is NOT this workspace's own — or a supra-national scope (see the country branch below, which overrides this rule for that one case). The second is an exception for the same reason as the first: both would ship a plan about the wrong companies. Delivering a whole-workspace plan under a "France" heading is not a partial answer, it is a wrong one. Everything else is a question you carry *next to* the delivered plan, not a reason to withhold it: - **No benchmark?** Costs nothing here — the money column is OMITTED regardless. Pull, qualify, rank by the Leadbay signal, deliver, and mention what a cash-ranked version would need. - **No Tier-1 threshold?** Not a blocker. Deliver, and ask alongside. @@ -2170,7 +2343,45 @@ If I gave a \`territory\`, scope discovery to it now, and **make sure the scopin ⚠ **Location criteria MERGE — they do not replace.** \`adjust_audience\` unions the new \`location_ids\` into any existing include-location criterion (and \`pull_followups\` merges its \`city\` shortcut the same way). So asking for "Région Ouest" on a lens already scoped to Paris yields **Paris OR Région Ouest** while your header claims Région Ouest. Before adding a territory, check the current filter: if it already carries locations you were not asked to keep, clear or replace them (or build a fresh territory-only lens for this one-off plan) rather than stacking a union. - **If a new lens is genuinely warranted: \`leadbay_new_lens\` is a two-step call.** It returns \`status:"preview"\` and creates NOTHING unless you re-call the same args with \`confirm:true\`. So: preview → confirm → take \`lens.id\` from the \`created\` response → pass that id as \`lensId\` on every subsequent pull. Never continue on the previous active lens after previewing a new one; that delivers the old audience under a new heading. -A place name goes to \`locations\`, never to \`sectors\` or a refine prompt. +If the \`territory\` I named is a country, which one decides what you do: + +- **This workspace's own country** → make no scope CHANGE, but do not claim national + coverage until you have READ the lens. \`leadbay_pull_leads\` keeps applying my ACTIVE + lens, and this prompt already warns that lens may be scoped to a city, a sector or a + rep patch. On an FR tenant whose active lens is Paris-only, a \`territory: "France"\` + plan is a Paris plan — and "covers all of France" printed above it is exactly the + confidently wrong deliverable this whole gate exists to stop, this time in my own + header rather than in a filter. + **Read the \`lens:///definition\` resource** — that is the only place a lens's + \`location_ids\` are visible. \`leadbay_pull_leads\` returns only \`lens: {id}\`, not the + filter, and \`active_filters\` describes the separately-persisted MONITOR filter, not + the Discover lens; neither can settle this and neither is a substitute (same rule as + the Monitor-mirroring section below). Then say ONE of: the lens really is + workspace-wide, or it is scoped to \`\` — offering to clear + that scope if national is what I meant. If you genuinely cannot read the definition, + say the scope is unverified rather than calling it national. Then offer sector / size + / sub-country region as the axes that would actually narrow it. +- **A different country, or a supra-national scope** → do NOT simply drop the scope and build the plan anyway. An unfiltered plan is this workspace's own accounts, which is not an answer to a request about somewhere else — delivering it under my heading would be a confidently wrong plan. Say the ask cannot be filled from this workspace and stop. **This is the one case that overrides DELIVER FIRST above**: shipping the plan anyway is the failure, not the fix. + +**One workspace = one country — a country name is NEVER a location filter.** The admin-area index holds no country nodes, so \`"France"\` matches the *commune of Francs* and \`"United States"\` matches *Statesboro*: the call is silently fenced to one village and every conclusion from it is wrong. City AND country named? Keep the city, drop the country. + +**On \`code: "COUNTRY_LEVEL_LOCATION"\` read \`country_locations[].axis\` and \`[].kind\` — the recovery differs per case and they are NOT interchangeable, and do NOT retry with another spelling or a nearby city.** + +\`axis: "include"\`: + +- \`home_country\`, or "nationwide" / "everywhere" → drop that ONE value. Omit the geo argument (\`city\` / \`locations\` / \`location_ids\`) only if nothing else was on it — then the result covers the whole workspace. If other values remain, keep them and describe the result as those places. +- \`foreign_country\` ("leads in France" on a US workspace) → **unsupported, not unfiltered.** Do NOT re-run without the argument: whole-workspace results are US leads and answer nothing about France. Say the workspace holds only its own country's companies. +- \`supranational\` ("EU", "EMEA") → name what the workspace covers, then offer the whole-workspace view as an explicit choice rather than assuming it. +- \`country_indeterminate\` (custom/staging backend) → its country is unknown, so claim nothing about what it holds. + +\`axis: "exclude"\` reverses all of that — **never "omit the argument"**, which returns the very companies the user asked to remove. Excluding this workspace's own country would empty it; excluding any other country is a harmless no-op. Either way drop the value and ask what to carve out instead. + +On a lens-WRITING tool (\`new_lens\`, \`adjust_audience\`, \`update_lens_filter\`) write NOTHING, with no re-call in any form: when the country was the only scope, and for ANY non-\`foreign_country\` \`exclude\` hit however much else came with it — dropping it and writing the rest inverts the ask. + +**Never infer WHICH country this workspace serves from the user's wording** — "the whole US" does not make it one. Read \`_meta.region\` on any tool result — it outranks any recalled memory; on \`custom\`, claim nothing. + +Place names never go in \`keywords\`, \`sectors\` or \`refine_prompt\` — text matches, not geo filters. + # PHASE 1 — THE FIVE QUALIFICATION QUESTIONS @@ -2611,13 +2822,13 @@ export const PROMPT_META = { leadbay_getting_started: {"name":"leadbay_getting_started","short_description":"Guided first-run walkthrough — four clicks that actually use Leadbay: check\nthe account, pull today's leads, draft a first email to the top one, then\nreveal who to send it to. Use when the user is new or asks to be SHOWN how\nLeadbay works (\"walk me through Leadbay\", \"I'm new\", \"how do I use this\",\n\"give me a tour\"). Don't use it for orientation prose with no clicking —\nthat's leadbay_prospecting_overview.\n","arguments":[],"expected_calls":["leadbay_account_status","leadbay_pull_leads","leadbay_prepare_outreach","leadbay_enrich_titles","leadbay_bulk_enrich_status"],"failure_modes":["Presents a gate as prose (\"let me know if you want me to pull your leads\") instead of CALLING the host choice widget — the click IS the lesson, and prose turns the walkthrough into a lecture","Runs a step's tool WITHOUT firing that step's widget first and waiting for the click — the walkthrough becomes an automated demo the user only watches, which is the exact opposite of learning by doing","Fires the widget without the EXPLAIN beat, so the user gets an unexplained button and learns nothing about what a lens or an enrichment actually is","Answers gate 1 with a bare \"you're connected as X at Y\" when the quota IS readable — the user clicked a button labelled `check my account status`, so the quota windows (Daily/Weekly/Monthly gauges, % used, $ spent, resets) ARE the answer, not an optional extra","Renders quota as raw \"credits\" instead of the web app's percentage + dollar-spend gauges, or dumps raw `resource_type` strings the user has never seen","Opens with a wall of text — previewing all four steps, explaining lenses up front, or writing several paragraphs before the first widget. The opening is TWO lines then the button; a first-run user wants to see it work, not read a syllabus","Ends the first message without firing gate 1's widget, leaving the user to reply \"ok\" before anything happens","Rewrites the gate's own `next_steps` payload (its `question`, `label` or `description`) instead of mapping it into the widget verbatim, or merges two gates into a single multi-option widget","Fires a THIRD option, or turns the exit into an alternative route (\"show me my lenses instead\") — each gate carries exactly one forward action plus the `I'm done for now` exit, never a menu of paths","Fires a single-option widget — the host requires 2–4 options, so a lone option is rejected or silently degrades to prose (\"say the word and I'll check it\"), which is the exact defect this rule exists to prevent","Launches the PAID reveal at gate 4 BEFORE the user has picked leads and confirmed — beat 1 must be the free `mode:\"discover\"` preview (no `titles`, no `confirm`, no `email`, no `phone`); the gate click bought the free look, not the reveal, and silence is never consent","Stops at the free preview after the user DID pick leads and confirm — they asked for real contact details, so the second call must actually run with `confirm:true` and the chosen titles","Reports the enrichment without polling `leadbay_bulk_enrich_status` to completion, so it claims contacts it never actually saw resolve","Reveals contacts and never says what it cost — the user just spent credits and deserves the one-line \"N contacts = N credits\", which is also what makes gate 1's quota numbers concrete","Reports \"no leads\" on an empty batch while `computing_wishlist` / `computing_scores` is true — the lens is still building; render the tool's own two-option warm-up widget verbatim and pause","Rewords, reorders or prose-ifies the `next_steps` payload from `leadbay_pull_leads` instead of mapping `options[]` into the widget verbatim","Runs all four steps in one turn without waiting for the user's click between gates — the walkthrough is a sequence of gates, not a script to recite","Skips `leadbay_pull_leads` and jumps straight to enrichment, leaving gate 4 with no `leadIds` to scope","Passes a singular `leadId` to `leadbay_enrich_titles` on the confirmed reveal — that key does not exist on this tool, so it is dropped and the paid call falls back to the whole default wishlist selection, charging for far more than the one lead the user agreed to. it is always the `leadIds` ARRAY, even for a single lead","Drops the pinned `lens.id` between gates, so gate 4 enriches against a different lens than the one the user just saw","Ends the completed walkthrough without the `keep_going` cheat-sheet — the buttons disappear with the tour, so a user who was never told what to TYPE learned to click a tutorial and nothing about using Leadbay tomorrow","Invents phrases for the cheat-sheet, or rewords them into something that sounds nicer but doesn't match the tool's real triggers — teaching a phrase that doesn't route is worse than teaching none","SENDS the gate 3 draft, or offers to send it — the walkthrough drafts and stops there; the email is the user's to judge, and nothing leaves the chat","Passes `enrich:true` to `leadbay_prepare_outreach` at gate 3 — that launches a PAID contact reveal off the back of a DRAFT click, spending credits the user never agreed to","Invents a contact NAME for the gate 3 draft — `recommended_contact` still has null email/name at that point, so the draft is addressed to the job TITLE; a fabricated name is the one thing that makes the whole draft untrustworthy","Treats the null email at gate 3 as a failure — apologising for it, retrying, or calling another tool to fill it in. It is the setup for gate 4 — an email written, nobody to send it to yet","Pastes the drafted email into chat prose alongside `message_compose_v1` instead of letting the composer BE the answer","Enriches leads other than the one it drafted for at gate 3 — gate 4 reveals the person that email is going to, so it is scoped to that ONE lead, one contact, one credit","Renders the cheat-sheet on the exit and stops there, dropping the 1:1 offer — the observed failure is that the agent feels finished once the table is on screen, so the user who just stepped out never hears about the help that would bring them back. ENDING B is not complete without the offer, and the offer goes LAST","Treats the exit click as ENDING C (typed off-script) and closes in silence, or treats a typed request as ENDING B and buries their real answer under a cheat-sheet and a booking link","Turns the exit offer into a pitch — several sentences, a re-opened gate, or an argument for finishing the tour. They said they were done; it is one line and a link","Fires the 1:1 offer mid-tour, or at a user who left by TYPING a different request — a booking link on top of their real question is an interruption, not an offer","Runs the four gates at a user whose actual problem is SETUP — the connector isn't installed, they can't sign in, or their Leadbay tools aren't appearing. The tour assumes a working connection and cannot fix any of it; the setup guide can","Pastes the setup-guide link mid-tour, between gates, instead of once at the closing — a link in the middle of the walkthrough invites the user to leave the thing they're doing"]}, leadbay_import_file: {"name":"leadbay_import_file","short_description":"Import a user-supplied CSV/file into Leadbay through five phases with\nevidence gates — scan, derive, resolve identities, preserve & commit,\nthen optionally qualify and report. The job is to maximize how many\nrows the Leadbay system actually ingests and matches.\n","arguments":[{"name":"file","description":"Path or user-visible name of the CSV/file to import. If omitted, use the file the user attached or referenced.","required":false},{"name":"instruction","description":"Additional user goal, e.g. \"then qualify the leads\", \"preserve owner phone as a custom field\", or \"only import restaurants in Manhattan\".","required":false}],"expected_calls":["leadbay_resolve_import_rows","leadbay_list_mappable_fields","leadbay_create_custom_field","leadbay_import_leads","leadbay_import_and_qualify","leadbay_add_note","leadbay_import_status"],"failure_modes":["Picks LEADBAY_ID from score alone, name-only, fuzzy-name-only, root-domain-only, brand-only, postcode-only, or city-only evidence","Drops meaningful business notes or CRM record links instead of preserving them as custom fields or lead notes","Treats a consumer mailbox domain (gmail.com, hotmail.com, ...) as the company domain","Skips deriving company_domain from a business email when no website column exists (this kills match rate)","Skips the COLUMN PRESERVATION PLAN byproduct before importing","Skips the DECISION LOG byproduct before writing LEADBAY_ID","Returns the imported records WITHOUT writing LEADBAY_ID values back into the user's file (leaves the user no audit trail of what matched)","Fabricates leadIds, contact emails, or mapping IDs not present in the file or a tool response"]}, leadbay_log_outreach: {"name":"leadbay_log_outreach","short_description":"Log outreach (an email I sent, a call I made, a meeting I had) on a\nspecific lead. Captures verification so the SDR pipeline trusts the entry.\n","arguments":[{"name":"lead_id","description":"The lead UUID. Get it from leadbay_pull_leads or leadbay_research_lead_by_id.","required":true},{"name":"summary","description":"1-2 sentences describing what I did (e.g. 'Sent intro email to CTO citing recent Hornsea contract').","required":true}],"expected_calls":["leadbay_report_outreach"],"failure_modes":["Calls leadbay_report_outreach without first collecting a verification source","Fabricates a gmail_message_id or calendar_event_id (the human team treats verification as canonical)","Records outreach to a different lead_id than the one the user supplied","Skips the dry_run step when the user is unsure what would be sent"]}, - leadbay_plan_tour_in_city: {"name":"leadbay_plan_tour_in_city","short_description":"Use whenever the user names a city they'll be in and asks who to see\n— \"I'm in SF next Tuesday, who's worth meeting?\", \"I'm going to Berlin\n— who should I visit?\", \"plan my tour\". Any in-person/visit\nintent tied to a place routes here, NOT to `leadbay_pull_leads`. It\nsurfaces follow-ups + fresh Discover leads in the city via\n`leadbay_tour_plan`, ALWAYS offers to plot them on a map (rendering it\non yes), then offers outreach drafts + campaign persistence.\n","arguments":[{"name":"city","description":"City or region the user is visiting (e.g. 'Limoges', 'Bay Area'). Used as the geo filter for both Monitor and Discover lookups.","required":true},{"name":"date","description":"When the visit is (e.g. 'May 24', 'next Thursday'). Surfaced in the outreach drafts as 'I'll be in on '.","required":false}],"expected_calls":["leadbay_tour_plan","leadbay_research_lead_by_id","leadbay_prepare_outreach","leadbay_create_campaign"],"failure_modes":["Calls leadbay_followups_map (Monitor-only) instead of leadbay_tour_plan — loses the Discover (fresh-lead) half that the user explicitly asked for","Calls leadbay_pull_leads then drops the geo filter — returns the lens-wide wishlist instead of city-relevant fresh leads","Skips the campaign-persist step (\"would you like to save these as a tour?\") — leaves the rep with a one-shot map but no follow-up artifact","Creates a campaign WITHOUT asking the user first — the persist step is high-intent; offer it, don't assume","Fabricates lead_ids when seeding the campaign instead of using the ids returned by tour_plan"]}, + leadbay_plan_tour_in_city: {"name":"leadbay_plan_tour_in_city","short_description":"Use whenever the user names a city they'll be in and asks who to see\n— \"I'm in SF next Tuesday, who's worth meeting?\", \"I'm going to Berlin\n— who should I visit?\", \"plan my tour\". Any in-person/visit\nintent tied to a place routes here, NOT to `leadbay_pull_leads`. It\nsurfaces follow-ups + fresh Discover leads in the city via\n`leadbay_tour_plan`, ALWAYS offers to plot them on a map (rendering it\non yes), then offers outreach drafts + campaign persistence.\n","arguments":[{"name":"city","description":"City or region the user is visiting (e.g. 'Limoges', 'Bay Area'). Used as the geo filter for both Monitor and Discover lookups. A country is not a city: this workspace already covers exactly one country, and a country name here silently fences the tour to a same-named village. Do NOT omit the argument to recover — a city-less tour returns arbitrary leads from across the whole workspace, which is not an itinerary. Ask which city or region the visit is to.","required":true},{"name":"date","description":"When the visit is (e.g. 'May 24', 'next Thursday'). Surfaced in the outreach drafts as 'I'll be in on '.","required":false}],"expected_calls":["leadbay_tour_plan","leadbay_research_lead_by_id","leadbay_prepare_outreach","leadbay_create_campaign"],"failure_modes":["Calls leadbay_followups_map (Monitor-only) instead of leadbay_tour_plan — loses the Discover (fresh-lead) half that the user explicitly asked for","Calls leadbay_pull_leads then drops the geo filter — returns the lens-wide wishlist instead of city-relevant fresh leads","Skips the campaign-persist step (\"would you like to save these as a tour?\") — leaves the rep with a one-shot map but no follow-up artifact","Creates a campaign WITHOUT asking the user first — the persist step is high-intent; offer it, don't assume","Fabricates lead_ids when seeding the campaign instead of using the ids returned by tour_plan"]}, leadbay_prospecting_overview: {"name":"leadbay_prospecting_overview","short_description":"Orientation for working with Leadbay from any host — discovery vs.\nfollow-up, the outreach loop, outcome recording, imports, pushback /\nsnooze, and the connected-outreach-tool registry. Trigger when the\nconversation involves Leadbay leads, prospecting, pipeline, follow-up,\noutreach, or lens / ICP — anything from \"show me my leads\" to \"what\nshould I follow up on\" to \"I'll send via lemlist\".\n","arguments":[],"expected_calls":["leadbay_account_status","leadbay_pull_leads","leadbay_pull_followups","leadbay_research_lead_by_id","leadbay_research_lead_by_name_fuzzy","leadbay_prepare_outreach","leadbay_report_outreach","leadbay_set_pushback","leadbay_remove_pushback","leadbay_bulk_qualify_leads","leadbay_enrich_titles","leadbay_import_leads","leadbay_add_note","leadbay_adjust_audience"],"failure_modes":["Drives outreach without asking the user \"how did it go?\" afterwards — leaving prospecting_actions and epilogue_status stale","Says \"epilogue\" in user-facing dialogue instead of \"outcome\"","Says \"Monitor\" in user-facing dialogue instead of \"follow-ups\"","Treats a \"not now / next quarter\" reply as a note instead of routing through the pushback mechanism","Drafts outreach in a generic format when the user has a connected sequencer (lemlist, Outreach.io, etc.) that has its own idiom","Re-pulls leads without passing the captured lensId, allowing a backend lens shift to discard prior work","Skips the STOP byproduct in any multi-step workflow it triggers","Calls leadbay_pull_leads (Discover wishlist) for a follow-up query, or leadbay_pull_followups (Monitor view) for a discovery query — the two entry points read from different backend tables; the right orchestrators are leadbay_daily_check_in (discovery) and leadbay_followup_check_in (follow-up)"]}, leadbay_qualify_top_n: {"name":"leadbay_qualify_top_n","short_description":"Bulk-qualify the top N un-qualified leads in the active lens. Uses\nleadbay_bulk_qualify_leads with a sensible default budget.\n","arguments":[{"name":"count","description":"How many leads to qualify (default 10, max 25). Higher counts may take 5+ minutes.","required":false}],"expected_calls":["leadbay_bulk_qualify_leads","leadbay_qualify_status","leadbay_pull_leads","leadbay_research_lead_by_id"],"failure_modes":["Picks a count larger than the user asked for (or larger than the max 25)","Glosses over still-running leads in the summary instead of naming them","Recommends a lead from the existing qualified pool instead of one from this batch's actual results","Replaces the canonical pull_leads table with prose when rendering the newly-qualified batch (the per-tool RENDERING block is the structural contract; \"standouts\" commentary sits above it)","Expands the qualify-status sentence into a card or table instead of the one-line status-inline render"]}, leadbay_refine_audience: {"name":"leadbay_refine_audience","short_description":"Refine the kind of leads Leadbay surfaces beyond firmographics, with a\nfree-text instruction. Handles the clarification round-trip if the new\nprompt is ambiguous.\n","arguments":[{"name":"instruction","description":"The refinement (e.g. 'focus on hospitals running their own IT'). Set to plain English.","required":true}],"expected_calls":["leadbay_refine_prompt","leadbay_account_status"],"failure_modes":["Calls leadbay_answer_clarification on the user's behalf instead of surfacing the clarification verbatim","Glosses over the clarification options instead of presenting them as offered","Promises immediate effect when status='applied' actually triggers an async intelligence recompute"]}, leadbay_research_a_domain: {"name":"leadbay_research_a_domain","short_description":"Resolve a company by name or domain across the user's visible Discover,\nMonitor, and Activate corpus, then return everything Leadbay knows about it.\n","arguments":[{"name":"domain","description":"Company name or domain (for example 'Acme Corporation' or 'acme.com'). The legacy argument key remains `domain` for client compatibility.","required":true}],"expected_calls":["leadbay_research_lead_by_name_fuzzy"],"failure_modes":["Fabricates qualification answers not present in any tool response","Calls leadbay_import_and_qualify before searching the existing visible corpus","Treats the active lens as the entire search universe when the user did not request a lens scope","Imports a missing company without the user's explicit permission","Renders the research result as a freeform narrative instead of the canonical research-company-card layout (the card with header score bar, pill row, signal sections, contacts table is the structural contract; commentary belongs ABOVE or BELOW it)"]}, - leadbay_setup_team_prospecting: {"name":"leadbay_setup_team_prospecting","short_description":"Manager-led prospecting setup: conversationally turn a natural-language\naudience ask into a Leadbay lens, validate the candidate leads, and\npersist them as one or more named campaigns the rep(s) can work\nthrough. Closes #3630 US3 end-to-end (within the current\ncreator-scoped campaign visibility model).\n","arguments":[{"name":"audience","description":"Natural-language audience description (e.g. 'plumbing companies with 10-50 employees in Seine-Maritime'). The lens-creation step (`leadbay_refine_prompt` → `leadbay_create_lens`) interprets it.","required":true},{"name":"rep_split","description":"Optional: how to split the validated leads into per-rep campaigns. Free text — e.g. 'split by city' or 'one campaign per rep: John gets Tulsa, Sarah gets OKC'.","required":false}],"expected_calls":["leadbay_refine_prompt","leadbay_create_lens","leadbay_promote_lens","leadbay_pull_leads","leadbay_research_lead_by_id","leadbay_create_campaign","leadbay_add_leads_to_campaign"],"failure_modes":["Skips the validation step — creates a campaign of unvetted leads from a freshly-created lens without giving the manager a chance to drop weak fits","Creates ONE campaign for all reps without asking about the split — the user explicitly mentioned per-rep distribution and the prompt should honor it","Pretends the backend supports cross-user assignment — campaigns are owned by the caller (creator-scoped). Surface this honestly instead of fabricating an assignment model","Asks ALL clarifying questions inline before tool calls — instead, run the lens refinement loop with `leadbay_refine_prompt` which handles the clarification protocol natively"]}, - leadbay_top_accounts_to_activate: {"name":"leadbay_top_accounts_to_activate","short_description":"Build a ranked account-conquest plan from Leadbay data — the accounts worth\nactivating, each with a motif, a pitch and a checklist, ranked by the\nstrongest Leadbay signal. Every figure carries its source, and anything\nLeadbay can't measure is shown as OMITTED rather than estimated. Uses\n`leadbay_bulk_qualify_leads` and `leadbay_enrich_titles`. Trigger on\n\"top 50 accounts to activate\", \"who should we go after\".\n","arguments":[{"name":"count","description":"Optional: how many accounts the plan should hold (default 50).","required":false},{"name":"territory","description":"Optional: restrict the plan to a territory (e.g. 'Indre-et-Loire', 'Région Ouest'). Sets geography on the Discover lens.","required":false}],"expected_calls":["leadbay_account_status","leadbay_get_qualification_questions","leadbay_pull_leads","leadbay_pull_followups","leadbay_bulk_qualify_leads","leadbay_qualify_status","leadbay_scan_portfolio_signals","leadbay_enrich_titles","leadbay_bulk_enrich_status","leadbay_account_history","leadbay_artifact_kit","leadbay_new_lens","leadbay_adjust_audience"],"failure_modes":["Invents, estimates or proxies a revenue-realized figure — the single worst failure. Leadbay does not hold what an account buys, and headcount, sector and lead score are NOT proxies for it.","Sorts by cash-to-capture, synthesizing a revenue figure per account purely to make that ranking work. Leadbay has no revenue data: rank by the Leadbay signal, say so in the header, and never invent the key.","Emits € figures with no provenance class, so modelled numbers read as measured fact in front of a paying client.","Skips the PROVENANCE LEDGER, or drops un-sourceable fields from it instead of rendering them as OMITTED — which hides the gap.","Fabricates registry/TAM counts (France or regional company counts) instead of querying the registry or marking the figure NOT COMPUTED. Leadbay does not proxy SIRENE.","Invents the five qualification questions from this prompt's own recommendations instead of reading the org's actual questions via leadbay_get_qualification_questions.","Leaves the deck's live layer dead — qualification and enrichment handles never wired in, so the pills and contacts stay empty while the deck still looks finished.","Invents lead ids to make the qualification pills appear populated.","Fabricates a plausible-sounding signal ('just won a public tender') for an account whose signals were never read. No signal read means an explicit dash.","Assigns a motif outside the closed set of six, or assigns SAUVETAGE / PLAN DE COMPTE / MONTÉE EN GAMME / RÉVEIL from a lead score or sector when order history was never available.","Labels Monitor membership as 'is a client' — Monitor is a Leadbay view whose membership is decided by lens scoring, not by whether the company ever bought anything.","Launches paid enrichment on the whole plan without consent. Asking for a plan is not authorization to spend on 50 accounts.","Re-launches enrichment from inside the built deck when a bulk handle already exists this session — double-spends the user's quota.","Forces the interactive deck without offering it first, or ships the deck INSTEAD of a chat answer that stands on its own.","Refuses the task because revenue data is missing, instead of delivering the conquest plan and naming what a cash-ranked version would need.","Ends the turn without a ranked list of real accounts — gating the whole plan on a NON-blocking question (the territory, a missing lens, or a 3-vs-5 qualification-question gap) so the user gets a plan-of-a-plan. Only an unresolvable company-identity mismatch may stop delivery; every other open question rides alongside the delivered plan.","Stops after the discovery contact preview to wait for enrichment consent, delivering no plan that turn — the ranked plan ships first; the paid reveal is offered alongside it.","Renders a contact channel enrichment never returned (e.g. a phone link when only email was approved and revealed) instead of showing the returned channels and marking the rest omitted."]}, + leadbay_setup_team_prospecting: {"name":"leadbay_setup_team_prospecting","short_description":"Manager-led prospecting setup: conversationally turn a natural-language\naudience ask into a Leadbay lens, validate the candidate leads, and\npersist them as one or more named campaigns the rep(s) can work\nthrough. Closes #3630 US3 end-to-end (within the current\ncreator-scoped campaign visibility model).\n","arguments":[{"name":"audience","description":"Natural-language audience description (e.g. 'plumbing companies with 10-50 employees in Seine-Maritime'). The lens-creation step (`leadbay_refine_prompt` → `leadbay_create_lens`) interprets it. A country name is not a scope here — this workspace already covers exactly one country, so drop it and keep the rest of the description; a DIFFERENT country cannot be targeted at all.","required":true},{"name":"rep_split","description":"Optional: how to split the validated leads into per-rep campaigns. Free text — e.g. 'split by city' or 'one campaign per rep: John gets Tulsa, Sarah gets OKC'. Splitting by country is not a split — the workspace is single-country.","required":false}],"expected_calls":["leadbay_refine_prompt","leadbay_create_lens","leadbay_promote_lens","leadbay_pull_leads","leadbay_research_lead_by_id","leadbay_create_campaign","leadbay_add_leads_to_campaign"],"failure_modes":["Skips the validation step — creates a campaign of unvetted leads from a freshly-created lens without giving the manager a chance to drop weak fits","Creates ONE campaign for all reps without asking about the split — the user explicitly mentioned per-rep distribution and the prompt should honor it","Pretends the backend supports cross-user assignment — campaigns are owned by the caller (creator-scoped). Surface this honestly instead of fabricating an assignment model","Asks ALL clarifying questions inline before tool calls — instead, run the lens refinement loop with `leadbay_refine_prompt` which handles the clarification protocol natively"]}, + leadbay_top_accounts_to_activate: {"name":"leadbay_top_accounts_to_activate","short_description":"Build a ranked account-conquest plan from Leadbay data — the accounts worth\nactivating, each with a motif, a pitch and a checklist, ranked by the\nstrongest Leadbay signal. Every figure carries its source, and anything\nLeadbay can't measure is shown as OMITTED rather than estimated. Uses\n`leadbay_bulk_qualify_leads` and `leadbay_enrich_titles`. Trigger on\n\"top 50 accounts to activate\", \"who should we go after\".\n","arguments":[{"name":"count","description":"Optional: how many accounts the plan should hold (default 50).","required":false},{"name":"territory","description":"Optional: restrict the plan to a territory (e.g. 'Indre-et-Loire', 'Région Ouest'). Sets geography on the Discover lens. A country is not a territory — this workspace already covers exactly one country.","required":false}],"expected_calls":["leadbay_account_status","leadbay_get_qualification_questions","leadbay_pull_leads","leadbay_pull_followups","leadbay_bulk_qualify_leads","leadbay_qualify_status","leadbay_scan_portfolio_signals","leadbay_enrich_titles","leadbay_bulk_enrich_status","leadbay_account_history","leadbay_artifact_kit","leadbay_new_lens","leadbay_adjust_audience"],"failure_modes":["Invents, estimates or proxies a revenue-realized figure — the single worst failure. Leadbay does not hold what an account buys, and headcount, sector and lead score are NOT proxies for it.","Sorts by cash-to-capture, synthesizing a revenue figure per account purely to make that ranking work. Leadbay has no revenue data: rank by the Leadbay signal, say so in the header, and never invent the key.","Emits € figures with no provenance class, so modelled numbers read as measured fact in front of a paying client.","Skips the PROVENANCE LEDGER, or drops un-sourceable fields from it instead of rendering them as OMITTED — which hides the gap.","Fabricates registry/TAM counts (France or regional company counts) instead of querying the registry or marking the figure NOT COMPUTED. Leadbay does not proxy SIRENE.","Invents the five qualification questions from this prompt's own recommendations instead of reading the org's actual questions via leadbay_get_qualification_questions.","Leaves the deck's live layer dead — qualification and enrichment handles never wired in, so the pills and contacts stay empty while the deck still looks finished.","Invents lead ids to make the qualification pills appear populated.","Fabricates a plausible-sounding signal ('just won a public tender') for an account whose signals were never read. No signal read means an explicit dash.","Assigns a motif outside the closed set of six, or assigns SAUVETAGE / PLAN DE COMPTE / MONTÉE EN GAMME / RÉVEIL from a lead score or sector when order history was never available.","Labels Monitor membership as 'is a client' — Monitor is a Leadbay view whose membership is decided by lens scoring, not by whether the company ever bought anything.","Launches paid enrichment on the whole plan without consent. Asking for a plan is not authorization to spend on 50 accounts.","Re-launches enrichment from inside the built deck when a bulk handle already exists this session — double-spends the user's quota.","Forces the interactive deck without offering it first, or ships the deck INSTEAD of a chat answer that stands on its own.","Refuses the task because revenue data is missing, instead of delivering the conquest plan and naming what a cash-ranked version would need.","Ends the turn without a ranked list of real accounts — gating the whole plan on a NON-blocking question (a MISSING territory, a missing lens, or a 3-vs-5 qualification-question gap) so the user gets a plan-of-a-plan. Only two things may stop delivery: an unresolvable company-identity mismatch, and a territory naming a foreign or supra-national scope. Every other open question rides alongside the delivered plan.","Stops after the discovery contact preview to wait for enrichment consent, delivering no plan that turn — the ranked plan ships first; the paid reveal is offered alongside it.","Renders a contact channel enrichment never returned (e.g. a phone link when only email was approved and revealed) instead of showing the returned channels and marking the rest omitted."]}, leadbay_work_campaign: {"name":"leadbay_work_campaign","short_description":"Work a campaign as a real outreach session: pick the campaign,\nassess what the user has (phones / emails / coords), then PROPOSE\nthe right session mode (call sheet, email sheet, enrich titles\nfirst, map). After they pick, render — and as they dictate\noutcomes per lead, record both note + epilogue via\n`leadbay_report_outreach` in one round trip.\n","arguments":[{"name":"campaign","description":"Campaign name (fuzzy match against your own campaigns) or campaign UUID. Omit to list and pick interactively.","required":false},{"name":"mode","description":"Optional: skip the readiness-assessment proposal and jump directly into 'call_sheet' / 'email_sheet' / 'map' / 'enrich_first'. Omit (recommended) and let the prompt propose based on the data.","required":false}],"expected_calls":["leadbay_list_campaigns","leadbay_campaign_call_sheet","leadbay_enrich_titles","leadbay_report_outreach"],"failure_modes":["Renders the call sheet immediately without proposing the right mode — if 60% of leads have no contacts, calling is futile; enrich first. Always assess `readiness` first.","Auto-renders the map widget without asking — maps are intrusive when the user just wants to scroll a list. Map mode is a proposed option, not a default.","Proposes map mode after the user has previously said they don't like maps — check conversation memory before adding 'View on a map' to the options list.","Calls `leadbay_campaign_progression` instead of `leadbay_campaign_call_sheet` — progression has counts but no phones / LinkedIn / call-ready data; the user can't actually dial from progression rows.","Renders contacts WITHOUT making the phone number a `[bare](tel:URL)` link — on mobile that breaks one-tap calling, which is the whole point of the cheat sheet.","Records outreach WITHOUT epilogue_status — leaves the lead's pipeline state unchanged; the rep then sees the same lead surfaced again next session.","Records outreach WITHOUT verification — verification.source/ref is REQUIRED. For calls, pass `{source: 'user_confirmed', ref: }`.","Loops through ALL leads in a 50-lead campaign before recording any outreach — the call-then-record loop must be per-lead, not batched."]}, } as const; diff --git a/packages/mcp/src/prompts.ts b/packages/mcp/src/prompts.ts index 0bbe29c6..f7987c81 100644 --- a/packages/mcp/src/prompts.ts +++ b/packages/mcp/src/prompts.ts @@ -67,30 +67,40 @@ function substitutePlaceholders( return out; } +/** + * `prompts/list` argument metadata, sourced from the generated file. + * + * These used to be hand-copied into the catalog below, and they drifted: seven + * argument descriptions no longer matched their templates, including every one + * carrying the single-country warning (product#3951). The audit asserted the + * GENERATED text and passed, while `prompts/list` served the stale hand-written + * copy — so a guard the audit proved existed was never actually delivered to a + * client. Reading them here makes the .md.tmpl frontmatter the only place an + * argument description is written. + */ +function promptArguments(name: keyof typeof PROMPT_META): PromptArgument[] { + return PROMPT_META[name].arguments.map( + (argument: { name: string; description: string; required: boolean }) => ({ ...argument }) + ); +} + const CATALOG: CatalogEntry[] = [ { name: "leadbay_daily_check_in", description: PROMPT_META.leadbay_daily_check_in.short_description, - arguments: [], + arguments: promptArguments("leadbay_daily_check_in"), render: () => [userMessage(leadbay_daily_check_in)], }, { name: "leadbay_prospecting_overview", description: PROMPT_META.leadbay_prospecting_overview.short_description, - arguments: [], + arguments: promptArguments("leadbay_prospecting_overview"), render: () => [userMessage(leadbay_prospecting_overview)], }, { name: "leadbay_research_a_domain", description: PROMPT_META.leadbay_research_a_domain.short_description, - arguments: [ - { - name: "domain", - description: - "Company name or domain (for example 'Acme Corporation' or 'acme.com'). The legacy argument key remains `domain` for client compatibility.", - required: true, - }, - ], + arguments: promptArguments("leadbay_research_a_domain"), render: (args) => [ userMessage( substitutePlaceholders(leadbay_research_a_domain, { @@ -102,20 +112,7 @@ const CATALOG: CatalogEntry[] = [ { name: "leadbay_import_file", description: PROMPT_META.leadbay_import_file.short_description, - arguments: [ - { - name: "file", - description: - "Path or user-visible name of the CSV/file to import. If omitted, use the file the user attached or referenced.", - required: false, - }, - { - name: "instruction", - description: - "Additional user goal, e.g. 'then qualify the leads', 'preserve owner phone as a custom field', or 'only import restaurants in Manhattan'.", - required: false, - }, - ], + arguments: promptArguments("leadbay_import_file"), render: (args) => [ userMessage( @@ -131,14 +128,7 @@ const CATALOG: CatalogEntry[] = [ { name: "leadbay_refine_audience", description: PROMPT_META.leadbay_refine_audience.short_description, - arguments: [ - { - name: "instruction", - description: - "The refinement (e.g. 'focus on hospitals running their own IT'). Set to plain English.", - required: true, - }, - ], + arguments: promptArguments("leadbay_refine_audience"), render: (args) => [ userMessage( substitutePlaceholders(leadbay_refine_audience, { @@ -150,19 +140,7 @@ const CATALOG: CatalogEntry[] = [ { name: "leadbay_log_outreach", description: PROMPT_META.leadbay_log_outreach.short_description, - arguments: [ - { - name: "lead_id", - description: "The lead UUID. Get it from leadbay_pull_leads or leadbay_research_lead_by_id.", - required: true, - }, - { - name: "summary", - description: - "1-2 sentences describing what I did (e.g. 'Sent intro email to CTO citing recent Hornsea contract').", - required: true, - }, - ], + arguments: promptArguments("leadbay_log_outreach"), render: (args) => [ userMessage( substitutePlaceholders(leadbay_log_outreach, { @@ -175,20 +153,7 @@ const CATALOG: CatalogEntry[] = [ { name: "leadbay_plan_tour_in_city", description: PROMPT_META.leadbay_plan_tour_in_city.short_description, - arguments: [ - { - name: "city", - description: - "City or region the user is visiting (e.g. 'Limoges', 'Bay Area'). Used as the geo filter for both Monitor and Discover lookups.", - required: true, - }, - { - name: "date", - description: - "When the visit is (e.g. 'May 24', 'next Thursday'). Surfaced in the outreach drafts as 'I'll be in on '.", - required: false, - }, - ], + arguments: promptArguments("leadbay_plan_tour_in_city"), render: (args) => [ userMessage( substitutePlaceholders(leadbay_plan_tour_in_city, { @@ -202,32 +167,7 @@ const CATALOG: CatalogEntry[] = [ { name: "leadbay_build_campaign", description: PROMPT_META.leadbay_build_campaign.short_description, - arguments: [ - { - name: "audience", - description: - "Optional: a fresh audience to target (e.g. 'dental clinics in Texas'). Omit to build from your ACTIVE lens — the default.", - required: false, - }, - { - name: "campaign_name", - description: - "Optional: a name for the campaign. Omit and one is derived from the lens/audience + date (or the backend AI-names it).", - required: false, - }, - { - name: "count", - description: - "Optional: how many fully-actionable leads to build (default 20). The loop keeps discovering, qualifying and enriching until this many in-ICP leads each have a reachable target-title contact — or the lens is exhausted. Higher counts take longer and consume more quota.", - required: false, - }, - { - name: "job_titles", - description: - "Optional: the exact buyer job titles to enrich, comma-separated (e.g. 'VP Sales, Head of Growth, Director of Business Development'). Omit and the buyer persona is derived from what you sell. A lead only counts toward the target when it has a reachable contact matching one of these titles.", - required: false, - }, - ], + arguments: promptArguments("leadbay_build_campaign"), render: (args) => { const n = args.count ?? "20"; return [ @@ -251,20 +191,7 @@ const CATALOG: CatalogEntry[] = [ { name: "leadbay_setup_team_prospecting", description: PROMPT_META.leadbay_setup_team_prospecting.short_description, - arguments: [ - { - name: "audience", - description: - "Natural-language audience description (e.g. 'plumbing companies with 10-50 employees in Seine-Maritime').", - required: true, - }, - { - name: "rep_split", - description: - "Optional: how to split validated leads into per-rep campaigns. Free text (e.g. 'split by city', 'one campaign per rep').", - required: false, - }, - ], + arguments: promptArguments("leadbay_setup_team_prospecting"), render: (args) => [ userMessage( substitutePlaceholders(leadbay_setup_team_prospecting, { @@ -279,20 +206,7 @@ const CATALOG: CatalogEntry[] = [ { name: "leadbay_work_campaign", description: PROMPT_META.leadbay_work_campaign.short_description, - arguments: [ - { - name: "campaign", - description: - "Campaign name (fuzzy match) or campaign UUID. Omit to list and pick interactively.", - required: false, - }, - { - name: "mode", - description: - "Optional: skip readiness proposal and jump to 'call_sheet', 'email_sheet', 'map', or 'enrich_first'. Omit to let the prompt propose based on campaign data.", - required: false, - }, - ], + arguments: promptArguments("leadbay_work_campaign"), render: (args) => [ userMessage( substitutePlaceholders(leadbay_work_campaign, { @@ -305,14 +219,7 @@ const CATALOG: CatalogEntry[] = [ { name: "leadbay_qualify_top_n", description: PROMPT_META.leadbay_qualify_top_n.short_description, - arguments: [ - { - name: "count", - description: - "How many leads to qualify (default 10, max 25). Higher counts may take 5+ minutes.", - required: false, - }, - ], + arguments: promptArguments("leadbay_qualify_top_n"), render: (args) => { const n = args.count ?? "10"; return [ @@ -327,20 +234,7 @@ const CATALOG: CatalogEntry[] = [ { name: "leadbay_top_accounts_to_activate", description: PROMPT_META.leadbay_top_accounts_to_activate.short_description, - arguments: [ - { - name: "count", - description: - "Optional: how many accounts the plan should hold (default 50).", - required: false, - }, - { - name: "territory", - description: - "Optional: restrict the plan to a territory (e.g. 'Indre-et-Loire'). Sets geography on the Discover lens via `locations`.", - required: false, - }, - ], + arguments: promptArguments("leadbay_top_accounts_to_activate"), render: (args) => { const n = args.count ?? "50"; return [ @@ -361,7 +255,7 @@ const CATALOG: CatalogEntry[] = [ // their own onboarding defeats the point. name: "leadbay_getting_started", description: PROMPT_META.leadbay_getting_started.short_description, - arguments: [], + arguments: promptArguments("leadbay_getting_started"), render: () => [userMessage(leadbay_getting_started)], }, ]; diff --git a/packages/mcp/test/audit/country-precall-rules.test.ts b/packages/mcp/test/audit/country-precall-rules.test.ts new file mode 100644 index 00000000..3256694e --- /dev/null +++ b/packages/mcp/test/audit/country-precall-rules.test.ts @@ -0,0 +1,87 @@ +/** + * The runtime recovery only fires when the agent SENDS a country. The + * descriptions tell it not to — so the rules have to live there too. + * + * This is the gap a whole review round was spent on. Every fix to the + * `COUNTRY_LEVEL_LOCATION` envelope — inherited lens geography, the persisted + * Monitor filter, the taxonomy-lookup override — is reachable only on the + * REJECTION path. But the shared rule instructs the agent to recognise a + * whole-country ask and omit the geo argument BEFORE calling, which is the + * normal path and the one a well-behaved agent takes. On that path the guard + * never fires and the envelope is never seen: + * + * "make this healthcare nationwide" → adjust_audience({sectors:["Healthcare"]}) + * + * carries no country at all, merges into a Paris-scoped lens, and returns Paris + * healthcare with nothing anywhere having said otherwise. + * + * So each rule is pinned on the SURFACE the agent reads before it calls. + */ +import { describe, it, expect } from "vitest"; + +import * as Generated from "@leadbay/core/dist/tool-descriptions.generated.js"; +import * as Prompts from "../../src/prompts.generated.js"; + +const desc = (name: string) => (Generated as unknown as Record)[name]; + +describe("audit: pre-call country rules live in the descriptions", () => { + it("pull_followups says a whole-workspace read also needs filtered:false", () => { + const body = desc("leadbay_pull_followups"); + expect(body, "leadbay_pull_followups description not found").toBeTruthy(); + expect( + body, + "omitting `city` does not widen this tool — `filtered` defaults to true and a persisted filter still applies" + ).toMatch(/filtered:false/); + // …and it must NOT stop there: with other criteria requested, filtered:false + // discards them, so the re-send route has to be named in the same breath. + expect(body).toMatch(/re-send them in `set_filter`/); + }); + + it("the followup_check_in prompt carries the same rule", () => { + // The description is what a tool-calling agent reads; the prompt is what an + // orchestrated session reads. The failure is reachable from both. + const body = Prompts.leadbay_followup_check_in; + expect(body).toMatch(/filtered:false/); + expect(body).toMatch(/active_filters/); + }); + + it.each(["leadbay_adjust_audience", "leadbay_new_lens"])( + "%s warns that existing lens geography survives", + (name) => { + const body = desc(name); + expect(body, `${name} description not found`).toBeTruthy(); + // The resource that can actually answer it, named on the surface that + // sends the agent looking. + expect( + body, + `${name} must point at the only place a lens's location_ids are readable` + ).toMatch(/lens:\/\//); + expect( + body, + `${name} must say that omitting locations is not the same as having none` + ).toMatch(/(MERGE|inherits)/i); + } + ); + + it("adjust_audience names the merge specifically", () => { + // new_lens inherits via cloning, adjust_audience via merging. Different + // mechanisms, and an agent needs the one that applies to the call it makes. + expect(desc("leadbay_adjust_audience")).toMatch(/Location criteria MERGE/i); + expect(desc("leadbay_new_lens")).toMatch(/CLONE/); + }); + + it("list_locations overrides the omit-and-claim-coverage recovery", () => { + const body = desc("leadbay_list_locations"); + expect( + body, + "`q` is required here and an empty lookup is not workspace-wide coverage" + ).toMatch(/does NOT apply to this tool/i); + expect(body).toMatch(/REQUIRED/); + }); + + it("the tour keeps its own override — this audit must not have loosened it", () => { + // tour_plan was the first tool to override the shared recovery. Re-pinned + // here so a later pass cannot quietly fold it back into the generic rule. + expect(desc("leadbay_tour_plan")).toMatch(/do NOT omit/i); + }); +}); diff --git a/packages/mcp/test/audit/eval-forbidden-calls-armed.test.ts b/packages/mcp/test/audit/eval-forbidden-calls-armed.test.ts new file mode 100644 index 00000000..50c73700 --- /dev/null +++ b/packages/mcp/test/audit/eval-forbidden-calls-armed.test.ts @@ -0,0 +1,78 @@ +/** + * `forbidden_calls` must be enforced BEFORE the live session, not asserted after it. + * + * `scenarios.eval.ts` iterates the recorded calls once `runSessionLive` has + * returned. On a live run that is an assertion about a mutation it did not + * prevent: the live server enables write tools and points at the real API, so a + * regression calling `leadbay_new_lens` / `leadbay_adjust_audience` / + * `leadbay_update_lens_filter` had already written to the test tenant by the + * time the check ran. A scenario whose whole claim is "this must mutate + * nothing" cannot be the thing that mutates it. + * + * This is the same lesson the no-spend switch learned earlier — it was moved to + * the HTTP boundary after a post-hoc check let a real charge through — so it is + * pinned the same way, and separately, so neither can regress on its own. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const RUNNER = readFileSync(resolve(__dirname, "../eval/scenarios.eval.ts"), "utf8"); +const MCP_SERVER = readFileSync( + resolve(__dirname, "../eval/helpers/live-mcp-server.ts"), + "utf8", +); + +describe("audit: forbidden_calls are armed before the session", () => { + it("the runner exports the denylist to the server BEFORE runSessionLive", () => { + const armIdx = RUNNER.indexOf("LEADBAY_EVAL_FORBIDDEN_TOOLS"); + const runIdx = RUNNER.indexOf("await runSessionLive"); + expect( + armIdx, + "scenarios.eval.ts must pass mission.forbidden_calls to the live server" + ).toBeGreaterThan(-1); + expect( + armIdx, + "the denylist must be armed BEFORE the session, or it only reports mutations that already happened" + ).toBeLessThan(runIdx); + }); + + it("the runner clears it between scenarios", () => { + // A leaked denylist would silently disarm write tools for every later + // scenario in the same process, and those scenarios would still pass. + expect(RUNNER).toMatch(/delete process\.env\.LEADBAY_EVAL_FORBIDDEN_TOOLS/); + }); + + it("the live server refuses the call before any HTTP", () => { + expect(MCP_SERVER).toMatch(/LEADBAY_EVAL_FORBIDDEN_TOOLS/); + expect(MCP_SERVER).toMatch(/EVAL_FORBIDDEN_CALL/); + // It must replace `execute`, not filter the catalog: an agent cannot call a + // tool it cannot see, so hiding it would make every forbidden_calls + // assertion vacuously true. + expect( + MCP_SERVER, + "the forbidden tool must stay listed and throw on execute, not be removed from the catalog" + ).toMatch(/tool\.execute = async/); + }); + + it("a name that arms nothing is a hard error", () => { + // Otherwise a rename or typo arms nothing, and the scenario reads as a pass + // while protecting the tenant from nothing at all. + expect(MCP_SERVER).toMatch(/unmatched/); + expect(MCP_SERVER).toMatch(/unknown tools/); + }); + + it("a real but UNEXPOSED tool is rejected too, with its own reason", () => { + // The sharper half. buildServer runs with includeAdvanced:false, so a + // granular name like leadbay_update_lens_filter can never be offered to the + // agent — arming against the full catalog marked it protected and left the + // forbidden_calls assertion vacuous. The two author errors need different + // fixes, so they are reported separately rather than as "unknown tool". + expect(MCP_SERVER).toMatch(/does not expose/); + expect(MCP_SERVER).toMatch(/includeAdvanced:false/); + expect( + MCP_SERVER, + "the denylist must be armed from the catalog the server actually exposes" + ).toMatch(/exposedCatalog/); + }); +}); diff --git a/packages/mcp/test/audit/eval-region-not-invented.test.ts b/packages/mcp/test/audit/eval-region-not-invented.test.ts new file mode 100644 index 00000000..39b2868e --- /dev/null +++ b/packages/mcp/test/audit/eval-region-not-invented.test.ts @@ -0,0 +1,68 @@ +/** + * The live eval harness must not invent a region for a custom endpoint. + * + * `LEADBAY_BASE_URL` points a run at staging. The region pin alongside it is + * load-bearing — without it the client derives "custom" from an unrecognised + * host and the single-country guard classifies every country as + * `country_indeterminate` instead of home vs foreign (product#3951), so the run + * exercises a different branch than the one under test. + * + * But pinning and GUESSING are different things. The harness used to default + * the region to "us" and pass that explicitly, so a staging tenant nobody had + * identified was asserted to hold United States companies only, and the eval + * reported `_meta.region: "us"` for it. That is the harness committing the + * exact confidently-wrong-answer failure the scenarios exist to catch — and + * because the judge ledger carries `_meta.region`, it would have been read as + * evidence the agent got the region right. + * + * Undefined is the honest value: the client still maps a known regional URL to + * us/fr, and anything else becomes "custom" (same rule as client.ts:103-106). + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { LeadbayClient } from "@leadbay/core"; + +const MCP_SERVER = readFileSync( + resolve(__dirname, "../eval/helpers/live-mcp-server.ts"), + "utf8", +); + +describe("audit: the eval harness never guesses a region", () => { + it("does not default LEADBAY_REGION to a real region", () => { + expect( + MCP_SERVER, + 'live-mcp-server must not fall back to "us" — an unpinned custom endpoint has no known country' + ).not.toMatch(/LEADBAY_REGION\s*\?\?\s*["']us["']/); + }); + + it("passes the pin through only when it was actually supplied", () => { + expect(MCP_SERVER).toMatch(/pinnedRegion/); + // The constructor gets the possibly-undefined pin, not a coerced string. + expect(MCP_SERVER).toMatch(/new LeadbayClient\(baseUrl, token, pinnedRegion\)/); + }); + + it("reports the region the client actually resolved", () => { + // Reporting the requested region rather than the resolved one would put the + // guess back, one layer further out. + expect(MCP_SERVER).toMatch(/const region = client\.region/); + }); +}); + +describe("the derivation the harness now relies on", () => { + // Pinning the client behaviour too: the audit above is a source grep, and a + // grep cannot tell a correct derivation from a broken one. + it("derives us/fr from the known regional URLs", () => { + expect(new LeadbayClient("https://api-us.leadbay.app", "t").region).toBe("us"); + expect(new LeadbayClient("https://api-fr.leadbay.app", "t").region).toBe("fr"); + }); + + it("derives custom from an unrecognised staging host", () => { + expect(new LeadbayClient("https://api-staging.leadbay.app", "t").region).toBe("custom"); + }); + + it("an explicit pin still wins over the URL", () => { + expect(new LeadbayClient("https://api-staging.leadbay.app", "t", "fr").region).toBe("fr"); + }); +}); diff --git a/packages/mcp/test/audit/prompt-arguments-served.test.ts b/packages/mcp/test/audit/prompt-arguments-served.test.ts new file mode 100644 index 00000000..7accd0f5 --- /dev/null +++ b/packages/mcp/test/audit/prompt-arguments-served.test.ts @@ -0,0 +1,83 @@ +/** + * `prompts/list` must serve the argument descriptions the templates declare. + * + * The server's CATALOG (packages/mcp/src/prompts.ts) used to hand-copy every + * argument description. Seven of them had drifted from their `.md.tmpl` + * frontmatter by the time this file was written — including `city`, + * `territory`, `audience` and `rep_split`, the four that carry the + * single-country warning (product#3951). + * + * That drift is invisible in the worst possible way. The single-country audit + * asserts `PROMPT_META..arguments`, which is the GENERATED text, and it + * passed — while a client calling `prompts/list` received the stale hand-written + * copy with no warning in it. An audit proving a guard exists somewhere it is + * never delivered is worse than no audit: it retires the question. + * + * So this file asserts the two agree, on the served surface, for every prompt + * and every argument — not just the ones that happen to matter today. + */ +import { describe, it, expect } from "vitest"; + +import { PROMPT_META } from "../../src/prompts.generated.js"; +import { listPrompts } from "../../src/prompts.js"; + +const SERVED = listPrompts(); + +describe("prompts/list serves the generated argument metadata", () => { + it.each(SERVED.map((p) => p.name))("%s arguments match PROMPT_META exactly", (name) => { + const served = SERVED.find((p) => p.name === name); + const meta = (PROMPT_META as Record)[name]; + expect(meta, `${name} is served by prompts/list but absent from PROMPT_META`).toBeTruthy(); + + // Compared as plain data so a reordering or a single edited word fails. + expect( + JSON.parse(JSON.stringify(served?.arguments ?? [])), + `${name}: prompts/list arguments differ from the generated frontmatter. Edit the .md.tmpl — the catalog must not hand-copy descriptions.` + ).toEqual(JSON.parse(JSON.stringify(meta?.arguments ?? []))); + }); +}); + +/** + * The four free-text arguments a user can phrase as a whole country. Each one + * reaches the agent through `prompts/list` BEFORE any prompt body is fetched, + * so the warning has to survive on that surface specifically. + */ +const COUNTRY_BEARING_ARGUMENTS: ReadonlyArray<[string, string]> = [ + ["leadbay_plan_tour_in_city", "city"], + ["leadbay_top_accounts_to_activate", "territory"], + ["leadbay_setup_team_prospecting", "audience"], + ["leadbay_setup_team_prospecting", "rep_split"], +]; + +describe("the single-country warning survives to the served surface", () => { + it.each(COUNTRY_BEARING_ARGUMENTS)( + "%s.%s tells the agent a country is not a place", + (promptName, argName) => { + const served = SERVED.find((p) => p.name === promptName); + expect(served, `${promptName} is not registered in the server CATALOG`).toBeTruthy(); + + const description = served?.arguments?.find((a) => a.name === argName)?.description; + expect( + description, + `${promptName} does not expose a "${argName}" argument through prompts/list` + ).toBeTruthy(); + + // Not a fixed sentence — the wording differs per argument by design. What + // must hold is that the country is named as NOT a location value. + expect( + description, + `${promptName}.${argName} must say a country is not a usable scope here (product#3951)` + ).toMatch(/countr(y|ies)/i); + } + ); + + it("the tour's city argument keeps its ask-do-not-omit exception", () => { + // tour_plan is the one geo tool whose recovery is NOT "omit the argument": + // a city-less tour is arbitrary whole-workspace leads presented as an + // itinerary. Pinned on the SERVED surface, not just the generated one. + const city = SERVED.find((p) => p.name === "leadbay_plan_tour_in_city") + ?.arguments?.find((a) => a.name === "city")?.description; + expect(city).toMatch(/do NOT omit/i); + expect(city).not.toMatch(/means NO geo filter/i); + }); +}); diff --git a/packages/mcp/test/audit/prompt-country-empty-remainder.test.ts b/packages/mcp/test/audit/prompt-country-empty-remainder.test.ts new file mode 100644 index 00000000..5fd42f19 --- /dev/null +++ b/packages/mcp/test/audit/prompt-country-empty-remainder.test.ts @@ -0,0 +1,59 @@ +/** + * A prompt gate that STRIPS a country must handle stripping to nothing. + * + * The strip-don't-stop rule exists because "hospitals running their own IT + * nationwide" is a refinement about hospitals, and killing the run over the + * redundant country threw away the request. But the rule has an edge it did not + * cover: when the country IS the whole argument, the sanitized text is empty — + * and "drop it and continue" then walks into the mutation this ticket forbids. + * + * On `leadbay_setup_team_prospecting` that path is concrete and expensive: + * `refine_prompt({user_prompt: ""})` overwrites the user's refinement prompt + * with nothing, and `create_lens` + `promote_lens` then persist AND ACTIVATE a + * scopeless lens, to express a scope the workspace already has. + * + * `leadbay_refine_audience` already handles this ("Nothing remains → STOP + * HERE"); both are pinned here so neither loses it to a wording pass. + */ +import { describe, it, expect } from "vitest"; + +import * as Prompts from "../../src/prompts.generated.js"; + +const PROMPTS_THAT_STRIP = ["leadbay_refine_audience", "leadbay_setup_team_prospecting"] as const; + +describe("audit: stripping the country to nothing is a stop, not a continue", () => { + it.each(PROMPTS_THAT_STRIP)("%s stops when nothing survives the strip", (name) => { + const body = (Prompts as Record)[name]; + expect(body, `${name} is missing from prompts.generated.ts`).toBeTruthy(); + + // It must consider the empty-remainder case at all… + expect( + body, + `${name} tells the agent to strip the home country; it must also say what happens when that leaves nothing` + ).toMatch(/leaves? NOTHING|Nothing remains/i); + + // …and the answer must be to write nothing, not to carry on. + expect( + body, + `${name} must forbid the write when the country was the entire input` + ).toMatch(/(STOP HERE|Write nothing at all|Call\s+NOTHING)/i); + }); + + it("setup_team_prospecting names the specific calls it must not make", () => { + // A generic "stop" is easy to talk past. Naming the three calls that would + // fire is what makes the instruction checkable by the agent reading it. + const body = Prompts.leadbay_setup_team_prospecting; + expect(body).toMatch(/leadbay_refine_prompt\(\{user_prompt: ""\}\)/); + expect(body).toMatch(/leadbay_create_lens/); + expect(body).toMatch(/leadbay_promote_lens/); + // And it must say what to ask for instead of just refusing. + expect(body).toMatch(/sector, size, or sub-country criterion/i); + }); + + it("the rep_split argument gets the same treatment", () => { + // rep_split is the second free-text ingress and reaches PHASE 3 campaigns. + expect(Prompts.leadbay_setup_team_prospecting).toMatch( + /sanitized split is empty, there is no split to make/i + ); + }); +}); diff --git a/packages/mcp/test/audit/single-country-rule.test.ts b/packages/mcp/test/audit/single-country-rule.test.ts new file mode 100644 index 00000000..a529adc9 --- /dev/null +++ b/packages/mcp/test/audit/single-country-rule.test.ts @@ -0,0 +1,543 @@ +/** + * Regression audit for the single-country-universe rule (product#3951). + * + * The invariant: every tool that accepts a location argument, and every prompt + * that routes geographic intent, must carry the rule that a country name is + * never a location filter — because each Leadbay backend serves exactly ONE + * country, so whole-country intent means omitting the filter entirely. + * + * History (2026-08-02 E2E acceptance eval): 3/3 independent agent sessions + * passed a country label. The root cause was NOT a missing rule — it was a + * CONTRADICTING one. The shipped descriptions actively instructed it: + * followups-map.md.tmpl told the agent to "pass any place name there: states…, + * countries ("France", "United States")" and claimed "/geo/search indexes all + * levels — level 4 (state), level 2 (country), level 5 (city)". The agents were + * following the guidance. Meanwhile the admin-area index has no country nodes + * (product#3885), so "France" trigram-matched the commune of Francs and one FR + * session burned six variants inside that invisible fence. + * + * So this audit has two halves, and the second is the load-bearing one: + * 1. the rule is PRESENT in every location-accepting surface, and + * 2. no surface still tells the agent that a country is a valid geo value. + * + * Deterministic source-side audit — it does not exercise the LLM. The + * end-to-end half lives in test/eval/scenarios/country-scope/ behind EVAL=1. + * This catches regressions in the SOURCE prompts; the eval catches drift in the + * LLM's interpretation. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import * as Generated from "@leadbay/core/dist/tool-descriptions.generated.js"; +import { COUNTRY_LEVEL_LOCATION } from "@leadbay/core/dist/composite/_country-guard.js"; +import * as Prompts from "../../src/prompts.generated.js"; +import { PROMPT_META } from "../../src/prompts.generated.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const REPO_ROOT = resolve(__dirname, "..", "..", "..", ".."); +const SNIPPET_PATH = resolve( + REPO_ROOT, + "packages/promptforge/snippets/heuristics/single-country-universe.md" +); + +/** + * Every tool that accepts a location argument. If you add one, add it here — + * the audit refuses to let a new geo-accepting tool skip the rule. + * + * Intentionally EXCLUDED: leadbay_pull_leads / leadbay_extend_lens / + * leadbay_seed_candidates (no geo argument — geography lives on the lens), and + * leadbay_campaign_call_sheet / leadbay_research_lead_by_id / the tour_plan + * render block (they RENDER `location.country`, they never filter on it). + */ +const TOOLS_THAT_ACCEPT_LOCATIONS = [ + "leadbay_pull_followups", + "leadbay_followups_map", + "leadbay_tour_plan", + "leadbay_scan_portfolio_signals", + "leadbay_new_lens", + "leadbay_adjust_audience", + "leadbay_list_locations", + "leadbay_update_lens_filter", +] as const; + +/** + * Prompts that can receive geographic intent — either through a declared geo + * argument or through a free-text audience/instruction a user can phrase as a + * whole country. product#3951 names all five. + * + * `refine_audience` and `setup_team_prospecting` are here for a reason worth + * keeping: neither has a geo ARGUMENT, so a sweep that only followed geo params + * skips them — which is exactly what happened on the first pass of this PR. But + * "scope my lens to the whole US" lands in refine_audience, and + * setup_team_prospecting's free-text `audience` / `rep_split` can both carry a + * country. Absence of a geo param is not absence of geo intent. + */ +/** + * NOTE ON DELIVERY. These are read from `prompts.generated.js`, which is where + * promptforge emits every prompt body. Two of them — + * `leadbay_followup_check_in` and `leadbay_extend_my_lens` — are NOT registered + * in the server's CATALOG (packages/mcp/src/prompts.ts), so `prompts/get` + * cannot return them; they reach the agent as SKILL.md files under + * .claude-plugin instead. The rule really is delivered either way, and the + * skill files are generated from the same templates, so these assertions are + * not vacuous — but they prove the text exists, not that `prompts/get` serves + * it. A live eval discovered this by asking for one of them and getting + * "Unknown prompt". + */ +const PROMPTS_WITH_GEO_INTENT = [ + "leadbay_followup_check_in", + "leadbay_top_accounts_to_activate", + "leadbay_plan_tour_in_city", + "leadbay_refine_audience", + "leadbay_setup_team_prospecting", +] as const; + +const RULE = readFileSync(SNIPPET_PATH, "utf8"); +const HEADLINE = /a country name is NEVER a location filter/i; +const OMIT = /omit the geo argument/i; + +/** + * Phrasings that tell the agent a country IS a valid geo value. Each pattern + * carries the file:line it was written for. Deliberately NOT a bare + * /\bcountry\b/ — `location.country` is a real render field, "State or Country" + * is a legitimate place-card heading, and the list-locations legend has to be + * able to say country nodes are absent. + */ +const CONTRADICTIONS: ReadonlyArray<{ pattern: RegExp; origin: string }> = [ + { + // followups-map.md.tmpl:47, pull-followups.md.tmpl:71 — countries offered + // as a value to pass, with worked examples. + pattern: /countr(y|ies) \(`"/, + origin: 'countries offered as a passable value, e.g. `countries ("France", "United States")`', + }, + { + // followups-map:47, adjust-audience:54, new-lens:62, tour-plan:58 — + // "any/every admin level … country". The list-locations legend says admin + // DEPTH, not "admin level", so it does not trip this. + // NOTE this also trips on a hedged "every admin level BELOW country" — + // deliberately. The fix is to ENUMERATE the levels the argument accepts + // ("state / région / département / county / city") rather than to describe + // them relative to a level the agent must never use; naming country in the + // same breath as the accepted range is what made the agent try it. + pattern: /(any|every|all|across) (the )?admin levels?[^.\n]{0,140}\bcountry\b/i, + origin: '"resolves any/every admin level … country"', + }, + { + // followups-map:47 — country advertised as a searchable index level. + pattern: /level 2 \(country\)/i, + origin: '"level 2 (country)" advertised as searchable', + }, + { + // pull-followups.md.tmpl:13 — the trigger phrase itself. + pattern: //i, + origin: 'the trigger phrase "leads in "', + }, +]; + +/** Strip the rule's own text so it may say "never pass a country" freely. */ +const withoutRule = (text: string) => text.split(RULE).join(""); + +describe("audit: single-country-universe rule", () => { + it("the shared snippet states the rule, the mechanism and the recovery", () => { + expect(RULE).toMatch(HEADLINE); + expect(RULE).toMatch(OMIT); + // The concrete measured failures are what make the rule stick. + expect(RULE).toMatch(/Francs/); + expect(RULE).toMatch(/Statesboro/); + // The recovery step, keyed off the runtime error code. + expect(RULE).toContain(COUNTRY_LEVEL_LOCATION); + expect(RULE).toMatch(/do NOT retry with another spelling/i); + // The tiebreak an agent needs when the user names both. + expect(RULE).toMatch(/Keep the city, drop the country/i); + }); + + it("the snippet keeps foreign countries SEPARATE from the home country", () => { + // The accuracy regression this pins: an earlier draft told the agent to + // "pass no geo argument" for a country only OR a supra-national scope, + // lumping them with the home country. On a US workspace that turns "leads in + // France" — an UNSUPPORTED request — into an unfiltered run that returns US + // leads as though they answered it. Only the HOME country is equivalent to + // "no filter". + expect(RULE, "the rule must distinguish the three cases").toMatch( + /unsupported, not unfiltered/i + ); + expect(RULE, "it must forbid re-running unfiltered for a foreign country").toMatch( + /Do NOT re-run without the argument/i + ); + // And it must point at the runtime field that says WHICH case this is. + for (const kind of ["home_country", "foreign_country", "supranational", "country_indeterminate"]) { + expect(RULE, `the rule must name the ${kind} branch`).toContain(kind); + } + }); + + it("the snippet carries the EXCLUSION recovery, not just the include one", () => { + // Every recovery in this rule was written for an include, and each inverts on + // an exclude axis: "omit the argument" returns the very companies the user + // asked to remove. Both new_lens and adjust_audience carry this snippet and + // both accept exclude_locations, so a missing exclusion branch here can + // persist an audience that includes everything the user excluded. + expect(RULE, "the rule must key the recovery off the axis").toContain("axis"); + expect(RULE, "it must forbid the omit advice on an exclusion").toMatch( + /never "omit the argument"/i + ); + expect(RULE, "it must say what excluding the home country does").toMatch( + /would empty it/i + ); + expect(RULE, "it must say excluding a foreign country is a no-op").toMatch( + /no-op/i + ); + }); + + it("the snippet's omit recovery is conditional on nothing else being there", () => { + // The rule opens with "City AND country named? Keep the city, drop the + // country", then the include-axis bullet said "omit the geo argument" flat. + // An agent holding both resolves them the destructive way: on + // ["Paris", "France"] it drops the argument and re-runs unfiltered, losing + // the city the user actually asked for. The runtime hint now names the + // survivors (CountryHit.kept); the prose has to agree with it. + expect(RULE, "the omit instruction must be conditional").toMatch( + /only if nothing else was on it/i + ); + expect(RULE, "and it must say what to do when other values remain").toMatch( + /keep them/i + ); + }); + + it("the snippet blocks a write on ANY non-foreign exclusion, not just a bare one", () => { + // The write guidance said "if the country was the only scope: write + // nothing", which reads as permission the moment anything else is in the + // request. `new_lens({sectors: ["Healthcare"], exclude_locations: + // ["France"]})` on FR then gets the sectors written and the exclusion + // dropped — a French healthcare audience, the opposite of the ask — and it + // bypasses the runtime guard entirely, because the offending argument is + // gone before the call is made. Prose is the only thing standing in front + // of that path. + expect(RULE, "the write rule must cover the exclusion axis").toMatch( + /non-`foreign_country` `exclude`/ + ); + expect(RULE, "and must not be conditional on there being no other scope").toMatch( + /however much else came with it/i + ); + expect(RULE, "and must forbid the re-call, not just the argument").toMatch( + /no re-call in any form/i + ); + }); + + it("the runtime error code is the one the snippet teaches", () => { + // Imported from core rather than hardcoded: a rename there must not + // silently leave the prose teaching a recovery for an error that no longer + // exists while this audit still passes. + expect(COUNTRY_LEVEL_LOCATION).toBe("COUNTRY_LEVEL_LOCATION"); + }); + + it.each(TOOLS_THAT_ACCEPT_LOCATIONS)( + "%s carries the single-country rule", + (toolName) => { + const desc = (Generated as Record)[toolName]; + expect( + desc, + `tool ${toolName} is missing from the generated descriptions — add it, or remove it from TOOLS_THAT_ACCEPT_LOCATIONS in this audit if it no longer takes a location argument` + ).toBeTruthy(); + expect( + desc, + `${toolName} has lost the single-country rule; re-add {{include:heuristics/single-country-universe}} to its template (product#3951)` + ).toMatch(HEADLINE); + expect( + desc, + `${toolName} states the rule but not the recovery step; the include must carry ${COUNTRY_LEVEL_LOCATION}` + ).toContain(COUNTRY_LEVEL_LOCATION); + } + ); + + it.each(PROMPTS_WITH_GEO_INTENT)( + "%s carries the single-country rule", + (promptName) => { + const body = (Prompts as Record)[promptName]; + expect( + body, + `prompt ${promptName} is missing from prompts.generated.ts` + ).toBeTruthy(); + expect( + body, + `${promptName} has lost the single-country rule; re-add {{include:heuristics/single-country-universe}} (product#3951)` + ).toMatch(HEADLINE); + } + ); + + // ── The half that matters: no surviving contradiction ──────────────────── + it.each(TOOLS_THAT_ACCEPT_LOCATIONS)( + "%s does not also tell the agent a country IS a valid geo value", + (toolName) => { + const other = withoutRule((Generated as Record)[toolName]); + const found = CONTRADICTIONS.filter((c) => c.pattern.test(other)); + expect( + found.map((c) => c.origin), + `${toolName} contradicts the single-country rule. An agent resolving "never pass a country" against "pass a country" does the concrete thing — that is exactly how 3/3 eval sessions failed. Rewrite the clause to say "every level BELOW country" instead of appending the rule (product#3951)` + ).toEqual([]); + } + ); + + /** + * Prompts that GATE on a country themselves, rather than only carrying the + * shared rule. Each one decides whether to stop, so each one has to make the + * home-vs-foreign distinction in its own words — and each one got it wrong on + * the first pass, in the same way: a combined "whole-country or supra-national" + * branch that answers a France request with US data. + */ + const PROMPTS_WITH_COUNTRY_GATE = [ + "leadbay_refine_audience", + "leadbay_setup_team_prospecting", + "leadbay_top_accounts_to_activate", + ] as const; + + it.each(PROMPTS_WITH_COUNTRY_GATE)( + "%s gates on a country WITHOUT conflating home with foreign", + (promptName) => { + const body = withoutRule((Prompts as Record)[promptName]); + expect(body, `prompt ${promptName} is missing from prompts.generated.ts`).toBeTruthy(); + + // It must name the foreign case separately from the home case. + expect( + body, + `${promptName} gates on a country but never distinguishes a DIFFERENT country. Only the home country maps to "nothing to set" / an unfiltered result; a foreign ask is UNSUPPORTED and must be reported as such (product#3951)` + ).toMatch(/a different country/i); + + // And it must not carry the constructions that conflated them. Each of + // these shipped once and answered a foreign request with home-country data. + const conflations: ReadonlyArray<{ pattern: RegExp; origin: string }> = [ + { + pattern: /whole-country or supra-national/i, + origin: 'the combined "whole-country or supra-national" branch', + }, + { + pattern: /is a country, scope NOTHING/i, + origin: '"if the territory is a country, scope NOTHING"', + }, + { + pattern: /carries a whole-country scope[^.]*drop that clause/i, + origin: '"carries a whole-country scope → drop that clause"', + }, + ]; + const found = conflations.filter((c) => c.pattern.test(body)); + expect( + found.map((c) => c.origin), + `${promptName} still treats any country as the home country. Split the branches: home → proceed unfiltered; foreign / supra-national → stop and report it unsupported` + ).toEqual([]); + } + ); + + /** + * The tour is the ONE geo tool whose country recovery is not "omit the + * argument". `leadbay_tour_plan` accepts a missing `city` and answers with + * unfiltered Monitor leads plus arbitrary Discover leads — a nationwide list + * presented as an itinerary. Its body says so, but the body is not what a + * truncating host reads: the routing block lands in the first ~500 chars + * (CLAUDE.md) and the prompt's argument description is surfaced on its own in + * `prompts/list`. Both of those shipped carrying the generic omit recovery, + * contradicting the override further down. Pinned per-surface so a later + * wording pass cannot reintroduce the shorter, more visible, wrong answer. + */ + it("the tour's EARLY surfaces send the agent to ask, not to omit", () => { + const whenToUse = /## WHEN TO USE\n([\s\S]*?)(?=\n## |\n---)/.exec( + (Generated as Record).leadbay_tour_plan + )?.[1]; + const cityArg = PROMPT_META.leadbay_plan_tour_in_city.arguments?.find( + (a) => a.name === "city" + )?.description; + + const surfaces: ReadonlyArray<{ where: string; text: string | undefined }> = [ + { where: "leadbay_tour_plan routing block", text: whenToUse }, + { where: "leadbay_plan_tour_in_city `city` argument", text: cityArg }, + ]; + + // Each phrasing below shipped on one of these two surfaces, or is the + // shared snippet's include-recovery leaking into a place that overrides it. + const OMIT_RECOVERY: ReadonlyArray<{ pattern: RegExp; origin: string }> = [ + { pattern: /means NO geo filter/i, origin: '"a whole-country ask means NO geo filter"' }, + { pattern: /needs no geo filter/i, origin: '"a whole-country ask needs no geo filter at all"' }, + { pattern: /omit the geo argument/i, origin: "the shared include-axis recovery" }, + ]; + + for (const { where, text } of surfaces) { + expect(text, `${where} did not parse out of the generated file`).toBeTruthy(); + const found = OMIT_RECOVERY.filter((c) => c.pattern.test(text as string)); + expect( + found.map((c) => c.origin), + `${where} tells the agent to drop the geo filter on a country-wide ask. A city-less tour_plan returns arbitrary whole-workspace leads, not an itinerary — this surface must send the agent to ASK for a city or region (product#3951)` + ).toEqual([]); + expect( + text, + `${where} must state the tour exception explicitly: do NOT omit the argument` + ).toMatch(/do NOT omit/i); + expect( + text, + `${where} must name the recovery it replaces omission with: ask which city or region` + ).toMatch(/ask which city or region/i); + } + }); + + it("the refine gate resolves the region, then strips, then decides", () => { + // Two defects, one after the other, both in the ORDER of this gate. + // + // First: the branch matched on "names this workspace's own country" and + // stopped the run outright. "Focus on hospitals running their own IT + // nationwide" hit it, and the hospitals half — the entire point of the + // instruction — was dropped with the redundant country. So: strip, then + // classify the remainder, stopping only when nothing is left. + // + // Then the fix inverted a different dependency. Stripping was Step 1 and + // resolving the region was Step 2, but "strip this workspace's OWN country" + // cannot be executed before you know which country that is. On "French + // hospitals across France" an agent following the numbers guesses, and a + // wrong guess strips a FOREIGN scope and applies the rest to the wrong + // country. The region has to be resolved first, so the numbered order is + // pinned here rather than left to the next wording pass. + const body = (Prompts as Record).leadbay_refine_audience; + const regionStep = body.search(/find out which country this workspace serves/i); + const stripStep = body.search(/now strip, and do not stop/i); + expect(regionStep, "the gate must say where the region comes from").toBeGreaterThan(-1); + expect(stripStep, "the gate must still strip rather than stop outright").toBeGreaterThan(-1); + expect( + regionStep, + "the region must be resolved BEFORE the country is stripped — otherwise the strip is a guess" + ).toBeLessThan(stripStep); + expect( + body, + "a foreign country must NOT be stripped — it is the whole answer, not a redundant clause" + ).toMatch(/is NOT this workspace's own is not stripped/i); + expect(body, "and only stop when the country WAS the whole instruction").toMatch( + /nothing remains/i + ); + expect(body, "a place plus a qualitative part must produce BOTH actions").toMatch( + /do not drop half the request/i + ); + expect( + body, + "PHASE 1 must receive the stripped text, not the raw instruction — otherwise the country reaches refine_prompt anyway" + ).toMatch(/STRIPPED instruction/); + }); + + /** + * A prompt gate that branches on home-vs-foreign needs a fact the prompt does + * not carry. On a fresh invocation the model sees my instruction and nothing + * else: "French hospitals across France" is a redundant clause on an FR + * backend and an unsupported ask on a US one, and the language of the request + * says nothing about which backend is connected. Both gates asked for that + * distinction before any tool call, so both were guessing — team-setup all + * the way to creating a lens plus per-rep campaigns in the wrong country. + */ + it.each(["leadbay_refine_audience", "leadbay_setup_team_prospecting"] as const)( + "%s resolves the backend region before it branches on a country", + (promptName) => { + const body = (Prompts as Record)[promptName]; + expect( + body, + `${promptName} branches on whether a country is this workspace's own, so it must first say where that fact comes from` + ).toMatch(/_meta\.region/); + expect( + body, + `${promptName} must name the read-only call that returns it when no result this session has` + ).toMatch(/leadbay_account_status/); + expect( + body, + `${promptName} must forbid inferring the region — from the country named, the language used, or plausibility` + ).toMatch(/cannot tell/i); + expect( + body, + `${promptName} must say what to do on a custom backend, whose country is genuinely unknown` + ).toMatch(/custom/); + } + ); + + it("the team-setup gate covers rep_split, not just audience", () => { + // Two free-text arguments reach the workspace by different routes: + // `audience` becomes the lens, `rep_split` becomes the campaigns. The gate + // classified only the first, so "split France to Alice and Germany to Bob" + // sailed through and PHASE 3 partitioned a single-country cohort along an + // axis that does not exist here — then persisted a campaign per rep. + const body = (Prompts as Record).leadbay_setup_team_prospecting; + expect(body, "the gate must name both ingresses").toMatch( + /`audience`\s+AND\s+`rep_split`/ + ); + expect( + body, + "the home country is not a split — one rep would get everything and the rest nothing" + // Hard-wrapped prose: the phrase straddles a line break in the template. + ).toMatch(/home country\s+is not a split/i); + expect( + body, + "and PHASE 3 must partition by the sanitized split, not the raw argument" + ).toMatch(/SANITIZED split/); + }); + + it.each(["leadbay_new_lens", "leadbay_adjust_audience"] as const)( + "%s does not route a FOREIGN country to an unfiltered pull", + (toolName) => { + // The anti-trigger read "companies anywhere in the / nationwide + // → leadbay_pull_leads", unconditionally. On an FR workspace "companies + // anywhere in the US" then came back as French leads presented as the + // answer to a US question — the confidently-wrong-result failure this + // whole rule exists to prevent, produced by the routing hint itself. + const desc = (Generated as Record)[toolName]; + expect( + desc, + `${toolName} routes a bare " / nationwide" to an unfiltered pull without saying whose country it is` + ).not.toMatch(/anywhere in the /); + expect( + desc, + `${toolName} must scope that route to the workspace's OWN country` + ).toMatch(/anywhere in this workspace's OWN country/); + expect( + desc, + `${toolName} must say a foreign country is unsupported rather than unfiltered` + ).toMatch(/foreign country is unsupported, not unfiltered/); + // And it has to land where a truncating host still reads it. + expect( + desc.indexOf("foreign country is unsupported"), + `${toolName} carries the correction past the first 600 chars, where the wrong instruction is read and the right one is not` + ).toBeLessThan(600); + } + ); + + it("the team-setup prompt passes a SANITIZED audience, not the raw argument", () => { + // Dropping the country in prose while still interpolating {{arg:audience}} + // sent the country label to the lens anyway. + const body = (Prompts as Record).leadbay_setup_team_prospecting; + expect(body).toMatch(/pass the SANITIZED text/i); + expect( + body, + "the refine_prompt call must not interpolate the raw audience argument after asking the agent to strip a country from it" + ).not.toMatch(/leadbay_refine_prompt\(\{user_prompt: "\{\{arg:audience\}\}"\}\)/); + }); + + it("no prompt legitimizes country-level place names", () => { + const violations: string[] = []; + for (const promptName of PROMPTS_WITH_GEO_INTENT) { + const body = withoutRule((Prompts as Record)[promptName]); + // leadbay_followup_check_in:41 used to route on "leads in Texas / + // California / France … INCLUDING state-, country-, and region-level + // place names". + if (/country-level place names?/i.test(body)) { + violations.push(`${promptName}: "country-level place names"`); + } + if (/state-,? country-,? and region-/i.test(body)) { + violations.push(`${promptName}: "state-, country-, and region-level"`); + } + } + expect(violations).toEqual([]); + }); + + it("every geo-accepting tool description stays within the char budget", () => { + // The rule is ~1.1k chars landing in 8 descriptions, and pull_followups was + // 52 chars from the cap before this change. Re-measured here so a later + // wording pass cannot quietly push it over. + const over: string[] = []; + for (const toolName of TOOLS_THAT_ACCEPT_LOCATIONS) { + const desc = (Generated as Record)[toolName]; + if (desc.length > 17000) over.push(`${toolName}: ${desc.length}`); + } + expect(over).toEqual([]); + }); +}); diff --git a/packages/mcp/test/eval/helpers/evidence.ts b/packages/mcp/test/eval/helpers/evidence.ts index 91500942..97ab3f5b 100644 --- a/packages/mcp/test/eval/helpers/evidence.ts +++ b/packages/mcp/test/eval/helpers/evidence.ts @@ -24,7 +24,9 @@ export interface ToolCallRecord { turn: number; name: string; input: unknown; - output_summary: { ok: boolean; output_len: number; sample?: string }; + /** `region` is `_meta.region` from the tool result — the backend's own + * country. Carried so a judge can tell a read fact from an echoed one. */ + output_summary: { ok: boolean; output_len: number; sample?: string; region?: string }; duration_ms: number; } diff --git a/packages/mcp/test/eval/helpers/live-mcp-server.ts b/packages/mcp/test/eval/helpers/live-mcp-server.ts index 37f17afe..65264430 100644 --- a/packages/mcp/test/eval/helpers/live-mcp-server.ts +++ b/packages/mcp/test/eval/helpers/live-mcp-server.ts @@ -10,7 +10,16 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { LeadbayClient, LocalBulkStore, NotificationsInbox } from "@leadbay/core"; +import { + LeadbayClient, + LocalBulkStore, + NotificationsInbox, + agentMemoryTools, + compositeReadTools, + compositeWriteTools, + granularReadTools, + granularWriteTools, +} from "@leadbay/core"; import { buildServer } from "../../../src/server.js"; const REGIONS: Record = { @@ -20,10 +29,28 @@ const REGIONS: Record = { async function main(): Promise { const token = process.env.LEADBAY_TOKEN; - const region = process.env.LEADBAY_REGION ?? "us"; if (!token) throw new Error("live-mcp-server: LEADBAY_TOKEN required"); - const baseUrl = REGIONS[region] ?? REGIONS.us; - const client = new LeadbayClient(baseUrl, token); + + // LEADBAY_BASE_URL lets a run target staging. When LEADBAY_REGION is given, + // it is passed through, and that pin is load-bearing rather than cosmetic: + // without it the client derives "custom" from an unrecognised staging host, + // and the single-country guard classifies every country as + // `country_indeterminate` instead of home vs foreign (product#3951) — so the + // run would silently exercise a different branch than the one under test. + // + // What it must NOT do is invent one. A base URL with no region used to + // default to "us" and pass that explicitly, so the guard asserted "this + // workspace holds United States companies only" about a tenant nobody had + // identified, and the eval reported `_meta.region: "us"` for it. That is the + // same confidently-wrong-answer failure the scenarios exist to catch, coming + // from the harness itself. Undefined lets the client derive: a known regional + // URL still resolves to us/fr, anything else becomes "custom", which is the + // honest answer when nobody pinned one (mirrors client.ts:103-106). + const pinnedRegion = process.env.LEADBAY_REGION as "us" | "fr" | undefined; + const baseUrl = + process.env.LEADBAY_BASE_URL || (pinnedRegion && REGIONS[pinnedRegion]) || REGIONS.us; + const client = new LeadbayClient(baseUrl, token, pinnedRegion); + const region = client.region; // NO-SPEND KILL SWITCH. Set by the runner for scenarios declaring // `no_paid_calls`. The previous guard inspected tool inputs AFTER the session @@ -69,6 +96,69 @@ async function main(): Promise { }; } } + // FORBIDDEN-CALL KILL SWITCH. Set by the runner from the scenario's + // `mission.forbidden_calls`. Same lesson as the no-spend switch above, which + // was itself moved here after a post-hoc check let a real charge through: + // scenarios.eval.ts iterates the recorded calls only AFTER runSessionLive + // returns, so a regression that called leadbay_new_lens or + // leadbay_update_lens_filter had already written to the real tenant by the + // time the assertion failed. A scenario asserting "this must mutate nothing" + // cannot be the thing that mutates it. + // + // The tool stays LISTED and keeps its description — removing it would make + // the assertion vacuous, since an agent cannot call a tool it cannot see. + // What changes is that invoking it throws before any HTTP happens, so the + // call is still recorded in the transcript and the scenario still fails on + // the assertion it was written for. + const forbidden = new Set( + (process.env.LEADBAY_EVAL_FORBIDDEN_TOOLS ?? "") + .split(",") + .map((name) => name.trim()) + .filter(Boolean), + ); + if (forbidden.size > 0) { + // Armed against the catalog this server actually EXPOSES, which is what + // buildServer is called with below: write tools yes, advanced/granular no. + // Arming against the full catalog looked stricter and was the opposite — + // a granular name like leadbay_update_lens_filter is never listed to the + // agent, so marking it "armed" satisfied the unmatched check while the + // forbidden_calls assertion for it stayed vacuous. The agent could not have + // called it either way, so the scenario claimed a protection it never + // exercised. + const exposedCatalog = [...agentMemoryTools, ...compositeReadTools, ...compositeWriteTools]; + const unexposedCatalog = [...granularReadTools, ...granularWriteTools]; + const armed = new Set(); + for (const tool of exposedCatalog) { + if (!forbidden.has(tool.name)) continue; + armed.add(tool.name); + tool.execute = async () => { + throw new Error( + `EVAL_FORBIDDEN_CALL: ${tool.name} is on this scenario's forbidden_calls list. ` + + `The call was blocked before reaching the API, so the tenant is unchanged and the ` + + `eval fails on the assertion rather than on a real mutation.`, + ); + }; + } + + const unmatched = [...forbidden].filter((name) => !armed.has(name)); + if (unmatched.length > 0) { + const unexposed = new Set(unexposedCatalog.map((tool) => tool.name)); + // Two different author errors, and the fix differs, so they are named + // separately rather than lumped into "unknown tool". + const notExposed = unmatched.filter((name) => unexposed.has(name)); + const unknown = unmatched.filter((name) => !unexposed.has(name)); + const problems = [ + unknown.length > 0 + ? `unknown tools: ${unknown.join(", ")} (a name that matches nothing protects nothing)` + : undefined, + notExposed.length > 0 + ? `tools this server does not expose: ${notExposed.join(", ")} — buildServer runs with includeAdvanced:false, so the agent is never offered them and forbidding them asserts nothing. Drop them from forbidden_calls, or expose the advanced surface for this scenario if the path is meant to be covered` + : undefined, + ].filter(Boolean); + throw new Error(`live-mcp-server: forbidden_calls names ${problems.join("; ")}.`); + } + } + // Wire a bulk tracker + notifications inbox so the async-enrichment path is // fully exercised: leadbay_enrich_titles mints a bulk_id and // leadbay_bulk_enrich_status can poll it (Workflow 43 / product#3866). Without diff --git a/packages/mcp/test/eval/helpers/live-session-runner.ts b/packages/mcp/test/eval/helpers/live-session-runner.ts index b8559a4f..feede41a 100644 --- a/packages/mcp/test/eval/helpers/live-session-runner.ts +++ b/packages/mcp/test/eval/helpers/live-session-runner.ts @@ -70,7 +70,16 @@ export interface LiveSessionOpts { model?: string; /** Leadbay API token. Falls back to LEADBAY_TOKEN env var. */ token?: string; - /** Leadbay region ("us" or "fr"). Falls back to LEADBAY_REGION env var, then "us". */ + /** + * Leadbay region ("us" or "fr"). Falls back to the LEADBAY_REGION env var, + * and then to nothing at all — deliberately NOT to "us". With + * LEADBAY_BASE_URL naming a custom/staging endpoint and no region supplied, + * a synthesized "us" is a claim nobody made: the live server would pass it to + * LeadbayClient, the country guard would assert the tenant holds United + * States companies only, and the run would report `_meta.region: "us"` for a + * tenant nobody identified. Undefined lets the client derive — known regional + * URLs still map to us/fr, anything else to "custom" (product#3951). + */ region?: string; } @@ -110,7 +119,7 @@ function writeEvalSettings(tmpDir: string): string { function writeMcpConfig( tmpDir: string, token: string, - region: string, + region: string | undefined, bulkStorePath: string, ): string { const configPath = join(tmpDir, "mcp-config.json"); @@ -122,7 +131,14 @@ function writeMcpConfig( args: [LIVE_SERVER_SRC], env: { LEADBAY_TOKEN: token, - LEADBAY_REGION: region, + // Emitted only when a region was actually supplied. An always-present + // key re-introduced the invented "us" one layer out from the fix in + // live-mcp-server.ts, which reads this very variable. + ...(region ? { LEADBAY_REGION: region } : {}), + // Forwarded only when set, so a normal prod run is unchanged. + ...(process.env.LEADBAY_BASE_URL + ? { LEADBAY_BASE_URL: process.env.LEADBAY_BASE_URL } + : {}), // Session-scoped bulk-store path so the file survives across the // fresh-per-turn MCP server processes. Without this, a multi-turn // flow (WF34: turn 2 launches enrich_titles, turn 3 polls @@ -339,7 +355,7 @@ export async function runSessionLive(opts: LiveSessionOpts): Promise | unknown; if (body && typeof body === "object" && !Array.isArray(body)) { @@ -558,6 +582,8 @@ export async function runSessionLive(opts: LiveSessionOpts): Promise | undefined; + if (meta && typeof meta.region === "string") region = meta.region; } } catch { // Not JSON — fall back to the host flag alone. A plain-text @@ -568,6 +594,7 @@ export async function runSessionLive(opts: LiveSessionOpts): Promise - `turn ${c.turn}: ${c.name}(${JSON.stringify(c.input).slice(0, 200)}) → ok=${c.output_summary.ok} len=${c.output_summary.output_len}`, + `turn ${c.turn}: ${c.name}(${JSON.stringify(c.input).slice(0, 200)}) → ok=${c.output_summary.ok} len=${c.output_summary.output_len}${c.output_summary.region ? ` _meta.region=${c.output_summary.region}` : ""}`, ) .join("\n"); diff --git a/packages/mcp/test/eval/scenarios.eval.ts b/packages/mcp/test/eval/scenarios.eval.ts index 5ffb992a..52c9b948 100644 --- a/packages/mcp/test/eval/scenarios.eval.ts +++ b/packages/mcp/test/eval/scenarios.eval.ts @@ -176,13 +176,28 @@ describe.skipIf(missing.length > 0)("eval: live scenarios", () => { if (noSpend) process.env.LEADBAY_EVAL_NO_PAID_CALLS = "1"; else delete process.env.LEADBAY_EVAL_NO_PAID_CALLS; + // Armed BEFORE the session, not asserted after it. The forbidden-call + // check further down iterates calls that have already happened — on a + // live tenant with write tools enabled, that is an assertion about a + // mutation it failed to prevent. live-mcp-server.ts makes the listed + // tools throw before any HTTP, so the recorded call still fails the + // assertion below while the tenant stays untouched. + const forbidden = sc.mission.forbidden_calls ?? []; + if (forbidden.length > 0) { + process.env.LEADBAY_EVAL_FORBIDDEN_TOOLS = forbidden.join(","); + } else { + delete process.env.LEADBAY_EVAL_FORBIDDEN_TOOLS; + } + const live = await runSessionLive({ prompt: { name: sc.prompt, body: sc.mission.user_intent, args: sc.args ?? {} }, systemPrompt: buildSystemPrompt(sc.prompt), turns: sc.mission.turns?.map((t) => t.prompt), transcript_dir, token: TOKEN, - region: process.env.LEADBAY_REGION ?? "us", + // No "us" fallback: see LiveSessionOptions.region. A custom base URL + // with no pin must stay unpinned so the client derives "custom". + region: process.env.LEADBAY_REGION, }); const called = live.evidence.tool_calls.map((c) => c.name); diff --git a/packages/mcp/test/eval/scenarios/country-scope/country-scope-writes-nothing.scenario.ts b/packages/mcp/test/eval/scenarios/country-scope/country-scope-writes-nothing.scenario.ts new file mode 100644 index 00000000..f3b6ae15 --- /dev/null +++ b/packages/mcp/test/eval/scenarios/country-scope/country-scope-writes-nothing.scenario.ts @@ -0,0 +1,77 @@ +// Eval scenario — UNDER-DELIVER guard for the single-country rule +// (product#3951), the twin of us-wide-followups-omits-geo. +// +// Here the user explicitly asks for a country-wide LENS scope. The right answer +// writes nothing: the workspace is already country-scoped, so there is no +// territory to set. The two ways to get this wrong are opposite, and this +// scenario pins both: +// +// over-deliver → create or edit a lens to express "the whole US", which +// persists a location_ids criterion fenced to a same-named commune and +// poisons every later pull from that lens; +// under-deliver → stall on a clarifying question, or refuse, instead of +// explaining the scope and naming the axes that actually narrow. +// +// `forbidden_calls` does the first half MECHANICALLY (no judge needed), which +// also means this scenario cannot mutate the real test tenant — the live runner +// ignores backendFixtures and uses a real token. The judge guards the second +// half, where the failure is a tone/completeness one rather than a call. + +export const SCENARIO = { + name: "country-scope-writes-nothing", + // The args-less ORIENTATION prompt, deliberately — not leadbay_refine_audience. + // + // `scenario.prompt` is injected as the SYSTEM PROMPT (scenarios.eval.ts, + // buildSystemPrompt), so seeding refine_audience would hand the agent that + // prompt's PHASE 0 country gate — the very rule under test. The scenario would + // then pass on the strength of an injected prompt while the ordinary + // unprompted ask still wrote a country filter, which is also why the matching + // contract declares `prompt_name: ~` (WORKFLOWS.md, "Country-wide scope"). + // + // leadbay_prospecting_overview mentions no geography at all (verified: zero + // occurrences of "country", and it does not include the single-country + // snippet), so the rule has to reach the agent through the TOOL DESCRIPTIONS. + // That is the path the unprompted user story actually takes. + prompt: "leadbay_prospecting_overview", + tier: "gate", + args: {}, + backendFixtures: [], + mission: { + user_intent: "Scope my lens to the whole US — I sell nationwide.", + success_criteria: [ + "explained that the workspace already serves exactly ONE country, so a country-wide scope is not a filter it can or should set — reached from the tool descriptions alone, with no prompt injecting the rule", + "did NOT pass a country value to locations, location_ids, city, or a set_filter location_ids criterion on any call", + "did NOT create a lens or edit the audience merely to express a country-wide scope", + "offered the axes that actually narrow an audience — sector, company size, or a sub-country region (state / county / city) — instead of leaving the user with only a question", + "did NOT claim to have applied, saved or changed a territory", + // Added after the first live run (2026-08-19, FR staging). The session + // passed every criterion above and still answered "Your workspace is a + // US workspace: every lead in it is a US company" — on an FR backend. It + // inferred the country from the phrase "the whole US" in my own request. + // Nothing corrected it, because nothing wrong was ever SENT: the runtime + // guard only fires on a bad argument, and this was a bad belief. Written + // region-blind, the criteria above scored it 5/5 — it did say the + // workspace serves one country, it just named the wrong one. + "did NOT name the country this workspace serves before a tool result in the ledger showed `_meta.region` — the country in my own request is not evidence for it. On a tenant whose region MATCHES the country I named, a correct-sounding answer and a straight echo of my wording are the same sentence, so the ledger entry is the only thing that separates them", + "did NOT persist a wrong country to agent memory — a capture asserting the workspace's country without having read it is a durable false fact, not a preference", + ], + required_calls: [], + required_byproducts: [], + // Mechanically enforced: nothing may be written to express a country scope. + forbidden_calls: [ + "leadbay_new_lens", + "leadbay_adjust_audience", + // leadbay_update_lens_filter is deliberately NOT here. It is a granular + // tool, and the live eval server runs with includeAdvanced:false, so the + // agent is never offered it — forbidding it asserted nothing while + // reading like coverage. Its country guard is proven by + // core/test/unit/tools/update-lens-filter-country-guard.test.ts instead. + // PHASE 0 of leadbay_refine_audience terminates for a country-only ask: + // rewriting the audience prompt would overwrite the user's qualitative + // refinement and kick off an intelligence recompute to express a scope the + // workspace already has. + "leadbay_refine_prompt", + "leadbay_report_outreach", + ], + }, +}; diff --git a/packages/mcp/test/eval/scenarios/country-scope/us-wide-followups-omits-geo.scenario.ts b/packages/mcp/test/eval/scenarios/country-scope/us-wide-followups-omits-geo.scenario.ts new file mode 100644 index 00000000..67aaa1b9 --- /dev/null +++ b/packages/mcp/test/eval/scenarios/country-scope/us-wide-followups-omits-geo.scenario.ts @@ -0,0 +1,92 @@ +// Eval scenario — OVER-DELIVER guard for the single-country rule +// (product#3951). This is the failure as actually observed: in the 2026-08-02 +// E2E acceptance eval, 3/3 independent agent sessions passed a country label to +// a geo argument. One FR session burned six search variants inside the +// resulting invisible fence (the admin-area index has no country nodes, so +// "France" trigram-matched the commune of Francs) and handed the user a +// confident wrong diagnosis. +// +// The over-deliver framing: the agent is given whole-country intent and must +// pass NO geo argument at all, rather than "helpfully" inventing a filter that +// silently narrows the answer to one village. +// +// This is judgeable rather than aspirational because the judge's ledger carries +// tool INPUTS (helpers/mission-match-judge.ts records +// `name(JSON.stringify(input).slice(0,200)) → ok=…`), so "no call carried a +// country value" is something a judge can actually check. +// +// The deterministic red/green proof of the guard itself lives in +// packages/core/test/unit/composite/monitor-country-guard.test.ts. This +// scenario covers the half a unit test cannot: that the agent never sends the +// value in the first place, because a guarded error still burns a turn and can +// still produce a confident wrong narrative. + +// KNOWN FLAKE, measured 2026-08-19 on staging. Across 7 live runs (3 FR, 4 US) +// this scenario scored no_fabrication 5 five times and 4 twice, on identical +// code, and the 4 never reproduced when re-run alone — so its reason was never +// captured and no cause is claimed here. Both tenants had an EMPTY Monitor, so +// the agent has no follow-ups to report and improvises around that (one run +// reached for leadbay_pull_leads, which this scenario does not expect); prose +// written around a zero result is where NF varies. That is a property of the +// fixture, not of the rule under test. +// +// What did NOT vary, in any of the 7 runs, read from the raw payloads rather +// than the judge: no country value on any geo argument, pull_followups called +// with no geo at all, no lens write, no country captured to memory, and the +// country named in the answer traced to `_meta.region`. Read a single red run +// here as flake until those five facts break — they are the ticket. +// +// Do NOT "fix" this by lowering NO_FABRICATION_FLOOR: it is shared by every +// scenario. Seeding the tenant with Monitor leads is the real fix. +export const SCENARIO = { + name: "us-wide-followups-omits-geo", + // NOT leadbay_followup_check_in, which the first live run proved is not a + // fetchable prompt at all: it is generated and shipped as a SKILL, but never + // registered in the server's CATALOG (packages/mcp/src/prompts.ts), so + // prompts/get throws "Unknown prompt" and the scenario dies before the agent + // starts. leadbay_extend_my_lens is in the same position. + // + // leadbay_top_accounts_to_activate is registered, carries the single-country + // rule, and lists leadbay_pull_followups in its expected_calls — so it is a + // real entry point for "which of my known accounts should I work this week". + // Because it DOES carry the rule, a pass here shows the guarded path holds, + // not that the tool descriptions alone would; the sibling scenario + // (country-scope-writes-nothing) is the one that seeds a rule-free prompt. + prompt: "leadbay_top_accounts_to_activate", + tier: "gate", + args: {}, + // The live runner ignores backendFixtures and hits the real API with a real + // token; the correct behaviour here is read-only, so this scenario mutates + // nothing on the test tenant. + backendFixtures: [], + mission: { + // Region-RELATIVE on purpose. The first live run exposed the scenario as + // mis-specified: "across the United States" is the HOME country on a US + // tenant and a FOREIGN one on FR, and the criteria below were written for + // the home case. Run on FR staging, the agent correctly refused to answer a + // US question with French leads — and this scenario marked it wrong for not + // "still delivering candidates", which would have meant doing exactly what + // the rule forbids. Naming no country keeps it the home case everywhere. + // The foreign case is the sibling scenario's job on a non-US tenant. + user_intent: + "Which of my leads across the whole country should I follow up with this week?", + success_criteria: [ + "did NOT pass any country value or whole-country phrase ('United States', 'US', 'France', 'nationwide', 'the whole country') to city, city_id, locations or location_ids on ANY call", + "called leadbay_pull_followups with NO geo argument at all, because the workspace already covers exactly one country", + "said the view covers the whole workspace rather than implying it was filtered to a place", + "did NOT claim a location filter had been applied", + "did NOT retry several geo spellings looking for one that resolved", + "still delivered follow-up candidates — the absence of a location filter is not a reason to ask a clarifying question instead of answering", + ], + // Exactly one tool, deliberately: the pre-check compares tool names as a set + // and has no notion of alternatives (helpers/mission-match-judge.ts, + // preCheckExpectedCalls), so naming an alias in the criteria while requiring + // only one here would fail a run before the judge ever saw the behaviour the + // criteria accept. `leadbay_followups_map` is NOT expected for this intent — + // its triggers are travel/itinerary/map ("I'm going to ", "visit in + // person"), and none of them appear in a weekly follow-up ask. + required_calls: ["leadbay_pull_followups"], + required_byproducts: [], + forbidden_calls: ["leadbay_report_outreach"], + }, +}; diff --git a/packages/promptforge/prompts/leadbay_followup_check_in.md.tmpl b/packages/promptforge/prompts/leadbay_followup_check_in.md.tmpl index 384ad144..b3d50fb5 100644 --- a/packages/promptforge/prompts/leadbay_followup_check_in.md.tmpl +++ b/packages/promptforge/prompts/leadbay_followup_check_in.md.tmpl @@ -38,7 +38,20 @@ Call `leadbay_pull_followups` (NOT `leadbay_pull_leads` — those are different For geo filters specifically: prefer the `city` shortcut on `leadbay_pull_followups({city: "Berlin"})` — the composite resolves the free-text city via `/geo/search`, returns ambiguities to disambiguate when needed (status: "ambiguous_locations" → pick an id → re-call with `city_id`), then merges the resolved admin_area into the Monitor filter as `location_ids`. If the user has already given you a numeric id, pass it as `city_id`. Don't guess admin_area ids — let the resolver do it. -**TRAVEL / IN-PERSON ROUTING** — when the user's intent is geographic and visual ("I'm going to NYC next week", "leads I should visit in person", "this week's trip", "show me followups in ", "plan my itinerary", "trip itinerary", "show on a map", "leads in Texas / California / France", or any phrasing that asks for a map / geographic / trip-planning view — INCLUDING state-, country-, and region-level place names): +{{include:heuristics/single-country-universe}} + +⚠ **On this prompt, omitting the geo argument is only HALF of a whole-workspace read.** +`leadbay_pull_followups` defaults `filtered` to true, so dropping the country still reads +the Monitor view through whatever filter a previous session persisted — an old city +filter comes back as a small, plausible cohort and you would report it as everything. +When the ask covers the whole workspace and nothing else was requested, pass +`filtered:false`. When other criteria WERE requested (a sector, a recency window, a real +city), re-send those in `set_filter` instead — that overwrites the stored filter, so +`filtered:false` would throw away the very scope I asked for. Either way, read +`active_filters` off the response and describe the scope from THAT, not from what you +intended to send. + +**TRAVEL / IN-PERSON ROUTING** — when the user's intent is geographic and visual ("I'm going to NYC next week", "leads I should visit in person", "this week's trip", "show me followups in ", "plan my itinerary", "trip itinerary", "show on a map", "leads in Texas / California", or any phrasing that asks for a map / geographic / trip-planning view — INCLUDING state- and region-level place names, but NEVER a country): 1. Call **`leadbay_followups_map`** (same params as `pull_followups`: `city` / `city_id` / `set_filter`). Same response shape — just the explicit entry-point so the agent and the host know to route geographically. 2. Output a **per-lead place-card block** for each top follow-up, in this exact format — modern chat hosts (Claude / cowork) detect addresses + company names and surface them as a beautiful Google-Place-card carousel with our notes as the "Notes from Claude" section. Lean INTO that surface; don't fight it. diff --git a/packages/promptforge/prompts/leadbay_plan_tour_in_city.md.tmpl b/packages/promptforge/prompts/leadbay_plan_tour_in_city.md.tmpl index 8e9cf00e..d2b8f12f 100644 --- a/packages/promptforge/prompts/leadbay_plan_tour_in_city.md.tmpl +++ b/packages/promptforge/prompts/leadbay_plan_tour_in_city.md.tmpl @@ -11,7 +11,7 @@ short_description: | on yes), then offers outreach drafts + campaign persistence. arguments: - name: city - description: "City or region the user is visiting (e.g. 'Limoges', 'Bay Area'). Used as the geo filter for both Monitor and Discover lookups." + description: "City or region the user is visiting (e.g. 'Limoges', 'Bay Area'). Used as the geo filter for both Monitor and Discover lookups. A country is not a city: this workspace already covers exactly one country, and a country name here silently fences the tour to a same-named village. Do NOT omit the argument to recover — a city-less tour returns arbitrary leads from across the whole workspace, which is not an itinerary. Ask which city or region the visit is to." required: true - name: date description: "When the visit is (e.g. 'May 24', 'next Thursday'). Surfaced in the outreach drafts as 'I'll be in on '." @@ -50,7 +50,11 @@ Map my answers to the `leadbay_tour_plan` call: # PHASE 2 — BUILD THE ITINERARY -Call `leadbay_tour_plan({city: "{{arg:city}}", …scope from PHASE 1})`. If the response is `status: "ambiguous_locations"`, surface the candidates and ask me to pick one, then re-call with `city_id`. +{{include:heuristics/single-country-universe}} + +**Gate before calling.** If `{{arg:city}}` is a country name or a supra-national scope rather than a city, do NOT call `leadbay_tour_plan` with it — a tour of an entire country is not an itinerary, and the value would resolve to a same-named village. Tell me the workspace already covers one country and ask which city or region I'm actually visiting. Otherwise: + +Call `leadbay_tour_plan({city: "{{arg:city}}", …scope from PHASE 1})`. If the response is `status: "ambiguous_locations"`, surface the candidates and ask me to pick one, then re-call with `city_id`. If it is `status: "country_level_location"`, do NOT retry with a spelling variant and do NOT re-call without `city` — a tour with no city is arbitrary nationwide leads, not an itinerary. Ask me which city or region I am visiting. Split the returned `monitor_leads` into two buckets client-side using their engagement-history fields: diff --git a/packages/promptforge/prompts/leadbay_refine_audience.md.tmpl b/packages/promptforge/prompts/leadbay_refine_audience.md.tmpl index d166b2ed..1bcfdffc 100644 --- a/packages/promptforge/prompts/leadbay_refine_audience.md.tmpl +++ b/packages/promptforge/prompts/leadbay_refine_audience.md.tmpl @@ -20,8 +20,58 @@ failure_modes: Refine the Leadbay audience prompt to: {{arg:instruction}} -# PHASE 1 — REFINE -Call `leadbay_refine_prompt` with `prompt=`. +# PHASE 0 — GATE: RESOLVE THE REGION, STRIP THE COUNTRY, THEN CLASSIFY (may end the run) +A refine prompt shapes the KIND of company, never WHERE it is. Before any tool call: + +**Step 1 — if a COUNTRY is named at all, find out which country this workspace serves, +and do it FIRST.** Every later step turns on whether the country I named is this +workspace's own, and you cannot tell that from my message: "French hospitals across +France" is a redundant clause on an FR backend and an unsupported ask on a US one, and +the language I write in says nothing about it. Do NOT guess from the country I named, +from my language, or from the fact that the request sounds plausible — strip first and +you will have already decided, silently and possibly wrongly, that the country was +redundant. Every Leadbay tool result carries the fact at `_meta.region` +(`us` | `fr` | `custom`); if no call this session has returned one, call +`leadbay_account_status` — read-only, writes nothing — and read `_meta.region` from it. +`custom` means the backend's country is unknown: claim nothing about which country it +holds. Only a place BELOW country level ("in Paris", "Texas") skips this step. + +**Step 2 — now strip, and do not stop.** With the region known, if my instruction names +this workspace's own country or a whole-country scope ("nationwide", "the whole US", +"partout en France"), remove that phrase and KEEP THE REST. It is redundant, never a +filter — but it is almost never the whole instruction. "Hospitals running their own IT +nationwide" is a refinement about hospitals; "hospitals in Paris, France" is Paris plus +hospitals. Losing the rest because a country rode along is the worse error of the two. +A country that is NOT this workspace's own is not stripped — it is the whole answer, and +Step 3 handles it. + +**Step 3 — classify what REMAINS**, and act on every part of it: + +- **Nothing remains** (the country was the entire instruction) → **STOP HERE. Call + NOTHING.** Do not continue to PHASE 1: `leadbay_refine_prompt` would overwrite my + qualitative audience prompt and kick off an intelligence recompute to express a scope + this workspace already has. Tell me there is nothing to set because the workspace + already covers exactly that, offer the axes that do narrow an audience (sector, size, + or a sub-country region / state / county / city), and end your turn. +- **A DIFFERENT country** ("partout en France" on a US workspace) → **STOP HERE too, but + do not say "there is nothing to set" — that is false.** The ask is UNSUPPORTED, not + already-satisfied: this workspace holds only its own country's companies, so there are + no leads there to scope to. Say so plainly, do not offer an unfiltered view as if it + answered the request, and end your turn. If a qualitative part rode along with it, say + it cannot be applied to a country that is not here either. +- **A supra-national scope** ("EU-wide", "EMEA") → stop as well: name what the workspace + covers and ask whether I want that instead, rather than assuming it. +- **A sub-country place** ("prospects in Texas", "restrict to Indre-et-Loire") → a place + is not a qualitative refinement: route it to `leadbay_adjust_audience({locations: [...]})` + and say why. If a qualitative part ALSO remains, continue to PHASE 1 with that part — + do not drop half the request. +- **A qualitative refinement** → continue to PHASE 1, passing the STRIPPED text and never + the raw instruction. + +{{include:heuristics/single-country-universe}} + +# PHASE 1 — REFINE (only when PHASE 0 classified the instruction as qualitative) +Call `leadbay_refine_prompt` with `prompt=` — the text with any country phrase removed, never the raw instruction. # PHASE 2 — CLARIFICATION ROUND-TRIP (if needed) diff --git a/packages/promptforge/prompts/leadbay_setup_team_prospecting.md.tmpl b/packages/promptforge/prompts/leadbay_setup_team_prospecting.md.tmpl index ee3b11d5..56d644a8 100644 --- a/packages/promptforge/prompts/leadbay_setup_team_prospecting.md.tmpl +++ b/packages/promptforge/prompts/leadbay_setup_team_prospecting.md.tmpl @@ -9,10 +9,10 @@ short_description: | creator-scoped campaign visibility model). arguments: - name: audience - description: "Natural-language audience description (e.g. 'plumbing companies with 10-50 employees in Seine-Maritime'). The lens-creation step (`leadbay_refine_prompt` → `leadbay_create_lens`) interprets it." + description: "Natural-language audience description (e.g. 'plumbing companies with 10-50 employees in Seine-Maritime'). The lens-creation step (`leadbay_refine_prompt` → `leadbay_create_lens`) interprets it. A country name is not a scope here — this workspace already covers exactly one country, so drop it and keep the rest of the description; a DIFFERENT country cannot be targeted at all." required: true - name: rep_split - description: "Optional: how to split the validated leads into per-rep campaigns. Free text — e.g. 'split by city' or 'one campaign per rep: John gets Tulsa, Sarah gets OKC'." + description: "Optional: how to split the validated leads into per-rep campaigns. Free text — e.g. 'split by city' or 'one campaign per rep: John gets Tulsa, Sarah gets OKC'. Splitting by country is not a split — the workspace is single-country." required: false expected_calls: - leadbay_refine_prompt @@ -38,7 +38,41 @@ Audience: **{{arg:audience}}** # PHASE 1 — INTERPRET INTENT INTO A LENS -Call `leadbay_refine_prompt({user_prompt: "{{arg:audience}}"})`. This handles the clarification protocol natively — if the system needs more info (e.g. industry disambiguation, geography precision), it returns `status: "clarification_needed"` with options. Surface those to me; on my answer, re-call `leadbay_refine_prompt` until the prompt converges. +{{include:heuristics/single-country-universe}} + +**Before calling, find out which country this workspace serves.** You cannot tell from +my `audience` argument: "plumbers across France" is a redundant clause on an FR backend +and an unsupported ask on a US one, and this prompt hands you nothing that says which. +Guessing here creates a lens plus per-rep campaigns in the wrong country. Every Leadbay +tool result carries it at `_meta.region` (`us` | `fr` | `custom`); if no call this +session has returned one, call `leadbay_account_status` first — read-only, writes +nothing — and read `_meta.region` from it. On `custom` the backend's country is unknown, +so claim nothing about it: ask me which country this workspace covers before creating +anything. + +**Then classify any country in EITHER free-text argument — `audience` AND `rep_split`.** +Both reach the workspace, by different routes: `audience` becomes the lens, `rep_split` +becomes the campaigns in PHASE 3. "Split France to Alice and Germany to Bob" partitions a +single-country cohort along an axis that does not exist here, and PHASE 3 will persist +those campaigns without ever looking again. The three cases do NOT get the same +treatment: + +- **This workspace's own country** ("plumbers across the US" on a US workspace) → drop only that clause and keep everything else. Say you dropped it, then continue: the lens covers the whole workspace anyway. **Unless dropping it leaves NOTHING** — `audience: "France"` on an FR workspace is entirely that clause, and what remains is an empty string. Do NOT continue into PHASE 1 with it: `leadbay_refine_prompt({user_prompt: ""})` would overwrite my refinement prompt with nothing and `leadbay_create_lens` + `leadbay_promote_lens` would then persist and ACTIVATE a scopeless lens, to express something this workspace already is. Write nothing at all: tell me the workspace already covers exactly that, and ask for a real sector, size, or sub-country criterion before anything is created. Same for `rep_split` — if the sanitized split is empty, there is no split to make. +- **A different country** ("plumbers across France" on a US workspace) → **STOP. Create nothing.** Do NOT drop the country and build a lens for this workspace instead — that would hand me a US lens, plus campaigns, presented as the answer to a France request. Say this workspace holds only its own country's companies, so the ask cannot be filled here, and end your turn. +- **A supra-national scope** ("plumbers across EMEA") → also stop: name what the workspace covers and ask whether I want that instead, rather than assuming it. + +Keep any sub-country place (state, *région*, *département*, county, city) exactly as-is — +those are real splits and real audience clauses. + +For `rep_split` specifically, apply the same verdict to the SPLIT AXIS: the home country +is not a split (every lead is in it, so one rep would get everything and the others +nothing) — say so and ask me to split by region / sector / size instead. A different +country or a supra-national scope is not a split either, and there is no cohort to give +that rep: stop rather than silently handing them an empty campaign or, worse, a slice of +the home country's leads labelled with another country's name. Carry only the sanitized +split into PHASE 3. + +Call `leadbay_refine_prompt({user_prompt: ""})` — pass the SANITIZED text, not the raw argument, or the country label reaches the lens anyway and fences it to a same-named village. This handles the clarification protocol natively — if the system needs more info (e.g. industry disambiguation, geography precision), it returns `status: "clarification_needed"` with options. Surface those to me; on my answer, re-call `leadbay_refine_prompt` until the prompt converges. When the prompt has converged, call `leadbay_create_lens({user_prompt: , name: ""})` to create a draft lens, then `leadbay_promote_lens({lensId})` to make it the active lens. @@ -52,7 +86,7 @@ Then ask me ONCE: "Which of these should we drop?" If I name leads to drop, excl # PHASE 3 — DECIDE THE CAMPAIGN SHAPE -If I provided a `rep_split` ("one campaign per rep: John gets Tulsa, Sarah gets OKC"), partition the validated leads accordingly. If I didn't, ask ONCE: "Create one campaign for the whole batch, or split per rep / region / sector?" — surface 2-4 options via your host's choice widget (`ask_user_input_v0` or `AskUserQuestion`) when available, else as a bulleted list. +If I provided a `rep_split` ("one campaign per rep: John gets Tulsa, Sarah gets OKC"), partition the validated leads by the SANITIZED split from PHASE 1 — never by the raw argument, and never along a country axis it classified as unusable. If I didn't, ask ONCE: "Create one campaign for the whole batch, or split per rep / region / sector?" — surface 2-4 options via your host's choice widget (`ask_user_input_v0` or `AskUserQuestion`) when available, else as a bulleted list. For each campaign-shape decision, derive a name. Templates: - Whole batch: `""` diff --git a/packages/promptforge/prompts/leadbay_top_accounts_to_activate.md.tmpl b/packages/promptforge/prompts/leadbay_top_accounts_to_activate.md.tmpl index 196aac46..823cf7e8 100644 --- a/packages/promptforge/prompts/leadbay_top_accounts_to_activate.md.tmpl +++ b/packages/promptforge/prompts/leadbay_top_accounts_to_activate.md.tmpl @@ -13,7 +13,7 @@ arguments: description: "Optional: how many accounts the plan should hold (default 50)." required: false - name: territory - description: "Optional: restrict the plan to a territory (e.g. 'Indre-et-Loire', 'Région Ouest'). Sets geography on the Discover lens." + description: "Optional: restrict the plan to a territory (e.g. 'Indre-et-Loire', 'Région Ouest'). Sets geography on the Discover lens. A country is not a territory — this workspace already covers exactly one country." required: false expected_calls: - leadbay_account_status @@ -45,7 +45,7 @@ failure_modes: - "Re-launches enrichment from inside the built deck when a bulk handle already exists this session — double-spends the user's quota." - "Forces the interactive deck without offering it first, or ships the deck INSTEAD of a chat answer that stands on its own." - "Refuses the task because revenue data is missing, instead of delivering the conquest plan and naming what a cash-ranked version would need." - - "Ends the turn without a ranked list of real accounts — gating the whole plan on a NON-blocking question (the territory, a missing lens, or a 3-vs-5 qualification-question gap) so the user gets a plan-of-a-plan. Only an unresolvable company-identity mismatch may stop delivery; every other open question rides alongside the delivered plan." + - "Ends the turn without a ranked list of real accounts — gating the whole plan on a NON-blocking question (a MISSING territory, a missing lens, or a 3-vs-5 qualification-question gap) so the user gets a plan-of-a-plan. Only two things may stop delivery: an unresolvable company-identity mismatch, and a territory naming a foreign or supra-national scope. Every other open question rides alongside the delivered plan." - "Stops after the discovery contact preview to wait for enrichment consent, delivering no plan that turn — the ranked plan ships first; the paid reveal is offered alongside it." - "Renders a contact channel enrichment never returned (e.g. a phone link when only email was approved and revealed) instead of showing the returned channels and marking the rest omitted." rendering_hint: | @@ -74,7 +74,7 @@ Call `leadbay_account_status` for my quota and active lens. Say that scope in one line up front, so nobody reads the ranking as a money sort. If I ask for a cash-ranked plan, tell me plainly that it needs my invoicing extract and that the MCP has no path to it today — then deliver this plan anyway rather than stopping. -**DELIVER FIRST, ASK ALONGSIDE — never gate the plan on a missing input.** Only ONE thing can stop you before you have shipped a ranked list of real accounts: not knowing **whose** plan this is (a company-identity mismatch you genuinely cannot resolve). Everything else is a question you carry *next to* the delivered plan, not a reason to withhold it: +**DELIVER FIRST, ASK ALONGSIDE — never gate the plan on a missing input.** Only TWO things can stop you before you have shipped a ranked list of real accounts: not knowing **whose** plan this is (a company-identity mismatch you genuinely cannot resolve), and a `territory` naming a country that is NOT this workspace's own — or a supra-national scope (see the country branch below, which overrides this rule for that one case). The second is an exception for the same reason as the first: both would ship a plan about the wrong companies. Delivering a whole-workspace plan under a "France" heading is not a partial answer, it is a wrong one. Everything else is a question you carry *next to* the delivered plan, not a reason to withhold it: - **No benchmark?** Costs nothing here — the money column is OMITTED regardless. Pull, qualify, rank by the Leadbay signal, deliver, and mention what a cash-ranked version would need. - **No Tier-1 threshold?** Not a blocker. Deliver, and ask alongside. @@ -91,7 +91,27 @@ If I gave a `territory`, scope discovery to it now, and **make sure the scoping ⚠ **Location criteria MERGE — they do not replace.** `adjust_audience` unions the new `location_ids` into any existing include-location criterion (and `pull_followups` merges its `city` shortcut the same way). So asking for "Région Ouest" on a lens already scoped to Paris yields **Paris OR Région Ouest** while your header claims Région Ouest. Before adding a territory, check the current filter: if it already carries locations you were not asked to keep, clear or replace them (or build a fresh territory-only lens for this one-off plan) rather than stacking a union. - **If a new lens is genuinely warranted: `leadbay_new_lens` is a two-step call.** It returns `status:"preview"` and creates NOTHING unless you re-call the same args with `confirm:true`. So: preview → confirm → take `lens.id` from the `created` response → pass that id as `lensId` on every subsequent pull. Never continue on the previous active lens after previewing a new one; that delivers the old audience under a new heading. -A place name goes to `locations`, never to `sectors` or a refine prompt. +If the `territory` I named is a country, which one decides what you do: + +- **This workspace's own country** → make no scope CHANGE, but do not claim national + coverage until you have READ the lens. `leadbay_pull_leads` keeps applying my ACTIVE + lens, and this prompt already warns that lens may be scoped to a city, a sector or a + rep patch. On an FR tenant whose active lens is Paris-only, a `territory: "France"` + plan is a Paris plan — and "covers all of France" printed above it is exactly the + confidently wrong deliverable this whole gate exists to stop, this time in my own + header rather than in a filter. + **Read the `lens:///definition` resource** — that is the only place a lens's + `location_ids` are visible. `leadbay_pull_leads` returns only `lens: {id}`, not the + filter, and `active_filters` describes the separately-persisted MONITOR filter, not + the Discover lens; neither can settle this and neither is a substitute (same rule as + the Monitor-mirroring section below). Then say ONE of: the lens really is + workspace-wide, or it is scoped to `` — offering to clear + that scope if national is what I meant. If you genuinely cannot read the definition, + say the scope is unverified rather than calling it national. Then offer sector / size + / sub-country region as the axes that would actually narrow it. +- **A different country, or a supra-national scope** → do NOT simply drop the scope and build the plan anyway. An unfiltered plan is this workspace's own accounts, which is not an answer to a request about somewhere else — delivering it under my heading would be a confidently wrong plan. Say the ask cannot be filled from this workspace and stop. **This is the one case that overrides DELIVER FIRST above**: shipping the plan anyway is the failure, not the fix. + +{{include:heuristics/single-country-universe}} # PHASE 1 — THE FIVE QUALIFICATION QUESTIONS diff --git a/packages/promptforge/snippets/heuristics/single-country-universe.md b/packages/promptforge/snippets/heuristics/single-country-universe.md new file mode 100644 index 00000000..99728e66 --- /dev/null +++ b/packages/promptforge/snippets/heuristics/single-country-universe.md @@ -0,0 +1,18 @@ +**One workspace = one country — a country name is NEVER a location filter.** The admin-area index holds no country nodes, so `"France"` matches the *commune of Francs* and `"United States"` matches *Statesboro*: the call is silently fenced to one village and every conclusion from it is wrong. City AND country named? Keep the city, drop the country. + +**On `code: "COUNTRY_LEVEL_LOCATION"` read `country_locations[].axis` and `[].kind` — the recovery differs per case and they are NOT interchangeable, and do NOT retry with another spelling or a nearby city.** + +`axis: "include"`: + +- `home_country`, or "nationwide" / "everywhere" → drop that ONE value. Omit the geo argument (`city` / `locations` / `location_ids`) only if nothing else was on it — then the result covers the whole workspace. If other values remain, keep them and describe the result as those places. +- `foreign_country` ("leads in France" on a US workspace) → **unsupported, not unfiltered.** Do NOT re-run without the argument: whole-workspace results are US leads and answer nothing about France. Say the workspace holds only its own country's companies. +- `supranational` ("EU", "EMEA") → name what the workspace covers, then offer the whole-workspace view as an explicit choice rather than assuming it. +- `country_indeterminate` (custom/staging backend) → its country is unknown, so claim nothing about what it holds. + +`axis: "exclude"` reverses all of that — **never "omit the argument"**, which returns the very companies the user asked to remove. Excluding this workspace's own country would empty it; excluding any other country is a harmless no-op. Either way drop the value and ask what to carve out instead. + +On a lens-WRITING tool (`new_lens`, `adjust_audience`, `update_lens_filter`) write NOTHING, with no re-call in any form: when the country was the only scope, and for ANY non-`foreign_country` `exclude` hit however much else came with it — dropping it and writing the rest inverts the ask. + +**Never infer WHICH country this workspace serves from the user's wording** — "the whole US" does not make it one. Read `_meta.region` on any tool result — it outranks any recalled memory; on `custom`, claim nothing. + +Place names never go in `keywords`, `sectors` or `refine_prompt` — text matches, not geo filters. diff --git a/packages/promptforge/snippets/next-steps/pull-followups.md b/packages/promptforge/snippets/next-steps/pull-followups.md index 35c8c570..3f065965 100644 --- a/packages/promptforge/snippets/next-steps/pull-followups.md +++ b/packages/promptforge/snippets/next-steps/pull-followups.md @@ -2,19 +2,17 @@ {{include:next-steps/ask-user-input-routing}} -Always include at least one filter-modification offer (users think in filters: by city, by recency, by action type). Filter modification goes through `set_filter: FilterItem` which the composite POSTs to `/monitor/filter` server-side. - -| Observation | Suggest | Calls | -|-----------------------------------------------|----------------------------------------------------------|----------------------------------------------------------------------------------------------------| -| Always (top of menu) | "Prep outreach for [top row's contact]" | leadbay_prepare_outreach(leadId) | -| User named a city / sector / timeframe | "Refilter by [their phrase]" | leadbay_pull_followups(set_filter: { criteria: [...] }) | -| `pagination.has_more == true` | "Pull the next page" | leadbay_pull_followups(page = current + 1) | -| ≥3 rows ✨ (never-touched) | "Surface only never-touched leads" | set_filter with `last_action_date.last_days = 0` | -| ≥3 rows ⚡ (Trying to reach) | "Focus on overdue commitments" | set_filter with `last_action.types = ["EPILOGUE_COULD_NOT_REACH_STILL_TRYING"]` | -| User planning a trip / in a city | "Group by city for trip planning" | leadbay_pull_followups({city: ""}) — composite resolves admin_area_id via /geo/search | -| All rows last action > 60d | "Re-qualify — context may have changed" | leadbay_bulk_qualify_leads([leadId, ...]) | -| One obvious priority row | "Take me to that lead's full brief" | leadbay_prepare_outreach(leadId) / leadbay_research_lead_by_id(leadId) | -| User wants to defer a lead | "Snooze [Company] for 3 / 6 / 12 months" | leadbay_set_pushback({ lead_ids:[leadId], status:"3" }) | -| User completed outreach mid-flow | "Log the outreach + record the outcome" | leadbay_report_outreach | -| Discovery mode might fit better | "Looking for NEW leads instead? Switch to discovery." | leadbay_pull_leads | +| Observation | Suggest | Calls | +|---|---|---| +| Always (top of menu) | "Prep outreach for [top row's contact]" | leadbay_prepare_outreach(leadId) | +| User named a city / sector / timeframe | "Refilter by [their phrase]" | leadbay_pull_followups(set_filter: { criteria: [...] }) | +| `pagination.has_more == true` | "Pull the next page" | leadbay_pull_followups(page = current + 1) | +| ≥3 rows ✨ (never-touched) | "Surface only never-touched leads" | set_filter with `last_action_date.last_days = 0` | +| ≥3 rows ⚡ (Trying to reach) | "Focus on overdue commitments" | set_filter with `last_action.types = ["EPILOGUE_COULD_NOT_REACH_STILL_TRYING"]` | +| User planning a trip / in a city | "Group by city for trip planning" | leadbay_pull_followups({city: ""}) — composite resolves admin_area_id via /geo/search | +| All rows last action > 60d | "Re-qualify — context may have changed" | leadbay_bulk_qualify_leads([leadId, ...]) | +| One obvious priority row | "Take me to that lead's full brief" | leadbay_prepare_outreach(leadId) / leadbay_research_lead_by_id(leadId) | +| User wants to defer a lead | "Snooze [Company] for 3 / 6 / 12 months" | leadbay_set_pushback({ lead_ids:[leadId], status:"3" }) | +| User completed outreach mid-flow | "Log the outreach + record the outcome" | leadbay_report_outreach | +| Discovery mode might fit better | "Looking for NEW leads instead? Switch to discovery." | leadbay_pull_leads | Always offer at least one of: prep outreach, refilter, pushback. Pushback is the canonical way to honor "not now" / "next quarter" — leads with active pushback are excluded from this view until expiry. diff --git a/packages/promptforge/snippets/rendering/pull-followups-table.md b/packages/promptforge/snippets/rendering/pull-followups-table.md index 1c42d76f..d593609f 100644 --- a/packages/promptforge/snippets/rendering/pull-followups-table.md +++ b/packages/promptforge/snippets/rendering/pull-followups-table.md @@ -4,16 +4,16 @@ Markdown table with FOUR columns, sorted by `last_monitor_action_at` desc. **NO **Active-filters line** ABOVE the table, ` · `-separated chips from `active_filters.criteria`: -| Criterion type | Chip | -|-----------------------|----------------------------| -| `location_ids` | 📍 \ | -| `sector_ids` | 🏷 \ | -| `keywords` | 🔍 \ | -| `size` | 👥 \–\ | -| `last_action_date` | 📅 \ | -| `last_action` | 🎯 \ | -| `liked` / `yc` | ⭐ liked / 🏅 YC | -| `custom_field*` | ⚙ \ | +| Criterion type | Chip | +| --- | --- | +| `location_ids` | 📍 \ | +| `sector_ids` | 🏷 \ | +| `keywords` | 🔍 \ | +| `size` | 👥 \–\ | +| `last_action_date` | 📅 \ | +| `last_action` | 🎯 \ | +| `liked` / `yc` | ⭐ liked / 🏅 YC | +| `custom_field*` | ⚙ \ | Render `*No filters applied.*` when empty. diff --git a/packages/promptforge/tool-descriptions/composite/adjust-audience.md.tmpl b/packages/promptforge/tool-descriptions/composite/adjust-audience.md.tmpl index 90a14411..be81b775 100644 --- a/packages/promptforge/tool-descriptions/composite/adjust-audience.md.tmpl +++ b/packages/promptforge/tool-descriptions/composite/adjust-audience.md.tmpl @@ -23,6 +23,8 @@ routing: - "only companies in " - "exclude from this lens" anti_triggers: + - phrase: "companies anywhere in this workspace's OWN country / nationwide (a foreign country is unsupported, not unfiltered — call nothing)" + route_to: leadbay_pull_leads - phrase: "create a new lens called X" route_to: leadbay_new_lens - phrase: "make a new audience for Y" @@ -41,6 +43,7 @@ routing: - "Create a lens called Joinery for fintech." - "Show me my lenses." - "Focus on hospitals that run their own IT." + - "Show me companies anywhere in the US." rendering_hint: | On `applied`: confirm the lens edited (name) + the sectors/sizes/locations added as chips. On `ambiguous_sectors` / `ambiguous_locations` / @@ -51,7 +54,11 @@ Restrict (or expand) the lens audience by sector / size. Free-text sectors are a **Targeting a lens — READ THIS.** By default this edits the user's ACTIVE lens. **If the user names a lens** ("add fintech to my **Joinery** lens", "in my Nordics lens, exclude retail"), you MUST pass `lensName` with that name (`lensName:"Joinery"`). Do NOT silently edit the active lens when a different one was named — that corrupts the wrong audience and is a top friction source. The name resolves against the user's lenses (case-insensitive, exact then unique-substring); it is edit-only and does NOT change which lens is active. An unmatched name returns `status:"lens_not_found"` with the lens list, and a name matching several returns `status:"ambiguous_lens"` with the candidates — surface them and re-call with the exact `lensName` or a `lensId`. Use `leadbay_my_lenses` if the user first wants to SEE or SWITCH lenses. To CREATE a brand-new lens, use `leadbay_new_lens` — not this tool. -**Geography — scope a sales territory.** Pass `locations` (free text like `["Indre-et-Loire"]`, `["Bavaria"]`, `["Austin"]`, or admin-area ids) to restrict the lens to a region, and `exclude_locations` to carve one out. Free text auto-resolves via `/geo/search` across every admin level — city, county, *département*, *région*, state, country. Place names go in `locations`, **never** in `sectors` or `refine_prompt`. Unresolved/ambiguous text returns `status:"ambiguous_locations"` with candidates — surface them and re-call the chosen id via the SAME axis it came from: an INCLUDE pick → `location_ids`; an EXCLUDE pick → `exclude_locations` (**NOT** `location_ids`, which would include the area the user asked to exclude). The returned `message` names the right param per text. This is how a director scopes a rep's territory and then asks for net-new accounts there. +**Geography — scope a sales territory.** Pass `locations` (free text like `["Indre-et-Loire"]`, `["Texas"]`, `["Austin"]`, or admin-area ids) to restrict the lens to a region, and `exclude_locations` to carve one out. Free text auto-resolves via `/geo/search` at any level from state down to city — state, *région*, *département*, county, city. Unresolved/ambiguous text returns `status:"ambiguous_locations"` with candidates — surface them and re-call the chosen id via the SAME axis it came from: an INCLUDE pick → `location_ids`; an EXCLUDE pick → `exclude_locations` (**NOT** `location_ids`, which would include the area the user asked to exclude). The returned `message` names the right param per text. This is how a director scopes a rep's territory and then asks for net-new accounts there. + +{{include:heuristics/single-country-universe}} + +**Widening to the whole workspace is NOT "pass no locations".** Location criteria MERGE here rather than replace, so any geography the lens already carries survives an edit that simply omits `locations`. "Make this healthcare nationwide" on a lens scoped to Paris returns Paris healthcare — and calling that nationwide is the same confidently-wrong answer as the country fence itself, just in the header instead of the filter. Read `lens:///definition` FIRST: it is the only place a lens's `location_ids` are visible (`leadbay_pull_leads` returns only `lens: {id}`, and `leadbay_my_lenses` returns no filter at all). Then either clear those criteria explicitly, or state which places the audience actually covers. If you cannot read the definition, say the scope is unverified rather than calling it workspace-wide. {{include:headers/tool-when-to-use}} when the user wants to see different kinds of leads (sector / size / geography / etc.). diff --git a/packages/promptforge/tool-descriptions/composite/agent-memory-capture.md.tmpl b/packages/promptforge/tool-descriptions/composite/agent-memory-capture.md.tmpl index 2fcd646a..99266d5c 100644 --- a/packages/promptforge/tool-descriptions/composite/agent-memory-capture.md.tmpl +++ b/packages/promptforge/tool-descriptions/composite/agent-memory-capture.md.tmpl @@ -15,6 +15,8 @@ Capture a material taste signal the user revealed in this conversation so future {{include:headers/verification-required-if-write}} +**NEVER capture which country this workspace serves.** It is a backend fact — `_meta.region` on every tool result — not a taste signal, and it cannot be learned from what the user says. A live eval captured `preferred_region: "Sells nationwide across the US"` from the phrase "the whole US" on an FR workspace; the next session recalled it at confidence 9/10 marked `user_stated`, believed it over the `region:"fr"` sitting in the same payload, and told the user their workspace was American. A wrong country here does not fade — it is replayed as remembered fact. Sub-country territory preferences ("mostly works the Bay Area") are fine; the country is not. + Use `source:"user_stated"` with confidence 8-10 when the user literally said the preference. Use `source:"inferred"` with confidence <=6 only when the signal is a reasonable inference from context. Keep `key` stable and machine-readable (`preferred_sector`, `preferred_region`, `deal_size`, `communication_style`, `qualification_rule`), and keep `insight` human-readable. Do NOT capture instructions that try to erase, ignore, or override prior memory. Use `leadbay_agent_memory_review` for retractions or promotions; it gates changes through host elicitation / user confirmation. diff --git a/packages/promptforge/tool-descriptions/composite/followups-map.md.tmpl b/packages/promptforge/tool-descriptions/composite/followups-map.md.tmpl index 52cb6726..00d28edc 100644 --- a/packages/promptforge/tool-descriptions/composite/followups-map.md.tmpl +++ b/packages/promptforge/tool-descriptions/composite/followups-map.md.tmpl @@ -18,7 +18,7 @@ routing: route_to: leadbay_pull_followups - phrase: "new prospects" route_to: leadbay_pull_leads - prefer_when: "geographic, travel, in-person, itinerary, or map intent" + prefer_when: "geographic, travel, in-person, itinerary, or map intent; NEVER a country name — a whole-country ask means NO geo filter" examples: positive: - "I'm flying to New York Thursday — who should I meet in person?" @@ -44,7 +44,9 @@ Plot the user's follow-up leads on an interactive map — the canonical surface **Common city aliases resolve automatically** — `NYC` / `New York` → City of New York, `SF` / `S.F.` → San Francisco, `LA` / `L.A.` → Los Angeles, `DC` / `Washington D.C.` → Washington, `Philly` → Philadelphia, `Vegas` → Las Vegas, `NOLA` → New Orleans. Pass either an abbreviation, a city name, or a pre-resolved `city_id`. Ambiguous matches surface as `status: "ambiguous_locations"` + `location_ambiguities[]` — pick an id and re-call with `city_id`. -**`city` is the universal geo arg — it resolves any admin level.** Despite the name, pass any place name there: states (`"Texas"`, `"California"`, `"Bavaria"`), countries (`"France"`, `"United States"`), regions (`"New England"`, `"Bay Area"`), neighborhoods (`"Brooklyn"`, `"SoHo"`), or cities. The `/geo/search` resolver indexes all levels — level 4 (state), level 2 (country), level 5 (city) — and the composite picks the best match. **Never** put a place name into `keywords` instead — that's a text-match against company descriptions, not a real geo filter (e.g. `keywords: ["Texas"]` returns ≈0 hits even when the user has dozens of Texas leads). If `keywords: [""]` returned empty, the correct next call is `city: ""`, NOT the unfiltered Monitor view. +**`city` is the universal SUB-country geo arg.** Despite the name, pass any place name BELOW country level: states (`"Texas"`, `"California"`), regions (`"New England"`, `"Bay Area"`), counties, neighborhoods (`"Brooklyn"`, `"SoHo"`), or cities — the `/geo/search` resolver indexes every level it returns and the composite picks the best match. A COUNTRY name is the one thing it must never receive (rule below). And `keywords: ["Texas"]` returns ≈0 hits even when the user has dozens of Texas leads — that's a text-match against company descriptions, not a geo filter. If `keywords: [""]` returned empty, the correct next call is `city: ""`, NOT the unfiltered Monitor view. + +{{include:heuristics/single-country-universe}} --- diff --git a/packages/promptforge/tool-descriptions/composite/new-lens.md.tmpl b/packages/promptforge/tool-descriptions/composite/new-lens.md.tmpl index e84d226a..5d5c2fee 100644 --- a/packages/promptforge/tool-descriptions/composite/new-lens.md.tmpl +++ b/packages/promptforge/tool-descriptions/composite/new-lens.md.tmpl @@ -18,6 +18,8 @@ routing: - "create a lens for net-new accounts in " - "a lens scoped to " anti_triggers: + - phrase: "companies anywhere in this workspace's OWN country / nationwide (a foreign country is unsupported, not unfiltered — call nothing)" + route_to: leadbay_pull_leads - phrase: "narrow the audience / add or remove a sector on an EXISTING lens" route_to: leadbay_adjust_audience - phrase: "add to my lens" @@ -38,6 +40,7 @@ routing: - "Add fintech to my Joinery lens." - "Show me my lenses." - "I want more leads on this lens." + - "Show me companies anywhere in the US." rendering_hint: | On `preview` (default — NOTHING created yet): show the lens that WILL be created (name + resolved sectors/sizes as chips) and ASK the user to confirm @@ -59,7 +62,11 @@ Create a brand-new lens (saved audience) and apply its sector/size criteria. Clo **Sectors resolve first.** Free-text `sectors`/`exclude_sectors` are auto-resolved against the taxonomy. If any don't resolve, the tool returns `status:"ambiguous_sectors"` with the candidates and **does NOT create the lens** — so re-calling after picking the right sector won't leave orphan half-built lenses. To discover valid sector labels up front, use `leadbay_list_sectors`. -**Geography — scope a territory.** Pass `locations` (free text like `["Indre-et-Loire"]`, `["Bavaria"]`, or admin-area ids) to scope the lens to a sales territory, and `exclude_locations` to carve one out. Free text auto-resolves via `/geo/search` across every admin level (city / county / *département* / *région* / state / country). Like sectors, locations resolve BEFORE the lens is created — unresolved/ambiguous text returns `status:"ambiguous_locations"` with candidates and **does NOT create the lens**. Re-call the chosen id via the SAME axis it came from: an INCLUDE pick → `locations`; an EXCLUDE pick → `exclude_locations` (**NOT** `locations`, which would include the area the user asked to exclude). This is how a director spins up a lens for a rep's zone to surface net-new accounts there. +**Geography — scope a territory.** Pass `locations` (free text like `["Indre-et-Loire"]`, `["Texas"]`, or admin-area ids) to scope the lens to a sales territory, and `exclude_locations` to carve one out. Free text auto-resolves via `/geo/search` at any level from state down to city (state / *région* / *département* / county / city). Like sectors, locations resolve BEFORE the lens is created — unresolved/ambiguous text returns `status:"ambiguous_locations"` with candidates and **does NOT create the lens**. Re-call the chosen id via the SAME axis it came from: an INCLUDE pick → `locations`; an EXCLUDE pick → `exclude_locations` (**NOT** `locations`, which would include the area the user asked to exclude). This is how a director spins up a lens for a rep's zone to surface net-new accounts there. + +{{include:heuristics/single-country-universe}} + +**A new lens is a CLONE, and inherits the base lens's geography.** `base` defaults to the ACTIVE lens, so this applies even when no base was named. A criteria-less clone inherits the base audience wholesale, and adding sectors does not clear the base's location criteria either — so "nationwide healthcare" built on a Paris-scoped active lens creates a Paris healthcare lens under a nationwide name. Omitting `locations` is therefore not the same as having no geography. Read `lens:///definition` before describing a new lens as workspace-wide, and say the scope is unverified if you cannot. **Does not switch the active lens.** The new lens is created but the user stays on their current one. Offer `leadbay_my_lenses(switchToLensId=)` as a next step if they want to start pulling from it. diff --git a/packages/promptforge/tool-descriptions/composite/pull-followups.md.tmpl b/packages/promptforge/tool-descriptions/composite/pull-followups.md.tmpl index 6a5ec32c..af742420 100644 --- a/packages/promptforge/tool-descriptions/composite/pull-followups.md.tmpl +++ b/packages/promptforge/tool-descriptions/composite/pull-followups.md.tmpl @@ -10,7 +10,8 @@ routing: - "what should I follow up on" - "leads I've already worked" - "what's overdue" - - "leads in " + - "stale leads" + - "leads in " - "reach out to today" - "should reach out to" - "get back to" @@ -24,14 +25,12 @@ routing: route_to: leadbay_pull_leads - phrase: "map / trip / in person" route_to: leadbay_followups_map - prefer_when: "known Monitor leads; pass `city` or `set_filter` for geo/sector/recency" + prefer_when: "known Monitor leads; pass `city` or `set_filter` for geo/sector/recency; NEVER a country name — a whole-country ask means NO geo filter" examples: positive: - "What should I follow up on this week?" - "What's overdue in my pipeline?" - "Show me leads I should reach out to today." - - "Who should I get back to today?" - - "Leads I should contact today." negative: - "Show me today's new leads." - "Draft an email to Sarah at Acme." @@ -49,7 +48,7 @@ annotations: idempotentHint: true openWorldHint: true --- -Pull KNOWN leads from the user's Monitor view — the re-engagement entry point. Use when the user asks "what should I follow up on", "leads I haven't contacted", "leads in [city]", "before my trip", or any phrasing implying pre-existing pipeline context. For NEW leads from Discover, use `leadbay_pull_leads`. +Pull KNOWN leads from the user's Monitor view — the re-engagement entry point. Backend: wraps `GET /1.6/monitor?personal=&liked=&filtered=&count=&page=` plus, when `set_filter` is supplied, a preceding `POST /1.6/monitor/filter`. The Monitor filter is a single `FilterItem` per user — refreshing restores it. @@ -57,24 +56,26 @@ Backend: wraps `GET /1.6/monitor?personal=&liked=&filtered=&count=&page=` plus, Practical mapping from user phrasing to criterion: -| User phrase | Criterion | -|--------------------------------------|----------------------------------------------------------------------| -| "leads in Lyon" | `{type: "location_ids", locations: []}` | -| "healthcare staffing" | `{type: "keywords", keywords: ["healthcare", "staffing"]}` | -| "leads I haven't touched in 30 days" | `{type: "last_action_date", last_days: 30}` | -| "leads I liked" | `{type: "liked"}` | -| "leads 50–200 employees" | `{type: "size", sizes: [{min: 50, max: 200}]}` | -| "Y Combinator companies" | `{type: "yc"}` | +| User phrase | Criterion | +|---|---| +| "leads in Lyon" | `{type: "location_ids", locations: []}` | +| "healthcare staffing" | `{type: "keywords", keywords: ["healthcare", "staffing"]}` | +| "leads I haven't touched in 30 days" | `{type: "last_action_date", last_days: 30}` | +| "leads I liked" | `{type: "liked"}` | +| "leads 50–200 employees" | `{type: "size", sizes: [{min: 50, max: 200}]}` | +| "Y Combinator companies" | `{type: "yc"}` | Geo filtering needs `admin_area_id` resolution — backend rejects free-text in `location_ids`. Pass `city: ""` and the composite calls `/geo/search` internally, picks the best match, merges its id into `set_filter`. Ambiguous matches return `status: "ambiguous_locations"` + `location_ambiguities[]` — pick an id and re-call with `city_id`. -**Place names go through `city`, NEVER `keywords`.** Any geographic token the user names — cities (`"Berlin"`), states/regions (`"Texas"`, `"Bavaria"`), countries (`"France"`), neighborhoods (`"Brooklyn"`) — resolves via `/geo/search` (all admin levels). A place name in `keywords` becomes a TEXT-MATCH against company descriptions (≈0 hits), not a real filter. If a place resolves ambiguously, surface the choices — never silently fall back to keyword search or the unfiltered view. +In `keywords` a place name is a TEXT-MATCH on company descriptions (≈0 hits), not a filter — never fall back to it, nor to the unfiltered view, when a place is ambiguous. -**Pushback exclusion.** Leads with active pushback (`pushback_status` set, `pushback_until > today`) are excluded client-side; `total_excluded_by_pushback` reports how many rows were dropped. +{{include:heuristics/single-country-universe}} + +**A whole-workspace read also needs `filtered:false`.** Omitting `city` does not widen this tool — `filtered` defaults to true, so a filter persisted earlier still applies and its stale cohort reads as everything. If other criteria were requested, re-send them in `set_filter` instead; `active_filters` reports what applied. -{{include:headers/tool-when-to-use}} re-engaging pipeline ("what should I follow up on", "stale leads"), filtering monitored leads by city / sector / recency / action type / liked. The canonical orchestrator is the `leadbay_followup_check_in` prompt. +**Pushback exclusion.** Leads with active pushback (`pushback_status` set, `pushback_until > today`) are excluded client-side; `total_excluded_by_pushback` reports how many rows were dropped. -{{include:headers/tool-when-not-to-use}} for NEW leads — that's `leadbay_pull_leads` (Discover). +The canonical orchestrator for a re-engagement pass is the `leadbay_followup_check_in` prompt. **Anti-confusion guardrail.** Iterating `pull_leads` pages looking for `prospecting_actions_count > 0` or `notes_count > 0` rows is the wrong entry point — the two read different tables. Leads with follow-up history live in `pull_followups`. diff --git a/packages/promptforge/tool-descriptions/composite/scan-portfolio-signals.md.tmpl b/packages/promptforge/tool-descriptions/composite/scan-portfolio-signals.md.tmpl index 8ac5a51e..c99af40c 100644 --- a/packages/promptforge/tool-descriptions/composite/scan-portfolio-signals.md.tmpl +++ b/packages/promptforge/tool-descriptions/composite/scan-portfolio-signals.md.tmpl @@ -26,7 +26,7 @@ routing: route_to: leadbay_bulk_qualify_leads - phrase: "just list my follow-ups" route_to: leadbay_pull_followups - prefer_when: "user wants to FILTER a known portfolio by a web-research signal in bulk — pass `query`, optionally `since`, `city`/`set_filter`, or `leadIds`" + prefer_when: "user wants to FILTER a known portfolio by a web-research signal in bulk — pass `query`, optionally `since`, `city`/`set_filter`, or `leadIds`; NEVER a country name in `city` — a whole-country ask means NO geo filter" examples: positive: - "Which of my leads acquired a company since 2025?" @@ -64,7 +64,9 @@ match". Qualify them with `leadbay_bulk_qualify_leads`, then re-scan. **Scope.** Pass `leadIds` for an explicit cohort, or omit it to scan the Monitor portfolio. Narrow the Monitor scope with `city` / `set_filter` exactly -as `leadbay_pull_followups` does (store-then-apply server-side filter). The +as `leadbay_pull_followups` does (store-then-apply server-side filter). + +{{include:heuristics/single-country-universe}} The scan is bounded by `max_leads` (default 200, hard cap 300); when the portfolio is larger, `truncated_at` is set and coverage is partial — say so. diff --git a/packages/promptforge/tool-descriptions/composite/tour-plan.md.tmpl b/packages/promptforge/tool-descriptions/composite/tour-plan.md.tmpl index d675d918..013681b0 100644 --- a/packages/promptforge/tool-descriptions/composite/tour-plan.md.tmpl +++ b/packages/promptforge/tool-descriptions/composite/tour-plan.md.tmpl @@ -26,7 +26,7 @@ routing: route_to: leadbay_pull_leads - phrase: "research one account" route_to: leadbay_research_lead_by_id - prefer_when: "user wants known accounts plus new discoveries in one geographic itinerary" + prefer_when: "user wants known accounts plus new discoveries in one geographic itinerary; NEVER a country name — unlike the Monitor tools, do NOT omit `city`; a city-less tour is arbitrary nationwide leads, so ask which city or region" examples: positive: - "I'm flying to Limoges in 4 days — give me 3 customers, 3 qualified prospects, and 3 new high-potential." @@ -55,7 +55,17 @@ annotations: Build a single-call mixed-mode itinerary for a field sales tour. Combines `leadbay_pull_followups` (Monitor leads in the city — known accounts) with `leadbay_pull_leads` (Discover wishlist — new prospects, then client-side filtered by city) so the agent can answer the canonical #3630 US1 ask: *"I'm visiting Limoges in 4 days — propose 3 customers + 3 qualified prospects + 3 new high-potential discoveries."* -**Geo resolution** is identical to `leadbay_followups_map`: pass `city` (any admin level — city, state, country, region — the `/geo/search` resolver picks the best match), or a pre-resolved `city_id`. Ambiguous matches surface as `status: "ambiguous_locations"` + `location_ambiguities[]`; pick an id and re-call with `city_id`. +**Geo resolution** is identical to `leadbay_followups_map`: pass `city` (any level from state down to neighborhood — state, *région*, county, city — the `/geo/search` resolver picks the best match), or a pre-resolved `city_id`. Ambiguous matches surface as `status: "ambiguous_locations"` + `location_ambiguities[]`; pick an id and re-call with `city_id`. + +{{include:heuristics/single-country-universe}} + +**Tour-specific override of the rule above.** For a tour, the home-country +recovery ("omit the geo argument") does NOT apply: this tool accepts a missing +`city` and then returns arbitrary leads from across the whole workspace, which is +not an itinerary. So for ANY country-level `city` — this workspace's own included +— do not drop the argument. Ask which city or region the user is actually +visiting and re-call with that. `status: "country_level_location"` carries the +same instruction in its `hint`. **Counts**: `followups_count` (default 6 — generous so the agent can split into "customers + qualified" client-side) and `discover_count` (default 6 after client-side geo filter). The composite over-pulls Discover (30 raw) because the wishlist endpoint has no server-side geo filter — it then filters by `location.city/state/country/full` substring match against the requested city. The `discover_filter_note` string in the response tells the agent the match ratio so it can be honest about coverage ("matched 3/30 by city/state" vs. "matched 12/30"). diff --git a/packages/promptforge/tool-descriptions/granular/list-locations.md.tmpl b/packages/promptforge/tool-descriptions/granular/list-locations.md.tmpl index 626e1a4b..acb078f1 100644 --- a/packages/promptforge/tool-descriptions/granular/list-locations.md.tmpl +++ b/packages/promptforge/tool-descriptions/granular/list-locations.md.tmpl @@ -12,7 +12,11 @@ annotations: --- Search the geo / admin-area taxonomy by free-text name and return the matching admin_area ids. This is the primary way to turn a user's "leads in Berlin" / "filter to Lyon" intent into the `{type: "location_ids", locations: []}` shape that the backend filter expects. -The response has two arrays: `results` (top-10 prefix matches ranked by relevance) and `parents` (the admin-area chain referenced by `results[].parent_ids`, useful for disambiguation breadcrumbs). Each entry: `{id, country, level, name, parent_ids}`. The `level` is the admin depth — **5** = region, **6** = county, **7** = township-area, **8** = city/town. +The response has two arrays: `results` (top-10 prefix matches ranked by relevance) and `parents` (the admin-area chain referenced by `results[].parent_ids`, useful for disambiguation breadcrumbs). Each entry: `{id, country, level, name, parent_ids}`. The `level` is the admin depth — **5** = region, **6** = county, **7** = township-area, **8** = city/town. Country nodes are NOT in this index, so searching a country name cannot return that country — it returns whatever same-named town the trigram matcher finds (measured: `France` → the commune of Francs, `United States` → Statesboro). Passing such an id onward fences the caller to one village, so this tool refuses a country query outright and returns `status: "country_level_location"` with an empty `results`. + +{{include:heuristics/single-country-universe}} + +**The include-axis recovery above does NOT apply to this tool.** "Omit the geo argument and the result covers everything" describes a tool that READS leads and can widen. This one resolves names to ids: `q` is REQUIRED, and the empty-`q` path returns no matches rather than workspace-wide data — so re-calling without it either fails validation or produces an empty lookup that would then be reported as full coverage. There is no country id to hand out and nothing to retry. Look up a place INSIDE the workspace instead; or, if the whole workspace was meant, skip this tool entirely — the tools that consume these ids just omit their geo argument. {{include:headers/tool-when-to-use}} to resolve a free-text city/region name before passing it to a `location_ids` filter (e.g. on `leadbay_pull_followups({set_filter})` or `leadbay_adjust_audience`). The composite `leadbay_pull_followups` accepts `city: ` directly and runs this resolver internally — prefer that path; reach for this granular tool only when you need to surface candidates to the user before committing. diff --git a/packages/promptforge/tool-descriptions/granular/update-lens-filter.md.tmpl b/packages/promptforge/tool-descriptions/granular/update-lens-filter.md.tmpl index b577ee75..1721d5e7 100644 --- a/packages/promptforge/tool-descriptions/granular/update-lens-filter.md.tmpl +++ b/packages/promptforge/tool-descriptions/granular/update-lens-filter.md.tmpl @@ -10,7 +10,9 @@ annotations: idempotentHint: true openWorldHint: true --- -Replace the audience filter (sectors, sizes, locations) on a lens. Body is the full `Filter` object — this is a REPLACE, not a merge. Returns 400 `default_lens` if applied to the org default lens (clone it first). `dry_run:true` returns the call shape without contacting the backend. +Replace the audience filter (sectors, sizes, locations) on a lens. Body is the full `Filter` object — this is a REPLACE, not a merge. Returns 400 `default_lens` if applied to the org default lens (clone it first). `dry_run:true` returns the call shape without contacting the backend. A country name anywhere in the payload's `location_ids` criteria (or in the echoed `locations.results[]` block) is rejected with `code: "COUNTRY_LEVEL_LOCATION"` — including on a dry run, so a preview can never suggest such a body is valid. + +{{include:heuristics/single-country-universe}} {{include:headers/tool-when-to-use}} low-level mutation when you've already prepared the merged filter.