From d1b856c173ff02b3a1fcef9cd4b6e17728b382d7 Mon Sep 17 00:00:00 2001 From: milstan Date: Wed, 2 Sep 2026 13:34:15 -0700 Subject: [PATCH] fix(mcp): pinning a suggested contact is answered, not retried (product#4050) leadbay_pin_contact failed 43 of its 48 production calls in the 180 days to 2026-09-02. Sentry MCP-3A holds exactly 43 events, so every "contact not found" in the mcp project is a pin call. 41 belong to one scheduled agent, zoe+dogfood@leadbay.ai, across 12 days and MCP 0.26.0 through 0.33.2. It picks the DG out of leadbay_research_lead_by_id and pins that id, which on an unenriched company is a source:"paid" candidate. Pin is its third most used tool and has never once succeeded. POST /contacts/{id}/pin resolves through org_contacts only, so a paid candidate id can never resolve there. The client's shared 404 hint is "Verify the ID is correct" (client.ts:1048). On this endpoint that is false: the id is correct, it is the wrong namespace. An agent reads it as "look it up and retry" and does, in bursts of up to 10 calls in 13 seconds. pin and unpin now catch NOT_FOUND and replace the hint with one naming the org-vs-paid split, saying not to retry, and naming the tools that make the person pinnable. Both descriptions carry the same rule via a new shared snippet, with the short form in prefer_when so it lands in the first 600 chars every host reads. The snippet also states that pinning does not steer enrichment: enrichment selects by job title and pinnedBy plays no part. That was the second half of the agent's mistake. Separately, pinned was invisible. The backend's ContactPayload has carried pinned + pinned_by_ai all along; every MCP shaping site dropped them, so a pin could be written but only read back by watching recommended, which moves for other reasons too. Now passed through in research_lead_by_id, get_contacts and get_lead_profile, and marked in both markdown contact lists. Paid contacts get no pinned key rather than a synthetic false, matching PaidContactPayload. Verified live against FR staging with the built branch: pinned false to true to false across pin and unpin, paid contacts carrying no pin state throughout. Not fixed here: on hosted an agent still has no way to enrich one named person. leadbay_enrich_contacts takes a contact_id and does exactly that, but sits in granularWriteTools behind LEADBAY_MCP_ADVANCED=1, and no granular tool has been called from a hosted IP in 30 days. That is why the agent reached for pin. Filed as leadbay/product#4050. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 16 + .../core/src/composite/research-lead-by-id.ts | 18 +- .../core/src/tool-descriptions.generated.ts | 40 ++- packages/core/src/tools/add-contact.ts | 4 + packages/core/src/tools/get-contacts.ts | 16 +- packages/core/src/tools/get-lead-profile.ts | 4 + packages/core/src/tools/pin-contact.ts | 24 +- packages/core/src/tools/unpin-contact.ts | 11 +- packages/core/src/types.ts | 6 + .../contact-pin-state-passthrough.test.ts | 301 ++++++++++++++++++ .../tools/pin-contact-not-found-hint.test.ts | 105 ++++++ packages/mcp/CHANGELOG.md | 54 ++++ packages/mcp/package.json | 2 +- packages/mcp/server.json | 4 +- .../snippets/heuristics/pinnable-contacts.md | 13 + .../granular/pin-contact.md.tmpl | 6 +- .../granular/unpin-contact.md.tmpl | 8 +- 17 files changed, 615 insertions(+), 17 deletions(-) create mode 100644 packages/core/test/unit/composite/contact-pin-state-passthrough.test.ts create mode 100644 packages/core/test/unit/tools/pin-contact-not-found-hint.test.ts create mode 100644 packages/promptforge/snippets/heuristics/pinnable-contacts.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f7cee5c..dcc33a23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.33.3 — 2026-09-02 — Pinning a contact says what it can and cannot do + +- **Pinning someone Leadbay only suggested no longer looks like a breakage.** + You can pin the people already in your contact list. Someone Leadbay has + suggested for a company but not yet enriched cannot be pinned. The assistant + used to get back a bare "contact not found", read it as Leadbay being broken, + and try again several times. It now knows the difference, says so, and tells + you how to get that person into your list. +- **Pinning does not decide who gets enriched.** Asking for the managing + director rather than the president is a matter of which job title you ask + Leadbay to enrich, not which contact you pin. The old wording suggested + otherwise and the assistant believed it. +- **You can see who is pinned.** Whether a contact is pinned, and whether + Leadbay pinned them for you rather than you doing it, now comes back with the + contact instead of having to be inferred. + ## 0.33.2 — 2026-09-02 — Correcting a contact no longer wipes their email - **Fixing one detail on a contact used to delete the others.** Asking the diff --git a/packages/core/src/composite/research-lead-by-id.ts b/packages/core/src/composite/research-lead-by-id.ts index e084b038..7306dd45 100644 --- a/packages/core/src/composite/research-lead-by-id.ts +++ b/packages/core/src/composite/research-lead-by-id.ts @@ -122,7 +122,8 @@ export function renderResearchLeadMarkdown( const ln = (c.last_name ?? "") as string; const title = c.job_title ?? "—"; const channel = c.email ?? c.phone_number ?? "—"; - out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}** — ${title} · ${channel}`); + const pin = c.pinned ? " 📌" : ""; + out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}**${pin} — ${title} · ${channel}`); } } const candidates = Array.isArray(contacts.candidates) @@ -135,7 +136,12 @@ export function renderResearchLeadMarkdown( const ln = (c.last_name ?? "") as string; const title = c.job_title ?? "—"; const li = c.linkedin_page ? `LinkedIn` : "no LinkedIn"; - out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}** — ${title} · ${li}`); + // An org contact with no email/phone is unreachable, so it lands here + // rather than in `reachable` — but it is still pinnable, and may be + // pinned right now. Mark it in both partitions or the pin disappears + // from the rendering for exactly the contacts that have no channel yet. + const pin = c.pinned ? " 📌" : ""; + out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}**${pin} — ${title} · ${li}`); } if (candidates.length > 10) out.push(`- _${candidates.length - 10} more …_`); } @@ -360,7 +366,7 @@ export const researchLeadById: Tool = { contacts: { type: "object", description: - "Two-tier contact set, partitioned by reachability — agent-friendly framing of the backend's paid-vs-org split. `reachable`: contacts with an email or phone right now (org-directory entries that ship with channels, PLUS paid contacts whose enrichment has completed). The agent can message these without buying enrichment. `candidates`: paid-contact entries WITHOUT resolved channels yet — typically LinkedIn URL only, `enrichment_done: false`. The agent must call leadbay_enrich_titles (or leadbay_prepare_outreach with enrich:true) before these become messagable. Every contact in both lists carries `source`: `\"org\"` means it is a row in your organization's own contact directory, `\"paid\"` means it came from enrichment. The two are separate id namespaces on the backend, so only a `source:\"org\"` id can be passed to leadbay_update_contact / leadbay_remove_contact — a `\"paid\"` id returns NOT_FOUND there.", + "Two-tier contact set, partitioned by reachability — agent-friendly framing of the backend's paid-vs-org split. `reachable`: contacts with an email or phone right now (org-directory entries that ship with channels, PLUS paid contacts whose enrichment has completed). The agent can message these without buying enrichment. `candidates`: paid-contact entries WITHOUT resolved channels yet — typically LinkedIn URL only, `enrichment_done: false`. The agent must call leadbay_enrich_titles (or leadbay_prepare_outreach with enrich:true) before these become messagable. Every contact in both lists carries `source`: `\"org\"` means it is a row in your organization's own contact directory, `\"paid\"` means it came from enrichment. The two are separate id namespaces on the backend, so only a `source:\"org\"` id can be passed to leadbay_update_contact / leadbay_remove_contact / leadbay_pin_contact / leadbay_unpin_contact — a `\"paid\"` id returns NOT_FOUND there, which means \"this candidate is not an org contact yet\", NOT that the tool is broken. Only `source:\"org\"` contacts carry `pinned` (someone flagged this person as the priority) and `pinned_by_ai` (that someone was Leadbay's AI, not a human); paid candidates have no pin state because they cannot be pinned.", properties: { reachable: { type: "array", items: { type: "object" } }, candidates: { type: "array", items: { type: "object" } }, @@ -531,6 +537,12 @@ export const researchLeadById: Tool = { linkedin_page: normalizeLinkedinPage(c.linkedin_page ?? null), recommended: c.recommended, enrichment_done: true, + // Pin state exists on org contacts only — mirror the backend, which + // omits it entirely from PaidContactPayload. `pinned` is what makes a + // pin readable at all; without it the agent can only infer the pin from + // `recommended`, which also moves for non-pin reasons. + pinned: c.pinned ?? false, + pinned_by_ai: c.pinned_by_ai ?? false, source: "org" as const, }); const allContacts: Array | ReturnType> = [ diff --git a/packages/core/src/tool-descriptions.generated.ts b/packages/core/src/tool-descriptions.generated.ts index 52ad1d34..4af607c0 100644 --- a/packages/core/src/tool-descriptions.generated.ts +++ b/packages/core/src/tool-descriptions.generated.ts @@ -2592,7 +2592,7 @@ Trigger phrases: "pin this contact", "mark this person as priority", "make this Do NOT use for: "unpin / remove the pin" → \`leadbay_unpin_contact\`; "add a contact to this company" → \`leadbay_add_contact\`; "remove / delete this contact" → \`leadbay_remove_contact\`. -Prefer when: user wants ONE person flagged as the priority on a company — pass that contact's own \`contact_id\` +Prefer when: user wants ONE person flagged as the priority on a company — pass that contact's own \`contact_id\`, and ONLY a \`source:"org"\` contact can be pinned (a \`source:"paid"\` candidate returns 'contact not found') Examples that SHOULD invoke this tool: - "Pin Jane Doe as the main contact on this company." @@ -2614,9 +2614,24 @@ Pin a single contact on a company so it surfaces first as a priority / favourite Pass the contact's **own** \`contact_id\` (the \`id\` field on a contact object from \`leadbay_research_lead_by_id\` or a contacts list) — **not** the parent lead id. +**Only \`source: "org"\` contacts are pinnable.** Every contact returned by \`leadbay_research_lead_by_id\` carries a \`source\` field, and the two sources are separate id namespaces on the backend: + +- \`source: "org"\` — a row in your organization's own contact directory. Pinnable. Also carries \`pinned\` (true when someone has pinned it) and \`pinned_by_ai\` (true when Leadbay's AI pinned it rather than a human). +- \`source: "paid"\` — an enrichment *candidate* (the \`candidates\` bucket): a person Leadbay suggests but has not yet resolved into your directory. NOT pinnable, and carries no \`pinned\` field at all. + +Passing a \`source: "paid"\` id here returns **\`contact not found\`**. That is the expected answer for a candidate, not an outage and not a transient error: nothing is broken, the person is simply not an org contact yet. Do not retry, do not re-fetch the lead hoping for a different result, and do not tell the user that pinning is failing or unavailable. + +To pin someone who is currently only a candidate, first make them an org contact: + +- \`leadbay_enrich_titles\` (or \`leadbay_prepare_outreach\` with \`enrich: true\`) resolves the candidate and writes a NEW org contact for that person. It has a **different \`id\`** from the paid candidate, so re-read the contacts list afterwards and pin the \`source: "org"\` row. +- Or add them directly with \`leadbay_add_contact\`, which returns the new org contact's \`id\` — that id is pinnable immediately. + +**Pinning does not steer enrichment.** It only marks who the priority is on a company the user already has. Enrichment picks people by JOB TITLE, so "enrich the Directeur Général rather than the Président" is \`leadbay_enrich_titles\` with the wanted title — not a pin. Pinning first and enriching after changes nothing about who gets enriched. + + Backend: \`POST /contacts/{contact_id}/pin\` → 204. Idempotent. The inverse is \`leadbay_unpin_contact\`. -Returns \`{ pinned: true, contact_id, action: "pinned" }\`. +Returns \`{ pinned: true, contact_id, action: "pinned" }\`. To read the resulting state back, re-call \`leadbay_research_lead_by_id\` — the pinned contact's \`pinned\` flips to \`true\` and it becomes the lead's \`recommended\` contact. Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw). `; @@ -4657,7 +4672,7 @@ Trigger phrases: "unpin this contact", "remove the pin from this contact", "this Do NOT use for: "pin / mark as priority" → \`leadbay_pin_contact\`; "remove / delete this contact" → \`leadbay_remove_contact\`. -Prefer when: user wants to clear the pinned flag on a contact (but keep the contact) — pass that contact's own \`contact_id\` +Prefer when: user wants to clear the pinned flag on a contact (but keep the contact) — pass that contact's own \`contact_id\`, and ONLY a \`source:"org"\` contact can be unpinned (a \`source:"paid"\` candidate returns 'contact not found') Examples that SHOULD invoke this tool: - "Unpin Jane Doe — she's not the priority anymore." @@ -4680,9 +4695,26 @@ Unpin a single contact on a company — clears its priority / favourite flag. Th Pass the contact's **own** \`contact_id\` — not the parent lead id. +**Only \`source: "org"\` contacts are pinnable.** Every contact returned by \`leadbay_research_lead_by_id\` carries a \`source\` field, and the two sources are separate id namespaces on the backend: + +- \`source: "org"\` — a row in your organization's own contact directory. Pinnable. Also carries \`pinned\` (true when someone has pinned it) and \`pinned_by_ai\` (true when Leadbay's AI pinned it rather than a human). +- \`source: "paid"\` — an enrichment *candidate* (the \`candidates\` bucket): a person Leadbay suggests but has not yet resolved into your directory. NOT pinnable, and carries no \`pinned\` field at all. + +Passing a \`source: "paid"\` id here returns **\`contact not found\`**. That is the expected answer for a candidate, not an outage and not a transient error: nothing is broken, the person is simply not an org contact yet. Do not retry, do not re-fetch the lead hoping for a different result, and do not tell the user that pinning is failing or unavailable. + +To pin someone who is currently only a candidate, first make them an org contact: + +- \`leadbay_enrich_titles\` (or \`leadbay_prepare_outreach\` with \`enrich: true\`) resolves the candidate and writes a NEW org contact for that person. It has a **different \`id\`** from the paid candidate, so re-read the contacts list afterwards and pin the \`source: "org"\` row. +- Or add them directly with \`leadbay_add_contact\`, which returns the new org contact's \`id\` — that id is pinnable immediately. + +**Pinning does not steer enrichment.** It only marks who the priority is on a company the user already has. Enrichment picks people by JOB TITLE, so "enrich the Directeur Général rather than the Président" is \`leadbay_enrich_titles\` with the wanted title — not a pin. Pinning first and enriching after changes nothing about who gets enriched. + + +A \`source: "org"\` contact that was never pinned is a no-op here, not an error — the backend answers 204 either way. Check \`pinned\` on the contact before calling if you need to tell the user whether anything actually changed. + Backend: \`POST /contacts/{contact_id}/unpin\` → 204. Idempotent. The inverse is \`leadbay_pin_contact\`. -Returns \`{ pinned: false, contact_id, action: "unpinned" }\`. +Returns \`{ pinned: false, contact_id, action: "unpinned" }\`. To read the resulting state back, re-call \`leadbay_research_lead_by_id\` — the contact's \`pinned\` flips to \`false\` and the lead's \`recommended\` contact reverts to the title-matched default. Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw). `; diff --git a/packages/core/src/tools/add-contact.ts b/packages/core/src/tools/add-contact.ts index 2f55a509..0ac266d6 100644 --- a/packages/core/src/tools/add-contact.ts +++ b/packages/core/src/tools/add-contact.ts @@ -25,7 +25,11 @@ interface CreatedContact { job_title: string | null; can_enrich?: boolean; recommended?: boolean; + // Pin state as the backend returns it. A freshly created org contact is + // never pinned, but the fields ship so the shape matches every other org + // contact the agent sees — and this id IS pinnable via leadbay_pin_contact. pinned?: boolean; + pinned_by_ai?: boolean; } interface AddContactResult { diff --git a/packages/core/src/tools/get-contacts.ts b/packages/core/src/tools/get-contacts.ts index 13494a00..6daddf0f 100644 --- a/packages/core/src/tools/get-contacts.ts +++ b/packages/core/src/tools/get-contacts.ts @@ -34,7 +34,7 @@ export const getContacts: Tool = { contacts: { type: "array", description: - "Merged org+paid contacts. Each: {id, first_name, last_name, email, phone_number, linkedin_page, job_title, recommended, enrichment, source:'org'|'paid'}.", + "Merged org+paid contacts. Each: {id, first_name, last_name, email, phone_number, linkedin_page, job_title, recommended, enrichment, source:'org'|'paid'}. `source:'org'` entries additionally carry {pinned, pinned_by_ai}; `source:'paid'` entries do not, because a paid candidate cannot be pinned — passing its id to leadbay_pin_contact / leadbay_unpin_contact returns NOT_FOUND.", items: { type: "object", properties: { @@ -46,6 +46,16 @@ export const getContacts: Tool = { linkedin_page: { type: ["string", "null"] }, job_title: { type: ["string", "null"] }, recommended: { type: "boolean" }, + pinned: { + type: "boolean", + description: + "Someone flagged this person as the priority on the company. Present on source:'org' contacts only.", + }, + pinned_by_ai: { + type: "boolean", + description: + "The pin came from Leadbay's AI rather than a human. Present on source:'org' contacts only.", + }, source: { type: "string", enum: ["org", "paid"] }, enrichment: { type: ["object", "null"], @@ -123,6 +133,10 @@ export const getContacts: Tool = { job_title: c.job_title, recommended: c.recommended, enrichment: c.enrichment, + // Org contacts only — the backend's PaidContactPayload carries no + // pin state, because a paid candidate cannot be pinned. + pinned: c.pinned ?? false, + pinned_by_ai: c.pinned_by_ai ?? false, source: "org" as const, })), ...paidContacts.map((c) => ({ diff --git a/packages/core/src/tools/get-lead-profile.ts b/packages/core/src/tools/get-lead-profile.ts index e213a8b4..a273d53c 100644 --- a/packages/core/src/tools/get-lead-profile.ts +++ b/packages/core/src/tools/get-lead-profile.ts @@ -144,6 +144,10 @@ export const getLeadProfile: Tool = { job_title: c.job_title, recommended: c.recommended, enrichment: c.enrichment, + // Org contacts only — the backend's PaidContactPayload carries no pin + // state, because a paid candidate cannot be pinned. + pinned: c.pinned ?? false, + pinned_by_ai: c.pinned_by_ai ?? false, source: "org" as const, })), ...paidContacts.map((c) => ({ diff --git a/packages/core/src/tools/pin-contact.ts b/packages/core/src/tools/pin-contact.ts index 59db3051..10d8789c 100644 --- a/packages/core/src/tools/pin-contact.ts +++ b/packages/core/src/tools/pin-contact.ts @@ -2,6 +2,18 @@ import type { LeadbayClient } from "../client.js"; import type { Tool, ToolContext } from "../types.js"; import { leadbay_pin_contact as PIN_CONTACT_DESCRIPTION } from "../tool-descriptions.generated.js"; +/** + * Replaces the client's generic 404 hint on the pin/unpin endpoints. Shared + * with unpin-contact.ts so both answer a NOT_FOUND the same way. + */ +export const NOT_PINNABLE_HINT = + "This contact id is not in your organization's contact directory, so it cannot be pinned or unpinned. " + + "Almost always it is a `source: \"paid\"` enrichment candidate from leadbay_research_lead_by_id's `candidates` list; " + + "only `source: \"org\"` contacts are pinnable. The id is not wrong and the tool is not broken, so do NOT retry it. " + + "To act on this person, enrich them by job title with leadbay_enrich_titles, or add them with leadbay_add_contact — " + + "either produces a NEW org contact with a different id, which is pinnable. " + + "Note that pinning does not decide who gets enriched; enrichment selects people by job title."; + interface PinContactParams { // The contact's own UUID (the `id` on a contact object) — NOT the lead id. contact_id: string; @@ -46,7 +58,17 @@ export const pinContact: Tool = { params: PinContactParams, _ctx?: ToolContext, ): Promise => { - await client.requestVoid("POST", `/contacts/${params.contact_id}/pin`); + try { + await client.requestVoid("POST", `/contacts/${params.contact_id}/pin`); + } catch (e: any) { + // The generic 404 hint is "Verify the ID is correct", which for this + // endpoint is wrong advice: the id IS correct, it is just a paid + // candidate rather than an org contact. Agents read that hint as "look + // it up again and retry" and hammer the endpoint (43 of 48 production + // pin calls in the 180 days to 2026-09-02 were this 404). + if (e?.code === "NOT_FOUND") throw { ...e, hint: NOT_PINNABLE_HINT }; + throw e; + } return { pinned: true, contact_id: params.contact_id, action: "pinned" }; }, }; diff --git a/packages/core/src/tools/unpin-contact.ts b/packages/core/src/tools/unpin-contact.ts index 01b1a707..61cb4a46 100644 --- a/packages/core/src/tools/unpin-contact.ts +++ b/packages/core/src/tools/unpin-contact.ts @@ -1,6 +1,7 @@ import type { LeadbayClient } from "../client.js"; import type { Tool, ToolContext } from "../types.js"; import { leadbay_unpin_contact as UNPIN_CONTACT_DESCRIPTION } from "../tool-descriptions.generated.js"; +import { NOT_PINNABLE_HINT } from "./pin-contact.js"; interface UnpinContactParams { // The contact's own UUID (the `id` on a contact object) — NOT the lead id. @@ -46,7 +47,15 @@ export const unpinContact: Tool = { params: UnpinContactParams, _ctx?: ToolContext, ): Promise => { - await client.requestVoid("POST", `/contacts/${params.contact_id}/unpin`); + try { + await client.requestVoid("POST", `/contacts/${params.contact_id}/unpin`); + } catch (e: any) { + // Same reasoning as pin-contact.ts: the generic "Verify the ID is + // correct" hint sends the agent into a retry loop on an id that can + // never resolve here. + if (e?.code === "NOT_FOUND") throw { ...e, hint: NOT_PINNABLE_HINT }; + throw e; + } return { pinned: false, contact_id: params.contact_id, action: "unpinned" }; }, }; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d2777237..36c4616b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -195,6 +195,12 @@ export interface ContactPayload { job_title: string | null; recommended: boolean; enrichment: ContactEnrichment | null; + // Pin state, straight off the backend's ContactPayload. Optional here only + // because older API builds predate the fields; the current backend always + // emits both on org contacts and NEITHER on paid ones (PaidContactPayload + // has no pin state — a paid candidate cannot be pinned). + pinned?: boolean; + pinned_by_ai?: boolean; } export interface BillingStatePayload { diff --git a/packages/core/test/unit/composite/contact-pin-state-passthrough.test.ts b/packages/core/test/unit/composite/contact-pin-state-passthrough.test.ts new file mode 100644 index 00000000..e2c50ade --- /dev/null +++ b/packages/core/test/unit/composite/contact-pin-state-passthrough.test.ts @@ -0,0 +1,301 @@ +/** + * Pin state was invisible through the MCP. + * + * The backend's ContactPayload has carried `pinned` + `pinned_by_ai` all + * along (routes/payloads/ContactPayload.kt, snake_cased on the wire), but + * every MCP shaping site dropped both. An agent could pin a contact and then + * had no way to read back whether it stuck — it could only infer the pin from + * `recommended`, which also moves for reasons that have nothing to do with + * pinning. + * + * The paid side is deliberately asymmetric: PaidContactPayload has no pin + * state, because `POST /contacts/{id}/pin` resolves through org_contacts only + * and answers 404 for a paid id. So a paid contact must NOT sprout a + * `pinned: false` here — that would tell the agent it can unpin something it + * was never able to pin. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { httpsMockFactory, mockHttp, resetHttpMock } from "../../harness.js"; + +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { researchLeadById } from "../../../src/composite/research-lead-by-id.js"; +import { getContacts } from "../../../src/tools/get-contacts.js"; + +const BASE = "https://api-us.leadbay.app"; +const LEAD = "lead-pin"; +const LENS = 77; +const newClient = () => new LeadbayClient(BASE, "u.test-token", "us"); + +beforeEach(() => resetHttpMock()); + +// Two org contacts — one human-pinned, one not — plus an AI-pinned one, so +// `pinned` and `pinned_by_ai` are exercised independently of each other. +const ORG = [ + { + id: "org-pinned", + first_name: "Pinned", + last_name: "Person", + job_title: "COO", + email: "pinned@acme.test", + phone_number: null, + linkedin_page: null, + recommended: true, + pinned: true, + pinned_by_ai: false, + }, + { + id: "org-unpinned", + first_name: "Unpinned", + last_name: "Person", + job_title: "CFO", + email: "unpinned@acme.test", + phone_number: null, + linkedin_page: null, + recommended: false, + pinned: false, + pinned_by_ai: false, + }, + { + id: "org-ai-pinned", + first_name: "Ai", + last_name: "Pinned", + job_title: "CTO", + email: "ai@acme.test", + phone_number: null, + linkedin_page: null, + recommended: false, + pinned: true, + pinned_by_ai: true, + }, +]; + +// The backend emits no pin fields at all for paid candidates. +const PAID = [ + { + id: "paid-candidate", + first_name: "Paid", + last_name: "Candidate", + job_title: "VP Sales", + email: null, + phone_number: null, + linkedin_page: "https://linkedin.com/in/paid-candidate", + recommended: false, + enrichment: { done: false }, + }, +]; + +function subResources(org: unknown = ORG) { + return [ + { method: "POST" as const, path: "/1.6/interactions", status: 200, body: {} }, + { + method: "GET" as const, + path: `/1.6/lenses/${LENS}/leads/${LEAD}`, + status: 200, + body: { + id: LEAD, + name: "Acme SA", + score: 80, + ai_agent_lead_score: 70, + location: null, + description: null, + size: null, + website: "acme.test", + tags: [], + keywords: [], + notes_count: 0, + epilogue_actions_count: 0, + prospecting_actions_count: 0, + org_contacts_count: 3, + liked: false, + disliked: false, + new: false, + recommended_contact: null, + }, + }, + { method: "GET" as const, path: `/1.6/leads/${LEAD}/ai_agent_responses`, status: 200, body: [] }, + { + method: "GET" as const, + path: `/1.6/leads/${LEAD}/enrich/contacts?IncludeEnriched=true`, + status: 200, + body: PAID, + }, + { + method: "GET" as const, + path: `/1.6/leads/${LEAD}/web_fetch`, + status: 200, + body: { content: null, fetch_at: null }, + }, + { + method: "GET" as const, + path: `/1.6/leads/${LEAD}/activities?count=20`, + status: 200, + body: { items: [], pagination: { page: 0, pages: 1, total: 0 } }, + }, + { + method: "GET" as const, + path: `/1.6/leads/${LEAD}/contacts?IncludeEnriched=true`, + status: 200, + body: org, + }, + ]; +} + +const byId = (contacts: any[], id: string) => contacts.find((c) => c.id === id); + +describe("research_lead_by_id — pin state survives the reachability merge", () => { + it("org contacts carry pinned and pinned_by_ai exactly as the backend sent them", async () => { + mockHttp(subResources()); + const res: any = await researchLeadById.execute(newClient(), { + leadId: LEAD, + lensId: LENS, + }); + + const all = [...res.contacts.reachable, ...res.contacts.candidates]; + expect(byId(all, "org-pinned")).toMatchObject({ pinned: true, pinned_by_ai: false }); + expect(byId(all, "org-unpinned")).toMatchObject({ pinned: false, pinned_by_ai: false }); + expect(byId(all, "org-ai-pinned")).toMatchObject({ pinned: true, pinned_by_ai: true }); + }); + + it("paid candidates carry no pin state at all — they cannot be pinned", async () => { + mockHttp(subResources()); + const res: any = await researchLeadById.execute(newClient(), { + leadId: LEAD, + lensId: LENS, + }); + + const paid = byId( + [...res.contacts.reachable, ...res.contacts.candidates], + "paid-candidate", + ); + expect(paid.source).toBe("paid"); + expect(paid).not.toHaveProperty("pinned"); + expect(paid).not.toHaveProperty("pinned_by_ai"); + }); + + it("an API build that omits the fields degrades to false, never undefined", async () => { + const legacy = [{ ...ORG[1], pinned: undefined, pinned_by_ai: undefined }]; + delete (legacy[0] as any).pinned; + delete (legacy[0] as any).pinned_by_ai; + mockHttp(subResources(legacy)); + + const res: any = await researchLeadById.execute(newClient(), { + leadId: LEAD, + lensId: LENS, + }); + + const c = byId([...res.contacts.reachable, ...res.contacts.candidates], "org-unpinned"); + expect(c.pinned).toBe(false); + expect(c.pinned_by_ai).toBe(false); + }); + + it("markdown rendering marks the pinned contact so chat hosts show it too", async () => { + mockHttp(subResources()); + const res: any = await researchLeadById.execute(newClient(), { + leadId: LEAD, + lensId: LENS, + response_format: "markdown", + }); + + const md = typeof res === "string" ? res : res.markdown ?? JSON.stringify(res); + expect(md).toContain("**Pinned Person** 📌"); + expect(md).not.toContain("**Unpinned Person** 📌"); + }); + + it("marks a pinned org contact that has no email or phone", async () => { + // Reachability, not source, decides the partition — so a pinned org + // contact with no channel yet renders under `candidates`. Marking only + // the `reachable` list would hide the pin for exactly the contacts a rep + // pins BEFORE enriching them. + const unreachablePinned = [ + { + id: "org-pinned-no-channel", + first_name: "Channelless", + last_name: "Pinned", + job_title: "Directeur Général", + email: null, + phone_number: null, + linkedin_page: null, + recommended: true, + pinned: true, + pinned_by_ai: false, + }, + ]; + mockHttp(subResources(unreachablePinned)); + + const res: any = await researchLeadById.execute(newClient(), { + leadId: LEAD, + lensId: LENS, + response_format: "markdown", + }); + + const md = typeof res === "string" ? res : res.markdown ?? JSON.stringify(res); + expect(md).toContain("## Contacts — candidates (need enrichment)"); + expect(md).toContain("**Channelless Pinned** 📌"); + }); + + it("keeps the pinned org contact in candidates when it has no channel", async () => { + const unreachablePinned = [ + { + id: "org-pinned-no-channel", + first_name: "Channelless", + last_name: "Pinned", + job_title: "Directeur Général", + email: null, + phone_number: null, + linkedin_page: null, + recommended: true, + pinned: true, + pinned_by_ai: false, + }, + ]; + mockHttp(subResources(unreachablePinned)); + + const res: any = await researchLeadById.execute(newClient(), { + leadId: LEAD, + lensId: LENS, + }); + + expect(byId(res.contacts.reachable, "org-pinned-no-channel")).toBeUndefined(); + expect(byId(res.contacts.candidates, "org-pinned-no-channel")).toMatchObject({ + source: "org", + pinned: true, + }); + }); +}); + +describe("get_contacts — pin state reaches the granular surface too", () => { + it("passes pinned through on org contacts and omits it on paid ones", async () => { + mockHttp([ + { + method: "GET", + path: `/1.6/leads/${LEAD}/contacts?IncludeEnriched=true`, + status: 200, + body: ORG, + }, + { + method: "GET", + path: `/1.6/leads/${LEAD}/enrich/contacts?IncludeEnriched=true`, + status: 200, + body: PAID, + }, + ]); + + const res: any = await getContacts.execute(newClient(), { leadId: LEAD }); + + expect(byId(res.contacts, "org-pinned")).toMatchObject({ + pinned: true, + pinned_by_ai: false, + source: "org", + }); + expect(byId(res.contacts, "org-ai-pinned")).toMatchObject({ + pinned: true, + pinned_by_ai: true, + }); + const paid = byId(res.contacts, "paid-candidate"); + expect(paid.source).toBe("paid"); + expect(paid).not.toHaveProperty("pinned"); + }); +}); diff --git a/packages/core/test/unit/tools/pin-contact-not-found-hint.test.ts b/packages/core/test/unit/tools/pin-contact-not-found-hint.test.ts new file mode 100644 index 00000000..f4e97c01 --- /dev/null +++ b/packages/core/test/unit/tools/pin-contact-not-found-hint.test.ts @@ -0,0 +1,105 @@ +/** + * The generic 404 hint sent agents into a retry loop. + * + * `POST /contacts/{id}/pin` resolves through org_contacts only, so a + * `source: "paid"` candidate id answers 404 forever. The client's shared + * 404 handler (client.ts) attaches `hint: "Verify the ID is correct"`, which + * on this endpoint is false: the id is correct, it is the wrong namespace. + * An agent that reads "verify the id" looks the id up again, gets the same + * one back, and calls again. + * + * That is the observed production behaviour: 43 of 48 leadbay_pin_contact + * calls in the 180 days to 2026-09-02 were this 404, arriving in bursts of + * up to 10 within 13 seconds, from a single scheduled agent. + */ + +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 { pinContact, NOT_PINNABLE_HINT } from "../../../src/tools/pin-contact.js"; +import { unpinContact } from "../../../src/tools/unpin-contact.js"; + +const BASE = "https://api-us.leadbay.app"; +const PAID = "paid-candidate-id"; +const newClient = () => new LeadbayClient(BASE, "u.test-token", "us"); + +beforeEach(() => resetHttpMock()); + +const notFound = (verb: "pin" | "unpin") => [ + { + method: "POST" as const, + path: `/1.6/contacts/${PAID}/${verb}`, + status: 404, + body: { message: "contact not found" }, + }, +]; + +describe("pin/unpin — the 404 hint names the real cause", () => { + it("pin replaces the generic hint and tells the agent not to retry", async () => { + mockHttp(notFound("pin")); + + const err: any = await pinContact + .execute(newClient(), { contact_id: PAID }) + .then(() => null, (e) => e); + + expect(err).not.toBeNull(); + expect(err.code).toBe("NOT_FOUND"); + expect(err.hint).toBe(NOT_PINNABLE_HINT); + expect(err.hint).not.toContain("Verify the ID is correct"); + expect(err.hint).toContain("do NOT retry"); + expect(err.hint).toContain('source: "org"'); + // The backend message and the request context survive the rewrite. + expect(err.message).toContain("contact not found"); + expect(err._meta?.endpoint).toContain(`/contacts/${PAID}/pin`); + }); + + it("unpin answers a 404 the same way", async () => { + mockHttp(notFound("unpin")); + + const err: any = await unpinContact + .execute(newClient(), { contact_id: PAID }) + .then(() => null, (e) => e); + + expect(err.code).toBe("NOT_FOUND"); + expect(err.hint).toBe(NOT_PINNABLE_HINT); + }); + + it("the hint names the tools that actually make the person pinnable", () => { + expect(NOT_PINNABLE_HINT).toContain("leadbay_enrich_titles"); + expect(NOT_PINNABLE_HINT).toContain("leadbay_add_contact"); + // The second half of the misconception behind the production failures: + // the agent pinned in order to steer enrichment, which pinning never did. + expect(NOT_PINNABLE_HINT).toContain("job title"); + }); + + it("a non-404 failure is passed through untouched", async () => { + mockHttp([ + { + method: "POST", + path: `/1.6/contacts/${PAID}/pin`, + status: 500, + body: { message: "boom" }, + }, + ]); + + const err: any = await pinContact + .execute(newClient(), { contact_id: PAID }) + .then(() => null, (e) => e); + + expect(err.code).not.toBe("NOT_FOUND"); + expect(err.hint).not.toBe(NOT_PINNABLE_HINT); + }); + + it("a successful pin still makes exactly one request and returns the plain result", async () => { + mockHttp([ + { method: "POST", path: "/1.6/contacts/org-1/pin", status: 204, body: "" }, + ]); + + const result = await pinContact.execute(newClient(), { contact_id: "org-1" }); + + expect(result).toEqual({ pinned: true, contact_id: "org-1", action: "pinned" }); + expect(getHttpRequests()).toHaveLength(1); + }); +}); diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index bc0a15d9..4ed69b8c 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,59 @@ # Changelog — @leadbay/mcp +## 0.33.3 — 2026-09-02 + +`leadbay_pin_contact` failed 43 of its 48 production calls. + +Measured over the 180 days to 2026-09-02 (PostHog `mcp tool called`, +`properties.ok = false`). Sentry issue `MCP-3A` carries exactly 43 events, so +every `contact not found` in the `mcp` project is a pin call. 41 of the 43 are +one scheduled agent, `zoe+dogfood@leadbay.ai`, across 12 days and MCP 0.26.0 +through 0.33.2. Its own `triggered_by` reads *"tâche planifiée : épingler le DG +Mickael Hamot (CROMOLOGY SERVICES) à la place du président"*. It picks the DG +out of `leadbay_research_lead_by_id` and pins that id, which on an unenriched +company is a `source: "paid"` candidate. It has never once succeeded, and pin +is its third most used tool. + +`POST /contacts/{id}/pin` resolves through `org_contacts` only +(`OrgContactRoutes.kt:44`), so a paid candidate id can never resolve there. Two +changes: + +- **The 404 now names its cause.** The client's shared 404 hint is "Verify the + ID is correct" (`client.ts:1048`), which on this endpoint is false: the id is + correct, it is the wrong namespace. An agent reads that as "look it up and + retry" and does, in bursts of up to 10 calls in 13 seconds. pin and unpin now + catch `NOT_FOUND` and replace the hint with one that names the org-vs-paid + split, says not to retry, and names the tools that make the person pinnable. +- **The descriptions carry the rule.** New shared snippet + `snippets/heuristics/pinnable-contacts.md`, included by both templates, plus + the rule in `prefer_when` so it lands in the first 600 chars every host reads. + It also states that pinning does not steer enrichment: enrichment selects by + job title (`resolveAutoIncludedTitles` → + `paidContacts.findSimilarJobTitlesWithScores`) and `pinnedBy` plays no part. + That was the second half of the agent's mistake. + +**`pinned` is now readable.** The backend's `ContactPayload` has carried +`pinned` + `pinned_by_ai` all along; every MCP shaping site dropped them, so a +pin could be written but not read back except by watching `recommended`, which +moves for other reasons too. Now passed through in `research_lead_by_id`, +`get_contacts` and `get_lead_profile`, and marked `📌` in both markdown contact +lists. Deliberately asymmetric: `PaidContactPayload` has no pin state, so paid +contacts get no `pinned` key rather than a synthetic `false`. + +Verified live against FR staging, running the built branch: + +``` +BEFORE PIN: org contact → pinned: false paid contacts → no pinned key +AFTER PIN: org contact → pinned: true (pin http 204) +AFTER UNPIN: org contact → pinned: false (unpin http 204) +``` + +Remaining, not fixed here: on hosted an agent still has no way to enrich one +named person. `leadbay_enrich_contacts` takes a `contact_id` and does exactly +that, but sits in `granularWriteTools` behind `LEADBAY_MCP_ADVANCED=1`, and no +granular tool has been called from a hosted IP in 30 days. That is why the +agent reached for pin. Filed as leadbay/product#4050. + ## 0.33.2 — 2026-09-02 Editing one field on a contact erased the others (product#4046). diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 3805cffa..1db88245 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@leadbay/mcp", - "version": "0.33.2", + "version": "0.33.3", "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 0cc167fd..95cdc1fc 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.33.2", + "version": "0.33.3", "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.33.2", + "version": "0.33.3", "transport": { "type": "stdio" }, diff --git a/packages/promptforge/snippets/heuristics/pinnable-contacts.md b/packages/promptforge/snippets/heuristics/pinnable-contacts.md new file mode 100644 index 00000000..08e91764 --- /dev/null +++ b/packages/promptforge/snippets/heuristics/pinnable-contacts.md @@ -0,0 +1,13 @@ +**Only `source: "org"` contacts are pinnable.** Every contact returned by `leadbay_research_lead_by_id` carries a `source` field, and the two sources are separate id namespaces on the backend: + +- `source: "org"` — a row in your organization's own contact directory. Pinnable. Also carries `pinned` (true when someone has pinned it) and `pinned_by_ai` (true when Leadbay's AI pinned it rather than a human). +- `source: "paid"` — an enrichment *candidate* (the `candidates` bucket): a person Leadbay suggests but has not yet resolved into your directory. NOT pinnable, and carries no `pinned` field at all. + +Passing a `source: "paid"` id here returns **`contact not found`**. That is the expected answer for a candidate, not an outage and not a transient error: nothing is broken, the person is simply not an org contact yet. Do not retry, do not re-fetch the lead hoping for a different result, and do not tell the user that pinning is failing or unavailable. + +To pin someone who is currently only a candidate, first make them an org contact: + +- `leadbay_enrich_titles` (or `leadbay_prepare_outreach` with `enrich: true`) resolves the candidate and writes a NEW org contact for that person. It has a **different `id`** from the paid candidate, so re-read the contacts list afterwards and pin the `source: "org"` row. +- Or add them directly with `leadbay_add_contact`, which returns the new org contact's `id` — that id is pinnable immediately. + +**Pinning does not steer enrichment.** It only marks who the priority is on a company the user already has. Enrichment picks people by JOB TITLE, so "enrich the Directeur Général rather than the Président" is `leadbay_enrich_titles` with the wanted title — not a pin. Pinning first and enriching after changes nothing about who gets enriched. diff --git a/packages/promptforge/tool-descriptions/granular/pin-contact.md.tmpl b/packages/promptforge/tool-descriptions/granular/pin-contact.md.tmpl index 00bd4940..6ef0bf03 100644 --- a/packages/promptforge/tool-descriptions/granular/pin-contact.md.tmpl +++ b/packages/promptforge/tool-descriptions/granular/pin-contact.md.tmpl @@ -23,7 +23,7 @@ routing: route_to: leadbay_add_contact - phrase: "remove / delete this contact" route_to: leadbay_remove_contact - prefer_when: "user wants ONE person flagged as the priority on a company — pass that contact's own `contact_id`" + prefer_when: "user wants ONE person flagged as the priority on a company — pass that contact's own `contact_id`, and ONLY a `source:\"org\"` contact can be pinned (a `source:\"paid\"` candidate returns 'contact not found')" examples: positive: - "Pin Jane Doe as the main contact on this company." @@ -41,8 +41,10 @@ Pin a single contact on a company so it surfaces first as a priority / favourite Pass the contact's **own** `contact_id` (the `id` field on a contact object from `leadbay_research_lead_by_id` or a contacts list) — **not** the parent lead id. +{{include:heuristics/pinnable-contacts}} + Backend: `POST /contacts/{contact_id}/pin` → 204. Idempotent. The inverse is `leadbay_unpin_contact`. -Returns `{ pinned: true, contact_id, action: "pinned" }`. +Returns `{ pinned: true, contact_id, action: "pinned" }`. To read the resulting state back, re-call `leadbay_research_lead_by_id` — the pinned contact's `pinned` flips to `true` and it becomes the lead's `recommended` contact. Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw). diff --git a/packages/promptforge/tool-descriptions/granular/unpin-contact.md.tmpl b/packages/promptforge/tool-descriptions/granular/unpin-contact.md.tmpl index f5baef50..261254d5 100644 --- a/packages/promptforge/tool-descriptions/granular/unpin-contact.md.tmpl +++ b/packages/promptforge/tool-descriptions/granular/unpin-contact.md.tmpl @@ -21,7 +21,7 @@ routing: route_to: leadbay_pin_contact - phrase: "remove / delete this contact" route_to: leadbay_remove_contact - prefer_when: "user wants to clear the pinned flag on a contact (but keep the contact) — pass that contact's own `contact_id`" + prefer_when: "user wants to clear the pinned flag on a contact (but keep the contact) — pass that contact's own `contact_id`, and ONLY a `source:\"org\"` contact can be unpinned (a `source:\"paid\"` candidate returns 'contact not found')" examples: positive: - "Unpin Jane Doe — she's not the priority anymore." @@ -40,8 +40,12 @@ Unpin a single contact on a company — clears its priority / favourite flag. Th Pass the contact's **own** `contact_id` — not the parent lead id. +{{include:heuristics/pinnable-contacts}} + +A `source: "org"` contact that was never pinned is a no-op here, not an error — the backend answers 204 either way. Check `pinned` on the contact before calling if you need to tell the user whether anything actually changed. + Backend: `POST /contacts/{contact_id}/unpin` → 204. Idempotent. The inverse is `leadbay_pin_contact`. -Returns `{ pinned: false, contact_id, action: "unpinned" }`. +Returns `{ pinned: false, contact_id, action: "unpinned" }`. To read the resulting state back, re-call `leadbay_research_lead_by_id` — the contact's `pinned` flips to `false` and the lead's `recommended` contact reverts to the title-matched default. Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw).