Skip to content

Commit 3ab7be1

Browse files
ArtyETH06claude
andcommitted
feat(mcp): add leadbay_scan_portfolio_signals — bulk portfolio signal scan + stale_at honesty fix
Closes leadbay/product#3704. Two root causes, both fixed: 1. Capability gap — no way to scan a portfolio for a signal in bulk. JM built a 497-lead Monitor portfolio, asked which had an M&A signal since 2025, and the agent fell back to one research_lead_by_id call per lead (~60 calls) before abandoning the task. New composite leadbay_scan_portfolio_signals bulk-reads CACHED web_fetch signals across the portfolio (read-only GET fan-out, no quota burn), filters entries by a diacritic/case-folded query + optional `since` date, and returns the matched cohort campaign-ready. 2. stale_at hallucination — the agent inferred signal presence/absence from freshness fields and reported confident-but-wrong results. New snippets/gates/signal-honesty.md guardrail, included in pull-followups, research-lead-by-id, and the followup_check_in prompt: freshness markers are not signal indicators; route portfolio-wide signal questions to the bulk tool; unresearched leads are reported (not_researched), never fabricated. The tool separates "no matching signal" (researched, no match) from "not yet researched" (no data to search) via a not_researched[] bucket — the structural antidote to the original hallucination. Verified live against the US test account end-to-end through the MCP server: one scan_portfolio_signals call over 169 leads correctly surfaced genuine post-2025 acquirers, with the agent reading the signal text to discard false-positive senses of "acquisition" and reporting 0 unresearched. - Extracts web_fetch reshaping into shared _web-fetch-helpers.ts (reused by research-lead-by-id). - Registered in compositeReadTools, _composite-file-names, TOOLS_WITH_ROUTING, output-schema conformance, WORKFLOWS.md. - Unit tests (new file) cover match/since/diacritics/not_researched/429/cap. - Two eval scenarios authored (underdeliver + honesty); runner glue not yet on this branch, so they are fixture-ready, not wired. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0e13e1a commit 3ab7be1

22 files changed

Lines changed: 1462 additions & 45 deletions

.claude-plugin/plugins/leadbay/skills/leadbay_followup_check_in/SKILL.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,29 @@ Unlike `leadbay_daily_check_in` which deep-dives on every promising lead in Phas
166166

167167
When the user picks a row, call `leadbay_research_lead_by_id` on that single lead (or `leadbay_research_lead_by_name_fuzzy` if they only have the name) and offer to `leadbay_prepare_outreach` once they say "let's reach out".
168168

169+
# PHASE 3b — BULK SIGNAL SCAN (when the user asks "which of these have signal X")
170+
171+
If the user wants to filter the whole portfolio by a web-research signal — "which of my leads acquired a company since 2025", "find everyone with a funding signal", "who changed CEO" — do NOT loop `leadbay_research_lead_by_id` per row, and do NOT guess from freshness fields. Call `leadbay_scan_portfolio_signals({query, since?})` once: it bulk-reads the cached signals across the portfolio and returns only the matches, campaign-ready. Report any `not_researched` leads honestly ("K aren't researched yet — want me to qualify them and re-scan?"), and offer to build a campaign from the matches.
172+
173+
**SIGNAL HONESTY — never infer signals from freshness.** `stale_at`,
174+
`web_fetch_in_progress`, `fetch_at` and `web_insights_fetched_at` are
175+
FRESHNESS markers, not signal indicators. A fresh timestamp does **not** mean a
176+
given signal (M&A, funding, a new hire, a CEO change) is present; a stale or
177+
missing one does **not** mean it's absent. The presence of a signal is
178+
determined ONLY by reading the actual `signals[]` / `web_fetch.content`
179+
entries.
180+
181+
To answer "which of my leads have signal X" across a portfolio, call
182+
**`leadbay_scan_portfolio_signals`** — it bulk-reads the cached signals and
183+
filters them for you. Do NOT loop `leadbay_research_lead_by_id` one lead at a
184+
time, and do NOT guess from list-level freshness flags.
185+
186+
If a lead has no cached signal content, say so honestly — "not yet researched,
187+
want me to qualify it?" — and surface it as `not_researched`. Never fabricate
188+
or imply a scan you didn't actually run, and never report a confident
189+
signal-presence verdict for a lead whose signals you never read.
190+
191+
169192
# CROSS-MODE PIVOT
170193

171194
Below the table, offer the cross-mode pivot in one short line so the user can redirect if you guessed wrong on entry-point routing: "Want to see NEW leads from your wishlist instead?" — that routes back to `leadbay_daily_check_in` (Discovery via `leadbay_pull_leads`).

WORKFLOWS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ The table is the human-readable index. The `yaml expected` + `yaml scenario` blo
2626
| 12 | **Lens extension — on-demand fill for bigger appetite** — "I want more leads on this lens / I need a bigger batch today" | `leadbay_extend_my_lens` | "I want more leads on this lens — bigger batch today" |
2727
| 13 | **Lens management — list / switch audiences** — "show me my lenses", "which audiences do I have", "switch to my Joinery lens" | `leadbay_my_lenses` | "Show me my lenses and switch to the Joinery one" |
2828
| 14 | **Lens creation — make a named audience** — "create a lens called X for sector Y", "set up a new audience" | `leadbay_new_lens` | "Create a lens called Joinery for the fintech sector" |
29+
| 15 | **Bulk portfolio signal scan** — "which of my leads acquired a company since 2025", "scan my portfolio for funding signals", "find everyone who changed CEO" — filters a known portfolio by a web-research signal in ONE call instead of looping `leadbay_research_lead_by_id` per lead | `leadbay_scan_portfolio_signals` | "Which of my leads acquired a company since 2025?" |
2930

3031
---
3132

packages/core/src/composite/_composite-file-names.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export const COMPOSITE_FILE_TOOL_NAMES: ReadonlySet<string> = new Set([
3939
"leadbay_research_lead_by_id",
4040
"leadbay_research_lead_by_name_fuzzy",
4141
"leadbay_resolve_import_rows",
42+
"leadbay_scan_portfolio_signals",
4243
"leadbay_seed_candidates",
4344
"leadbay_tour_plan",
4445
]);
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { WebFetchSignalsSection } from "../types.js";
2+
3+
// Shared web_fetch.content reshaping. The backend keys web-research signals
4+
// by emoji-prefixed section labels (e.g. "🏢 company profile", "📈 business
5+
// signals"); each value is an array of WebFetchEntry. Composites that surface
6+
// signals to the agent reshape this dynamic dict into an ordered array.
7+
//
8+
// Used by leadbay_research_lead_by_id (single lead) and
9+
// leadbay_scan_portfolio_signals (bulk read).
10+
11+
// Stable section ordering: profile → signals → clues → others (alphabetical).
12+
export const SECTION_PRIORITY = ["profile", "signals", "clues"];
13+
14+
// Map an emoji-prefixed section label like "🏢 company profile" to
15+
// {emoji: "🏢", label: "company profile"}. If no emoji prefix, label stays
16+
// as-is and emoji is null.
17+
export function splitEmojiSection(key: string): {
18+
emoji: string | null;
19+
label: string;
20+
} {
21+
// Match a leading non-letter/non-digit run (typically emoji) followed by space.
22+
const m = key.match(/^([^\p{L}\p{N}\s]+)\s+(.+)$/u);
23+
if (m) return { emoji: m[1], label: m[2] };
24+
return { emoji: null, label: key };
25+
}
26+
27+
export function reshapeWebFetchContent(
28+
content: Record<string, unknown> | null
29+
): WebFetchSignalsSection[] {
30+
if (!content) return [];
31+
const sections: WebFetchSignalsSection[] = [];
32+
for (const [key, val] of Object.entries(content)) {
33+
if (!Array.isArray(val)) continue;
34+
const { emoji, label } = splitEmojiSection(key);
35+
sections.push({
36+
section_label: label,
37+
section_emoji: emoji,
38+
entries: val as WebFetchSignalsSection["entries"],
39+
});
40+
}
41+
// Sort: known section labels first (in priority order), then alphabetical.
42+
sections.sort((a, b) => {
43+
const ai = SECTION_PRIORITY.findIndex((p) =>
44+
a.section_label.toLowerCase().includes(p)
45+
);
46+
const bi = SECTION_PRIORITY.findIndex((p) =>
47+
b.section_label.toLowerCase().includes(p)
48+
);
49+
const aN = ai < 0 ? SECTION_PRIORITY.length : ai;
50+
const bN = bi < 0 ? SECTION_PRIORITY.length : bi;
51+
if (aN !== bN) return aN - bN;
52+
return a.section_label.localeCompare(b.section_label);
53+
});
54+
return sections;
55+
}

packages/core/src/composite/research-lead-by-id.ts

Lines changed: 3 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ import type {
55
AiAgentResponse,
66
LeadPayload,
77
LeadWebFetchPayload,
8-
WebFetchSignalsSection,
98
PaidContactPayload,
109
ContactPayload,
1110
PaginatedActivities,
1211
} from "../types.js";
1312
import { withAgentMemoryMeta } from "../agent-memory/index.js";
13+
import { reshapeWebFetchContent } from "./_web-fetch-helpers.js";
1414

1515
import { leadbay_research_lead_by_id as RESEARCH_LEAD_BY_ID_DESCRIPTION } from "../tool-descriptions.generated.js";
1616

@@ -169,43 +169,8 @@ export function renderResearchLeadMarkdown(
169169
return out.join("\n");
170170
}
171171

172-
// Map an emoji-prefixed section label like "🏢 company profile" to
173-
// {section_emoji: "🏢", section_label: "company profile"}. If no emoji, label
174-
// stays as-is. Stable section ordering: profile → signals → clues → others.
175-
const SECTION_PRIORITY = ["profile", "signals", "clues"];
176-
177-
function splitEmojiSection(key: string): { emoji: string | null; label: string } {
178-
// Match a leading non-letter/non-digit character (typically emoji) followed by space.
179-
const m = key.match(/^([^\p{L}\p{N}\s]+)\s+(.+)$/u);
180-
if (m) return { emoji: m[1], label: m[2] };
181-
return { emoji: null, label: key };
182-
}
183-
184-
function reshapeWebFetchContent(
185-
content: Record<string, unknown> | null
186-
): WebFetchSignalsSection[] {
187-
if (!content) return [];
188-
const sections: WebFetchSignalsSection[] = [];
189-
for (const [key, val] of Object.entries(content)) {
190-
if (!Array.isArray(val)) continue;
191-
const { emoji, label } = splitEmojiSection(key);
192-
sections.push({
193-
section_label: label,
194-
section_emoji: emoji,
195-
entries: val as WebFetchSignalsSection["entries"],
196-
});
197-
}
198-
// Sort: known section labels first (in priority order), then alphabetical.
199-
sections.sort((a, b) => {
200-
const ai = SECTION_PRIORITY.findIndex((p) => a.section_label.toLowerCase().includes(p));
201-
const bi = SECTION_PRIORITY.findIndex((p) => b.section_label.toLowerCase().includes(p));
202-
const aN = ai < 0 ? SECTION_PRIORITY.length : ai;
203-
const bN = bi < 0 ? SECTION_PRIORITY.length : bi;
204-
if (aN !== bN) return aN - bN;
205-
return a.section_label.localeCompare(b.section_label);
206-
});
207-
return sections;
208-
}
172+
// Section reshaping (splitEmojiSection / reshapeWebFetchContent / SECTION_PRIORITY)
173+
// moved to ./_web-fetch-helpers.js — shared with leadbay_scan_portfolio_signals.
209174

210175
// Hashable contact summary used by hasReachableContact. A contact is
211176
// "reachable" iff it has at least one of: non-empty email, non-empty

0 commit comments

Comments
 (0)