fix(mcp): hosted agents can enrich the one person they named (product#4050) - #200
Conversation
…#4050)
leadbay_enrich_contacts is the only tool that enriches ONE chosen person —
leadId + contactId, paid-candidate path first, org-contact path on NOT_FOUND —
but it sat in granularWriteTools behind LEADBAY_MCP_ADVANCED=1, which hosted
never sets. No granular tool has been called from a hosted IP in 30 days. The
daily check-in prompt and the pull-leads NEXT STEPS table already told the
agent to call it, so on hosted they named a tool that was not registered. The
scheduled agent from 0.33.3 reached for leadbay_pin_contact instead and got
"contact not found" 41 times.
It now registers in compositeWriteTools, the same way as add/remove/pin/unpin/
update_contact and set_lead_status: granular-shaped, lives in tools/, default
write surface, still hidden by LEADBAY_MCP_WRITE=0, no _triggered_by mandate.
Moved with it so the tool works where it now lands: the result hint named
leadbay_get_contacts (advanced-only) as the read to poll; it now names
leadbay_research_lead_by_id. The description gains routing + rendering_hint
and joins TOOLS_WITH_ROUTING, stating that a source:"paid" candidate id from
research_lead_by_id is valid input and that pinning does not enrich anyone.
The pin/unpin 404 hint and heuristics/pinnable-contacts name it as the direct
route next to enrich_titles and add_contact, since that 404 is where the
agent in the issue lands.
Verified on FR staging with the built dist/bin.js and no LEADBAY_MCP_ADVANCED:
tools/list shows 61 tools with enrich_contacts present and get_contacts absent.
On LALOUER-BOUCHER, a Directeur Général source:"paid" candidate next to a
Président, enrich_contacts returned triggered:true and 50 s later the raw
record read enrichment {done:true, credits_used:0, email_requested:true}: the
backend ran the enrichment for exactly that person; the provider had no email
for her.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
Registration move follows the established precedent (same pattern as add/remove/pin/unpin/update_contact), routing frontmatter and TOOLS_WITH_ROUTING/WORKFLOWS.md are wired correctly, and the pin-404 hint + heuristics/pinnable-contacts snippet were both updated to stop naming leadbay_get_contacts. New tests exercise the real buildServer surface rather than the raw arrays, which is the right level.
One gap in the "moved with it" cleanup: packages/promptforge/snippets/server-instructions/quota-topup.md (line 3, emitted unconditionally via QUOTA_TOPUP in server.ts) still tells the agent, for the single-contact flow, to "refresh only once leadbay_get_contacts shows the REQUESTED channel actually landed" — nearly verbatim the same hint this PR just fixed in enrich-contacts.ts's own hint and in NOT_PINNABLE_HINT. leadbay_get_contacts stays advanced-only, so a hosted agent that just launched leadbay_enrich_contacts (now default-surface) is told by the always-on server instructions to poll a tool it doesn't have. This is exactly the pattern the repo's own #3504 rule guards against (see the ENRICHMENT_TERMINAL gate a few lines above in server.ts, which is conditioned on has("leadbay_enrich_titles") for this reason) — worth pointing this paragraph at leadbay_research_lead_by_id the same way the other two hints were fixed.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 78f3169910
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| "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. " + | ||
| "To act on this person, enrich them directly with leadbay_enrich_contacts (the lead id + this contact id), " + |
There was a problem hiding this comment.
Remove the contradictory job-title routing from the pin hint
When pinning or unpinning a paid candidate returns NOT_FOUND, this hint now recommends exact-person enrichment through leadbay_enrich_contacts, but its final sentence still says enrichment selects people by job title. That directly contradicts the new route and can send the agent back to leadbay_enrich_titles, recreating the misrouting this change is intended to prevent; distinguish direct contact-ID enrichment from title-based enrichment here.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
[Claude]: Fixed in 9773bc8. The last sentence now reads: pinning does not decide who gets enriched; leadbay_enrich_titles selects by job title, leadbay_enrich_contacts by the contact id you pass.
| // chosen person (leadId + contactId) rather than a job title, and hosted | ||
| // never sets LEADBAY_MCP_ADVANCED, so behind that gate no hosted agent could | ||
| // act on a person it had already identified (product#4050). | ||
| enrichContacts, |
There was a problem hiding this comment.
Keep the enrichment polling instructions on the default surface
With this tool now exposed when includeAdvanced:false, its lifecycle conflicts with the always-emitted QUOTA_TOPUP server instruction: the single-contact branch in packages/promptforge/snippets/server-instructions/quota-topup.md still mandates polling leadbay_get_contacts, which remains advanced-only. Although the tool's result hint now suggests leadbay_research_lead_by_id, the higher-level instructions can still make hosted agents call an unregistered tool or fail to determine completion; update that instruction and its generated output to use the default-surface read.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
[Claude]: Fixed in 9773bc8. snippets/server-instructions/quota-topup.md now tells the single-contact flow to re-read the lead's contacts via leadbay_research_lead_by_id (get_contacts where exposed); server-instructions.generated.ts regenerated.
| Order email and/or phone enrichment for a specific contact. Tries the paid-contact path and falls back to the org-contact path on NOT_FOUND. Each email reveal and each phone reveal consumes QUOTA. Both `email` and `phone` default to `true` — a bare call enriches both channels. | ||
|
|
||
| {{include:headers/tool-when-to-use}} when you have a specific `contact_id` (from leadbay_get_contacts) and want to enrich just that one. | ||
| {{include:headers/tool-when-to-use}} when the user has already picked WHO they want on a company and you hold that person's `contact_id` — from `leadbay_research_lead_by_id` (`reachable` / `candidates` lists) or `leadbay_get_contacts` where exposed. A `source: "paid"` candidate id is the normal input here: it is the person Leadbay suggested but has not resolved yet, and this tool is what resolves them. Enrichment writes a NEW org contact with a different id; re-read the lead afterwards rather than re-using the candidate id. |
There was a problem hiding this comment.
Describe org-contact enrichment as an in-place update
When the supplied ID belongs to a source:"org" contact—for example, an existing contact with email whose phone is being requested—the implementation falls back to /leads/{leadId}/contacts/{contactId}/enrich and enriches that existing org row. The new unconditional statement that enrichment creates a different ID tells the agent not to track the actual contact it just enriched, which can make it miss the requested channel on subsequent reads; limit the new-ID guidance to the paid-candidate path.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
[Claude]: Fixed in 9773bc8. Checked against FullEnrichRoutes.kt on backend origin/main: the paid row never carries email or phone (PaidContactPayload has no such field), so on success the person appears as a source:"org" row, new or merged into an existing org contact, with a different id. No result: paid row done, no channel, no org row. A source:"org" id is updateContact in place. The description, the runtime hint and the pinnable-contacts snippet now say exactly that, split by which id was passed.
Three review findings, each checked against the backend's FullEnrichRoutes before editing: - The description said enrichment always writes a new org contact. That is the paid-candidate path only, and only when the provider finds a channel: the paid row never carries email or phone, so on success the person shows up as a source:"org" row (new, or merged into an existing org contact for the same person) with a different id; on no result the paid row reads done with no channel and no org row exists. A source:"org" id is updated in place, same id. The template, the runtime hint and the pinnable-contacts snippet now say exactly that. - The pin 404 hint ended with "enrichment selects people by job title", which contradicted the direct route it had just named. It now says enrich_titles selects by title and enrich_contacts by the id passed. - The QUOTA_TOPUP server instruction still told the single-contact flow to poll leadbay_get_contacts, which is advanced-only. It now names the default-surface read, leadbay_research_lead_by_id. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9773bc8887
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| export const granularWriteTools: Tool[] = [ | ||
| qualifyLead, | ||
| enrichContacts, | ||
| addNote, |
There was a problem hiding this comment.
Preserve enrichContacts in the compatibility catalogue
Keep enrichContacts reachable through the backward-compatible granularTools export when moving it to the default MCP tier. After this removal, that alias—explicitly documented below as serving existing consumers—no longer contains a tool it exposed previously, so consumers that import granularTools rather than combining it with compositeTools silently lose single-contact enrichment. The MCP server tests do not catch this because buildServer reads the new tier arrays directly.
Useful? React with 👍 / 👎.
| credits_remaining: creditsRemaining, | ||
| hint: | ||
| "Enrichment started (runs async). Re-check leadbay_get_contacts every ~30s and treat the contact as done only when the REQUESTED channel actually landed — " + | ||
| "Enrichment started (runs async). Re-read the lead's contacts every ~30s (leadbay_research_lead_by_id; or leadbay_get_contacts where exposed) and treat the contact as done only when the REQUESTED channel actually landed — " + |
There was a problem hiding this comment.
Avoid polling through a tool that records clicks
Use a side-effect-free default-surface read for this polling loop. In the hosted configuration, the hint now makes every single-contact enrichment call leadbay_research_lead_by_id repeatedly, but that tool unconditionally posts LEAD_SEEN and LEAD_CLICKED interactions before fetching the contact data (research-lead-by-id.ts:455-462). A normal 90-second-to-2-minute wait therefore records several artificial clicks for one enrichment and can affect the interaction history and the lead-aging/delivery pacing that those events drive.
Useful? React with 👍 / 👎.
Closes leadbay/product#4050.
leadbay_enrich_contactsmoves fromgranularWriteToolstocompositeWriteTools. It is the only tool that enriches one chosen person (leadId+contactId, paid-candidate path first, org path onNOT_FOUND), but it sat behindLEADBAY_MCP_ADVANCED=1, which hosted never sets. The daily check-in prompt and the pull-leads NEXT STEPS table already named it, so on hosted they pointed at a tool that was not registered. Same registration pattern as add/remove/pin/unpin/update_contact and set_lead_status; still hidden byLEADBAY_MCP_WRITE=0; not inCOMPOSITE_FILE_TOOL_NAMES, so no_triggered_by.Moved with it, so the tool works on the surface it lands on:
leadbay_get_contacts", which is advanced-only. It now namesleadbay_research_lead_by_id(andget_contactswhere exposed).QUOTA_TOPUPserver instruction had the sameget_contactspointer for the single-contact flow; same fix. The hint and description also say where the channel lands: a paid candidate's channel appears on a NEWsource:"org"row with a different id (the paid payload has no email field), an org contact is updated in place, and a no-result enrichment creates no row.routing+rendering_hintand joinsTOOLS_WITH_ROUTING.prefer_whensays asource:"paid"candidate id fromresearch_lead_by_idis valid input and that pinning does not enrich anyone. Cross-routes toenrich_titles,pin_contact,prepare_outreach.heuristics/pinnable-contactsname it as the direct route, next toenrich_titlesandadd_contact.enrich_titlesdrops its "(granular)" label. These two are the one edit outside the tool itself: the pin 404 is where the agent in the issue lands.Not touched:
research_lead_by_id's output-schema prose (still namesenrich_titlesfor candidates; not wrong), MIGRATION.md history, the daily check-in prompt.Tests.
packages/mcp/test/enrich-contacts-default-surface.test.tsdrives the real MCPtools/listandtools/callthroughbuildServer: listed withincludeWrite:true, includeAdvanced:false, hidden withincludeWrite:false, listed once with both on, routing in the first 600 chars, and everyleadbay_*the description and the call hint name is registered on that same surface.packages/core/test/unit/tools/pin-contact-hint-names-enrich-contacts.test.tspins the hint.pnpm -r testandpnpm -r typecheckgreen.Verified live against FR staging (
milstan+solmur.fr@leadbay.ai) with the builtdist/bin.js, default env, noLEADBAY_MCP_ADVANCED:tools/list: 61 tools,leadbay_enrich_contactspresent,leadbay_get_contactsabsent.source:"paid", ida73e2623…) next to a Président.leadbay_enrich_contacts({leadId, contactId, email:true})→triggered:true. 50 s later the raw record readenrichment: {done:true, credits_used:0, email_requested:true}: the backend ran the enrichment for exactly that person; the provider found no email for her.source:"paid"): same call,triggered:true; 30 s laterenrichment: {done:true, credits_used:0, email_requested:true}. Also no email from the provider. Both records prove the launch targets the named contact id, not a title; whether staging's provider resolves anyone is outside this change.Hosted (
http-server.ts) builds from the samebuildServer(includeWrite, includeAdvanced); it was not run live here because it routes by token to production only.