Files
ProspectingAgentLoop/sales-agent/README.md
Chris Olson 9131e83c08 Improve signal quality (Exa news category) and remove mock fallback
Exa harvester:
- EXA_CATEGORY=news (default) restricts to news articles, excluding evergreen
  careers/marketing landing pages that were producing irrelevant signals.
- Event-focused query (acquisition/earnings/launch/leadership), dropped
  hiring/career terms that pulled recruiting pages.
- Domain scoping now OFF by default (broad-web news about the account beats
  first-party careers pages); add EXA_EXCLUDE_DOMAINS and loop --domain-filter.

LLM layer:
- Remove the mock reasoner entirely (no provider => loop refuses to run).
- On HTTP 429, WAIT (LLM_RATELIMIT_WAIT_SECONDS, default 900s; honors
  Retry-After) and retry up to LLM_RATELIMIT_MAX_RETRIES (24) instead of
  degrading to mock output.
- If the LLM is ultimately unavailable for an account, skip it and leave its
  signals unconsumed (retried next run) — never fabricate briefs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:41:41 -04:00

8.6 KiB

Sales Prospecting Agent Loop — MVP (v1)

A runnable first cut of the closed-loop account-intelligence system described in ../sales-prospecting-agent-loop.md. It harvests signals per account, evaluates them against a capability library, scores and synthesizes opportunity briefs, delivers them, and feeds rep feedback back into scoring — the full loop, end to end.

⚠️ What's real vs. placeholder in this version

This MVP is built to run today with zero setup so we can review output quality and iterate. Deliberate v1 simplifications (we'll revisit each tomorrow):

Design calls for v1 MVP uses Why
TypeScript / Node + BullMQ Python 3, stdlib only Node isn't installed on this machine; concepts map 1:1. Easy to port.
Proxycurl/Apify, JSearch, SEC EDGAR Fixture file (data/fixtures.json) No API keys needed; swap each connector for the real API.
Exa.ai news/web search LIVE when EXA_API_KEY set (else fixtures) Real connector with hard request ceilings — see below.
Salesforce/HubSpot data/accounts.json + console write-back Read context + log-activity stub.
Postgres + Redis + pgvector JSON files under output/state/ Hash dedup works; semantic dedup is a documented v2 hook.
Vector ANN capability pre-filter Keyword-overlap pre-filter Same interface, no embedding service.
Slack Bolt + SendGrid Console + markdown files Briefs render Slack-style; digest writes to output/digest.md.
LLM relevance + synthesis OpenAI-compatible / Gemini / Anthropic (required) Provider-agnostic (LLM_PROVIDER). No mock fallback — on a rate limit it waits and retries; without a key it refuses to run.

Run it

cd sales-agent

# 1. Run the loop over all target accounts (harvest -> deliver)
python3 run.py loop

# Single account or one tier:
python3 run.py loop --account acct_meridian
python3 run.py loop --tier strategic

# 2. See the per-rep daily digest
python3 run.py digest
python3 run.py digest --rep rep_sarah

# 3. Record rep feedback (closes the loop)
python3 run.py feedback --brief <BRIEF_ID> --action act_on
#   actions: act_on | snooze | reject | won | lost   (reject also suppresses 14d)

# 4. Recompute scoring weights from feedback (the weekly recalibration job)
python3 run.py recalibrate

# Helpers
python3 run.py accounts   # list target accounts
python3 run.py briefs     # list all generated briefs + status

LLM reasoning (provider-agnostic)

The relevance filter and synthesizer call whichever provider is configured. Set in .env:

# OpenAI-compatible (default) — OpenAI, OpenRouter, Together, Groq, vLLM, Ollama, ...
LLM_PROVIDER=openai
OPENAI_BASE_URL=https://api.openai.com/v1   # any /chat/completions endpoint
OPENAI_API_KEY=...
OPENAI_MODEL=gpt-4o-mini
OPENAI_JSON_MODE=1                          # set 0 if the endpoint rejects json_object

# or Gemini
LLM_PROVIDER=gemini
GEMINI_API_KEY=...
GEMINI_MODEL=gemini-2.5-flash

# or Anthropic
LLM_PROVIDER=anthropic
ANTHROPIC_API_KEY=...
LLM_MODEL=claude-sonnet-4-6

If LLM_PROVIDER is blank it auto-detects from whichever key is present (OpenAI- compatible preferred). All providers use plain urllib — no SDKs. LLM calls are batched (one relevance call + one synthesis call per account).

No mock fallback. Quality is the priority: the loop refuses to run without a configured provider, and on HTTP 429 it waits and retries (LLM_RATELIMIT_WAIT_ SECONDS, default 900s; honors Retry-After) up to LLM_RATELIMIT_MAX_RETRIES (default 24) rather than emitting low-quality output. If the LLM is ultimately unavailable for an account, that account is skipped and its signals are left unconsumed (retried next run) — never fabricated. Degenerate/unparseable responses (e.g. a model repetition loop) are re-sampled LLM_CONTENT_RETRIES times.

Exa.ai news connector & free-plan protection

The news harvester (src/connectors/exa.py) calls the real Exa search API (POST https://api.exa.ai/search) when EXA_API_KEY is set, and falls back to fixtures otherwise. Two hard ceilings keep you inside the free plan (Exa's free tier is ~20,000 requests/month; we default far below that):

  • EXA_MONTHLY_REQUEST_CAP (default 1000) — a persistent counter in output/state/exa_usage.json that resets at the start of each calendar month. Once hit, the connector refuses to call Exa and falls back to fixtures.
  • EXA_PER_RUN_REQUEST_CAP (default 25) — a per-process cap so a single loop can't burn through the budget.

A request is only counted when a call actually reaches Exa (a billable call); network failures that never hit Exa are not counted. Each account uses one Exa request per loop run (so the default 3 sample accounts = 3 requests/run).

python3 run.py exa-usage      # show remaining monthly budget

Signal quality: news category & domain scoping

The single biggest lever on signal quality is EXA_CATEGORY=news (default): it restricts results to news articles and keeps out evergreen pages (careers, marketing, generic corporate). The query is also event-focused (acquisition, earnings, product launch, leadership change, ...) rather than pulling recruiting pages.

Domain scoping is off by default — with category=news, broad-web search finds actual coverage about the account (PRNewswire, CNBC, trade press). Scope to the company's own newsroom only if you want first-party press releases:

python3 run.py loop --domain-filter      # account's own domain (newsroom/press only)
python3 run.py loop --no-domain-filter   # broad-web news about the account (default)
# or set EXA_DOMAIN_FILTER / EXA_CATEGORY / EXA_EXCLUDE_DOMAINS in .env

Tune all limits in .env (EXA_MONTHLY_REQUEST_CAP, EXA_PER_RUN_REQUEST_CAP, EXA_RESULTS_PER_QUERY, EXA_LOOKBACK_DAYS, EXA_CATEGORY). Set EXA_ENABLED=0 to force fixtures.

How the loop maps to the design

run.py loop
  └─ src/pipeline.run_account_cycle           (design §7.3)
       1. agents/harvester      Phase 1  signal harvest  (connectors/*)
       2. agents/normalizer     Phase 2  normalize + hash dedup
       3. agents/relevance      Phase 3  LLM vs. capability library
       4. agents/synthesizer    Phase 4  opportunity briefs
       5. agents/scorer         Phase 5  composite score + priority
       6. delivery/render       Phase 7  deliver (console/md) + CRM log
       7. memory/store          Phase 6  account memory + suppression
  feedback ─ memory/feedback    §6       feedback store + recalibration

Project layout

sales-agent/
├── run.py                 CLI entrypoint
├── data/
│   ├── accounts.json      target accounts (stand-in for CRM)
│   ├── capabilities.json  the Capability Library (the "brain")
│   └── fixtures.json      placeholder harvested signals
├── src/
│   ├── config.py          paths, thresholds, .env loader
│   ├── types.py           RawSignal / NormalizedSignal / Brief dataclasses
│   ├── llm.py             Anthropic call (urllib) + mock fallback
│   ├── capabilities.py    library loader + keyword pre-filter
│   ├── connectors/        linkedin, news, jobs, crm (fixture-backed)
│   ├── agents/            harvester, normalizer, relevance, synthesizer, scorer
│   ├── memory/            store (dedup/memory/briefs) + feedback (recalibration)
│   ├── delivery/          render (briefs) + digest
│   └── pipeline.py        the per-account orchestration
└── output/                generated briefs, digest, and JSON state (created on run)

Suggested review focus for tomorrow

  1. Capability Library (data/capabilities.json) — now seeded with 40 real Broadcom/VMware capabilities across Mainframe (13), Enterprise Software (9), Identity & Access Management (6), and VMware by Broadcom (12). Review wording of buying-signals/anti-signals and add any missing SKUs. The prior placeholder library is kept at data/capabilities.placeholder.json.
  2. Accounts (data/accounts.json) — sample BSG target accounts; swap for a real target-account list + CRM pull.
  3. Scoring weights — the composite formula in agents/scorer.py uses the design's weights; tune the tier/seniority/timing tables.
  4. Connectors — Exa.ai news is now live (with request caps). Improve query precision (a bare company name pulls in stock-ticker noise — add domain filtering via the account domain), then wire the next source (jobs/LinkedIn).
  5. Reasoning quality — set an API key and compare Claude briefs vs. the mock.