Skip to content

fix(mcp): hosted agents can enrich the one person they named (product#4050) - #200

Merged
milstan merged 2 commits into
mainfrom
milstan/product-issue-4050
Sep 2, 2026
Merged

fix(mcp): hosted agents can enrich the one person they named (product#4050)#200
milstan merged 2 commits into
mainfrom
milstan/product-issue-4050

Conversation

@milstan

@milstan milstan commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Closes leadbay/product#4050.

leadbay_enrich_contacts moves from granularWriteTools to compositeWriteTools. It is the only tool that enriches one chosen person (leadId + contactId, paid-candidate path first, org path on NOT_FOUND), but it sat behind LEADBAY_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 by LEADBAY_MCP_WRITE=0; not in COMPOSITE_FILE_TOOL_NAMES, so no _triggered_by.

Moved with it, so the tool works on the surface it lands on:

  • The result hint said "re-check leadbay_get_contacts", which is advanced-only. It now names leadbay_research_lead_by_id (and get_contacts where exposed).
  • The QUOTA_TOPUP server instruction had the same get_contacts pointer 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 NEW source:"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.
  • The description gets routing + rendering_hint and joins TOOLS_WITH_ROUTING. prefer_when says a source:"paid" candidate id from research_lead_by_id is valid input and that pinning does not enrich anyone. Cross-routes to enrich_titles, pin_contact, prepare_outreach.
  • The pin/unpin 404 hint and heuristics/pinnable-contacts name it as the direct route, next to enrich_titles and add_contact. enrich_titles drops 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 names enrich_titles for candidates; not wrong), MIGRATION.md history, the daily check-in prompt.

Tests. packages/mcp/test/enrich-contacts-default-surface.test.ts drives the real MCP tools/list and tools/call through buildServer: listed with includeWrite:true, includeAdvanced:false, hidden with includeWrite:false, listed once with both on, routing in the first 600 chars, and every leadbay_* 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.ts pins the hint. pnpm -r test and pnpm -r typecheck green.

Verified live against FR staging (milstan+solmur.fr@leadbay.ai) with the built dist/bin.js, default env, no LEADBAY_MCP_ADVANCED:

  • tools/list: 61 tools, leadbay_enrich_contacts present, leadbay_get_contacts absent.
  • LALOUER-BOUCHER, the issue's exact shape: a Directeur Général (source:"paid", id a73e2623…) next to a Président. leadbay_enrich_contacts({leadId, contactId, email:true})triggered:true. 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 found no email for her.
  • GROUPE GABRIELI, Christophe Terrand (Responsable Commercial, source:"paid"): same call, triggered:true; 30 s later enrichment: {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 same buildServer(includeWrite, includeAdvanced); it was not run live here because it routes by token to production only.

…#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>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-02T22:00:26.443631Z 9773bc8 New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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), " +

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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>
@milstan
milstan merged commit 4514b24 into main Sep 2, 2026
2 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines 227 to 229
export const granularWriteTools: Tool[] = [
qualifyLead,
enrichContacts,
addNote,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 — " +

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant