Files
ProspectingAgentLoop/sales-agent/src/config.py
Chris Olson fefda7481f Capability-set targeting + Claude Code headless provider + resilience fixes
Capability targeting (new feature):
- loop --capability <SET|ALL> scopes which capability library the loop reasons
  against. data/capability_sets/<name>.json (same schema as capabilities.json)
  holds one-off targeted products; drop a file in and pass its name, no code
  changes. First set: agentminder (Broadcom AgentMinder, agentic-AI identity).
- capabilities.set_active_set() swaps the active library; keyword pre-filter is
  bypassed for small targeted sets so every signal is evaluated. New
  'capability-sets' command lists them; run banner shows the active set.

LLM provider — the '429' was a disguised policy block:
- Anthropic rejects Claude Code OAuth tokens on /v1/messages for non-Claude-Code
  traffic with a fake rate_limit_error (no anthropic-ratelimit-* headers). New
  'claude-cli' provider runs inference through the sanctioned headless path
  (claude -p --append-system-prompt --model), now the default provider.
- OAuth keychain token auto-refresh via headless claude when expired.

Resilience:
- STATE_DIR is overridable (macOS endpoint-security locked output/state via
  com.apple.macl); load_memory tolerates PermissionError; exa usage counter
  tolerates malformed files; run.py handles Ctrl-C cleanly (no traceback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:00:02 -04:00

185 lines
9.2 KiB
Python

"""Central config & paths. Loads a .env file if present (no external deps)."""
from __future__ import annotations
import os
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DATA_DIR = ROOT / "data"
def _load_dotenv() -> None:
"""Minimal .env loader so the user can keep keys in sales-agent/.env."""
env_path = ROOT / ".env"
if not env_path.exists():
return
for line in env_path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
key, val = key.strip(), val.strip().strip('"').strip("'")
os.environ.setdefault(key, val)
_load_dotenv()
# Paths (after dotenv so OUTPUT_DIR/STATE_DIR overrides in .env apply).
# STATE_DIR is overridable because macOS TCC/endpoint-security software can
# permanently lock a directory tree (com.apple.macl xattr) — pointing at a
# fresh dir is the recovery path (state is regenerable). Relative values are
# resolved against the sales-agent root.
OUTPUT_DIR = ROOT / os.environ.get("OUTPUT_DIR", "output")
STATE_DIR = (ROOT / os.environ.get("STATE_DIR", "")) if os.environ.get("STATE_DIR") else OUTPUT_DIR / "state"
BRIEFS_DIR = OUTPUT_DIR / "briefs"
# --- LLM ---
# Provider abstraction: openai | gemini | anthropic. If LLM_PROVIDER is unset we
# auto-detect from whichever key is present (OpenAI-compatible first). There is NO
# mock fallback: the loop refuses to run without a configured provider, and on
# rate limits it WAITS and retries rather than emitting low-quality output.
#
# "openai" is any OpenAI-compatible /chat/completions endpoint — OpenAI, OpenRouter,
# Together, Groq, NVIDIA, vLLM, Ollama, LM Studio, etc. — configured by:
# OPENAI_BASE_URL (e.g. https://api.openai.com/v1)
# OPENAI_API_KEY
# OPENAI_MODEL (e.g. gpt-4o-mini)
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "").strip()
OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1").strip().rstrip("/")
OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o-mini").strip()
# Some endpoints/models don't support response_format=json_object — turn off if so.
OPENAI_JSON_MODE = os.environ.get("OPENAI_JSON_MODE", "1").strip() == "1"
# Sampling/anti-repetition. A small frequency penalty + non-trivial temperature
# avoids the repetition-loop degeneration some models (e.g. Kimi) fall into at
# very low temperature. Penalties are omitted from the request when set to 0
# (for endpoints that reject them).
OPENAI_TEMPERATURE = float(os.environ.get("OPENAI_TEMPERATURE", "0.5"))
OPENAI_FREQUENCY_PENALTY = float(os.environ.get("OPENAI_FREQUENCY_PENALTY", "0.4"))
OPENAI_PRESENCE_PENALTY = float(os.environ.get("OPENAI_PRESENCE_PENALTY", "0.0"))
# Retries when a model returns degenerate/unparseable content (stochastic — a
# retry usually succeeds).
LLM_CONTENT_RETRIES = int(os.environ.get("LLM_CONTENT_RETRIES", "3"))
# On HTTP 429 (rate limit) we WAIT this long and retry (no mock fallback). Honors
# a Retry-After response header when present. Lets the loop grind slowly but
# correctly through a tight rate limit instead of degrading quality.
LLM_RATELIMIT_WAIT_SECONDS = int(os.environ.get("LLM_RATELIMIT_WAIT_SECONDS", "900"))
LLM_RATELIMIT_MAX_RETRIES = int(os.environ.get("LLM_RATELIMIT_MAX_RETRIES", "24"))
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "").strip()
LLM_MODEL = os.environ.get("LLM_MODEL", "claude-sonnet-5")
# Anthropic OAuth (subscription auth instead of an API key). Token resolution:
# ANTHROPIC_OAUTH_TOKEN env var, else the Claude Code credential in the macOS
# keychain ("Claude Code-credentials"), read at runtime so refreshes are picked up.
ANTHROPIC_OAUTH_TOKEN = os.environ.get("ANTHROPIC_OAUTH_TOKEN", "").strip()
ANTHROPIC_USE_KEYCHAIN = os.environ.get("ANTHROPIC_USE_KEYCHAIN", "1").strip() == "1"
# When the keychain token is expired, run a headless `claude` command to make
# Claude Code refresh it, then re-read — the loop self-heals with no human step.
ANTHROPIC_AUTOREFRESH = os.environ.get("ANTHROPIC_AUTOREFRESH", "1").strip() == "1"
# "claude-cli" provider: run inference through Claude Code's official headless
# mode (`claude -p`). This is the sanctioned way to use a Claude subscription
# programmatically — direct API calls with the Claude Code OAuth token are
# policy-blocked (they return a fake 429 "Error"). Requires `claude` on PATH.
CLAUDE_CLI_MODEL = os.environ.get("CLAUDE_CLI_MODEL", "sonnet").strip()
CLAUDE_CLI_TIMEOUT = int(os.environ.get("CLAUDE_CLI_TIMEOUT", "300"))
# Effort level for reasoning models (Sonnet 5 etc.): low | medium | high | max.
ANTHROPIC_EFFORT = os.environ.get("ANTHROPIC_EFFORT", "low").strip()
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "").strip()
GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash")
_provider = os.environ.get("LLM_PROVIDER", "").strip().lower()
if not _provider:
if ANTHROPIC_API_KEY:
_provider = "anthropic"
elif OPENAI_API_KEY:
_provider = "openai"
elif GEMINI_API_KEY:
_provider = "gemini"
else:
_provider = "claude-cli" # subscription via Claude Code headless mode
# Selecting a provider whose key is missing => no usable provider.
# ("anthropic" is exempt here: the token may come from the keychain at runtime.)
if _provider == "openai" and not OPENAI_API_KEY:
_provider = "none"
elif _provider == "gemini" and not GEMINI_API_KEY:
_provider = "none"
elif _provider == "anthropic" and not (
ANTHROPIC_API_KEY or ANTHROPIC_OAUTH_TOKEN or ANTHROPIC_USE_KEYCHAIN
):
_provider = "none"
LLM_PROVIDER = _provider
LLM_CONFIGURED = LLM_PROVIDER != "none"
def llm_label() -> str:
if LLM_PROVIDER == "none":
return "NONE (no LLM provider configured)"
if LLM_PROVIDER == "claude-cli":
return f"Claude Code headless ({CLAUDE_CLI_MODEL}, subscription)"
if LLM_PROVIDER == "openai":
host = OPENAI_BASE_URL.split("//")[-1].split("/")[0]
return f"OpenAI-compatible ({OPENAI_MODEL} @ {host})"
if LLM_PROVIDER == "gemini":
return f"Gemini ({GEMINI_MODEL})"
auth = "OAuth" if (ANTHROPIC_OAUTH_TOKEN or not ANTHROPIC_API_KEY) else "API key"
return f"Anthropic ({LLM_MODEL}, effort={ANTHROPIC_EFFORT}, {auth})"
# --- Scoring thresholds (Phase 5 of the design) ---
PRIORITY_THRESHOLD = float(os.environ.get("PRIORITY_THRESHOLD", "0.72"))
STANDARD_THRESHOLD = float(os.environ.get("STANDARD_THRESHOLD", "0.45"))
# Briefs whose LLM confidence falls below this floor are demoted to "low"
# (logged, not delivered) regardless of composite score — keeps weak matches
# the model itself doubts out of the rep's queue.
CONFIDENCE_FLOOR = float(os.environ.get("CONFIDENCE_FLOOR", "0.35"))
# --- Renewal proximity (from local entitlement data; no LLM) ---
# A renewal within this window is a live commercial event: boost the brief's
# score, scaled by how close the renewal is. Same-capability renewals get the
# full boost; any-product renewals on the account get half.
RENEWAL_WINDOW_DAYS = int(os.environ.get("RENEWAL_WINDOW_DAYS", "365"))
RENEWAL_BOOST_MAX = float(os.environ.get("RENEWAL_BOOST_MAX", "0.10"))
# --- Exa.ai news/web harvester ---
EXA_API_KEY = os.environ.get("EXA_API_KEY", "").strip()
# Enabled only when a key is present AND not explicitly turned off.
EXA_ENABLED = (os.environ.get("EXA_ENABLED", "1").strip() == "1") and bool(EXA_API_KEY)
# Hard request ceilings to protect the free plan. The connector will NOT call
# Exa once either cap is reached; the monthly counter persists across runs.
EXA_MONTHLY_REQUEST_CAP = int(os.environ.get("EXA_MONTHLY_REQUEST_CAP", "1000"))
EXA_PER_RUN_REQUEST_CAP = int(os.environ.get("EXA_PER_RUN_REQUEST_CAP", "25"))
EXA_RESULTS_PER_QUERY = int(os.environ.get("EXA_RESULTS_PER_QUERY", "5"))
EXA_LOOKBACK_DAYS = int(os.environ.get("EXA_LOOKBACK_DAYS", "45"))
# Exa content category. "news" restricts results to news articles (press releases,
# coverage) and keeps out evergreen pages like careers/marketing landing pages —
# the single biggest signal-quality lever. Set to "" to disable category filtering.
EXA_CATEGORY = os.environ.get("EXA_CATEGORY", "news").strip()
# Domain scoping is OFF by default now: with category=news, broad-web search finds
# actual news ABOUT the account from outlets, whereas scoping to the company's own
# domain surfaces careers/marketing pages. Turn on for first-party newsroom only.
EXA_DOMAIN_FILTER = os.environ.get("EXA_DOMAIN_FILTER", "0").strip() == "1"
# Hosts to always exclude (comma-separated) — e.g. careers/talent subdomains.
EXA_EXCLUDE_DOMAINS = [
d.strip() for d in os.environ.get("EXA_EXCLUDE_DOMAINS", "").split(",") if d.strip()
]
# --- SEC EDGAR filings harvester (free, no key) ---
SEC_ENABLED = os.environ.get("SEC_ENABLED", "1").strip() == "1"
SEC_MAX_FILINGS_PER_ACCOUNT = int(os.environ.get("SEC_MAX_FILINGS_PER_ACCOUNT", "5"))
# --- Dedup / suppression windows ---
SEMANTIC_DEDUP_DAYS = 90
SAME_TYPE_SUPPRESS_DAYS = 14
# Per-tier harvest cadence (informational for the scheduler; the MVP runs on demand)
TIER_CADENCE_HOURS = {"strategic": 24, "enterprise": 60, "growth": 168}
# Brief expiry (opportunities decay if not acted on)
BRIEF_TTL_DAYS = 21
def ensure_dirs() -> None:
for d in (OUTPUT_DIR, STATE_DIR, BRIEFS_DIR):
d.mkdir(parents=True, exist_ok=True)