From fefda7481f03bf447e5fbeffc78b6107db74771e Mon Sep 17 00:00:00 2001 From: Chris Olson Date: Tue, 21 Jul 2026 09:00:02 -0400 Subject: [PATCH] Capability-set targeting + Claude Code headless provider + resilience fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capability targeting (new feature): - loop --capability scopes which capability library the loop reasons against. data/capability_sets/.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 --- sales-agent/USAGE.md | 1 + .../data/capability_sets/agentminder.json | 50 ++++++++++++ sales-agent/run.py | 27 ++++++- sales-agent/src/capabilities.py | 47 ++++++++++- sales-agent/src/config.py | 28 +++++-- sales-agent/src/connectors/exa.py | 13 +-- sales-agent/src/llm.py | 81 ++++++++++++++++--- sales-agent/src/memory/store.py | 9 ++- sales-agent/src/pipeline.py | 4 + 9 files changed, 234 insertions(+), 26 deletions(-) create mode 100644 sales-agent/data/capability_sets/agentminder.json diff --git a/sales-agent/USAGE.md b/sales-agent/USAGE.md index 93c5feb..75b517e 100644 --- a/sales-agent/USAGE.md +++ b/sales-agent/USAGE.md @@ -25,6 +25,7 @@ python3 run.py loop --no-domain-filter # Exa: broad-web news (default) | `--tier T` | Only `strategic` / `enterprise` / `growth` accounts | | `--rep NAME` | Only accounts assigned to a rep (case-insensitive name or `rep_id` substring) | | `--limit N` | Process at most N accounts. Order: **strategic tier first, then least-recently-scanned** (never-scanned first). The run cursor (`output/state/run_cursor.json`) persists, so successive `--limit` runs walk the whole territory — e.g. a nightly `loop --limit 30` covers 342 accounts in ~12 days. | +| `--capability SET` | Capability set to reason against: `ALL` (full 40-capability portfolio, default) or a named set from `data/capability_sets/` (e.g. `agentminder`). In a targeted set the keyword pre-filter is bypassed so EVERY signal is evaluated against the chosen product. List sets: `python3 run.py capability-sets`. **Add a new one-off product**: drop `data/capability_sets/.json` (same schema as `capabilities.json`) and pass `--capability ` — no code changes. | | `--domain-filter` | Scope Exa news to the account's own domain (newsroom/PR only) | | `--no-domain-filter` | Broad-web news about the account (default) | diff --git a/sales-agent/data/capability_sets/agentminder.json b/sales-agent/data/capability_sets/agentminder.json new file mode 100644 index 0000000..4b93c18 --- /dev/null +++ b/sales-agent/data/capability_sets/agentminder.json @@ -0,0 +1,50 @@ +{ + "version": "1.0", + "lastUpdated": "2026-07-16", + "_source": "https://www.broadcom.com/products/identity/agentminder", + "_note": "One-off targeted capability set. Select at runtime: `loop --capability agentminder`. Add more sets as data/capability_sets/.json with this same schema.", + "capabilities": [ + { + "id": "cap_iam_agentminder", + "grouping": "Cybersecurity — Identity & Access Management", + "category": "Agentic AI Security", + "name": "AgentMinder — Secure Agentic AI (Identity & Governance for AI Agents)", + "description": "Agentic security and governance platform that turns autonomous AI agents into production-grade 'digital employees': governs AI agents as first-class identities with least-privilege access, credential protection, centralized authentication/authorization, and lifecycle governance, on a cloud-native container-based architecture.", + "businessProblems": [ + "AI agents/copilots being deployed with no identity, credential, or access governance", + "Non-human identities (service accounts, bots, AI agents) proliferating outside IAM controls", + "No least-privilege or audit trail for what autonomous agents can access and do", + "Compliance/regulatory exposure from ungoverned agentic AI acting on production systems", + "Security teams blocking AI initiatives because agent access cannot be controlled" + ], + "buyingSignals": [ + "Announcements of agentic AI, AI agents, or copilot deployments in production", + "AI transformation or GenAI initiatives touching core business systems", + "Hiring for AI security, AI governance, ML platform, or non-human identity roles", + "Executive posts about AI agents as 'digital workers' or 'digital employees'", + "AI governance, responsible-AI, or AI-risk programs and committees being stood up", + "Security incidents, breaches, or audit findings involving automation or bots", + "Regulatory or compliance pressure on AI usage (finance, healthcare, government)", + "Partnerships with AI platform vendors (OpenAI, Anthropic, Microsoft Copilot, agent frameworks)" + ], + "antiSignals": [ + "Already owns AgentMinder per CRM", + "Standardized on a competing agentic-AI security platform", + "Explicit company policy prohibiting AI agent deployment" + ], + "targetBuyer": { + "titles": ["CISO", "Chief AI Officer", "VP Security", "Director IAM", "VP AI/ML Platform", "CTO"], + "departments": ["Security", "IT", "AI/Data", "Risk & Compliance"] + }, + "keywords": [ + "agentminder", "agentic ai", "ai agent", "ai agents", "autonomous agent", + "digital employee", "digital worker", "copilot", "non-human identity", "nhi", + "machine identity", "ai governance", "ai security", "least privilege", + "agent identity", "genai", "generative ai", "ai risk", "responsible ai", + "service account", "credential", "zero trust" + ] + } + ], + "bundleSignals": [], + "globalAntiSignals": [] +} diff --git a/sales-agent/run.py b/sales-agent/run.py index dd587de..54b77fd 100644 --- a/sales-agent/run.py +++ b/sales-agent/run.py @@ -22,6 +22,8 @@ from src.memory import store, feedback def cmd_loop(args): + from src import capabilities + capabilities.set_active_set(args.capability) if args.no_domain_filter: config.EXA_DOMAIN_FILTER = False # per-run override if args.domain_filter: @@ -95,6 +97,19 @@ def cmd_recalibrate(args): print(f" {k}: weight={v['weight']} (n={v['samples']})") +def cmd_capability_sets(args): + from src import capabilities + import json as _json + for name in capabilities.available_sets(): + if name == "ALL": + n = len(_json.loads((config.DATA_DIR / "capabilities.json").read_text())["capabilities"]) + print(f" ALL {n} capabilities (full portfolio, default)") + else: + doc = _json.loads((config.DATA_DIR / "capability_sets" / f"{name}.json").read_text()) + caps = ", ".join(c["name"].split(" — ")[0] for c in doc["capabilities"]) + print(f" {name:12} {len(doc['capabilities'])} capability(ies): {caps}") + + def cmd_accounts(args): for a in crm.load_accounts(): prods = a.get("currentProducts") or [] @@ -137,6 +152,9 @@ def main(argv=None): p_loop.add_argument("--limit", type=int, help="process at most N accounts (strategic + least-recently-run first); " "successive runs resume where the last left off") + p_loop.add_argument("--capability", default="ALL", metavar="SET", + help="capability set to reason against: ALL (full portfolio, default) " + "or a name from data/capability_sets/ (e.g. agentminder)") p_loop.add_argument("--no-domain-filter", action="store_true", help="broad-web news about the account (default behavior)") p_loop.add_argument("--domain-filter", action="store_true", @@ -168,13 +186,20 @@ def main(argv=None): p_re = sub.add_parser("recalibrate", help="Recompute feedback weights (weekly job)") p_re.set_defaults(func=cmd_recalibrate) + sub.add_parser("capability-sets", help="List available capability sets" + ).set_defaults(func=cmd_capability_sets) sub.add_parser("accounts", help="List target accounts").set_defaults(func=cmd_accounts) sub.add_parser("briefs", help="List all generated briefs").set_defaults(func=cmd_briefs) sub.add_parser("exa-usage", help="Show Exa.ai request budget").set_defaults(func=cmd_exa_usage) args = parser.parse_args(argv) config.ensure_dirs() - args.func(args) + try: + args.func(args) + except KeyboardInterrupt: + print("\nInterrupted — no partial briefs were written; unprocessed " + "signals will be retried on the next run.") + return 130 if __name__ == "__main__": diff --git a/sales-agent/src/capabilities.py b/sales-agent/src/capabilities.py index 53d35fc..42caaa1 100644 --- a/sales-agent/src/capabilities.py +++ b/sales-agent/src/capabilities.py @@ -3,6 +3,12 @@ The design uses a vector ANN pre-filter to cut LLM calls ~60%. For the MVP we approximate that with keyword overlap scoring (no embedding service required). The interface is the same: given a signal, return candidate capabilities. + +CAPABILITY SETS: the loop can target a subset of the portfolio at runtime. +- "ALL" (default): data/capabilities.json — the full portfolio. +- "": data/capability_sets/.json — a one-off targeted set + (same schema). Add new products by dropping another file in that folder and + passing `loop --capability `. """ from __future__ import annotations @@ -12,10 +18,43 @@ from functools import lru_cache from . import config +# Selected via set_active_set() before the loop runs; "ALL" = full portfolio. +_active_set = "ALL" + + +def available_sets() -> list[str]: + sets_dir = config.DATA_DIR / "capability_sets" + names = sorted(p.stem for p in sets_dir.glob("*.json")) if sets_dir.exists() else [] + return ["ALL"] + names + + +def set_active_set(name: str) -> None: + """Select which capability library the loop reasons against.""" + global _active_set + name = (name or "ALL").strip() + if name.upper() == "ALL": + _active_set = "ALL" + else: + path = config.DATA_DIR / "capability_sets" / f"{name}.json" + if not path.exists(): + raise SystemExit( + f"Unknown capability set '{name}'. Available: {', '.join(available_sets())}" + ) + _active_set = name + load_library.cache_clear() + + +def active_set() -> str: + return _active_set + @lru_cache(maxsize=1) def load_library() -> dict: - return json.loads((config.DATA_DIR / "capabilities.json").read_text()) + if _active_set == "ALL": + return json.loads((config.DATA_DIR / "capabilities.json").read_text()) + return json.loads( + (config.DATA_DIR / "capability_sets" / f"{_active_set}.json").read_text() + ) def capability_by_id(cap_id: str) -> dict | None: @@ -33,7 +72,13 @@ def prefilter(signal_content: str, top_k: int = 3) -> list[dict]: """Return capabilities whose keywords overlap the signal, best first. Stand-in for the vector ANN pre-filter in the design (section 5.2). + In a targeted set (<=5 capabilities) the pre-filter is skipped: the whole + point of a targeted run is to evaluate EVERY signal against the chosen + product, and the LLM prompt stays small anyway. """ + caps = load_library()["capabilities"] + if _active_set != "ALL" and len(caps) <= 5: + return caps sig_tokens = _tokens(signal_content) scored: list[tuple[float, dict]] = [] for cap in load_library()["capabilities"]: diff --git a/sales-agent/src/config.py b/sales-agent/src/config.py index be3e6ab..e9c9a39 100644 --- a/sales-agent/src/config.py +++ b/sales-agent/src/config.py @@ -6,9 +6,6 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent.parent DATA_DIR = ROOT / "data" -OUTPUT_DIR = ROOT / "output" -STATE_DIR = OUTPUT_DIR / "state" -BRIEFS_DIR = OUTPUT_DIR / "briefs" def _load_dotenv() -> None: @@ -27,6 +24,15 @@ def _load_dotenv() -> None: _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 @@ -66,6 +72,16 @@ LLM_MODEL = os.environ.get("LLM_MODEL", "claude-sonnet-5") # 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() @@ -74,14 +90,14 @@ GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash") _provider = os.environ.get("LLM_PROVIDER", "").strip().lower() if not _provider: - if ANTHROPIC_OAUTH_TOKEN or ANTHROPIC_API_KEY: + if ANTHROPIC_API_KEY: _provider = "anthropic" elif OPENAI_API_KEY: _provider = "openai" elif GEMINI_API_KEY: _provider = "gemini" else: - _provider = "none" + _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: @@ -100,6 +116,8 @@ 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})" diff --git a/sales-agent/src/connectors/exa.py b/sales-agent/src/connectors/exa.py index c4ab6e8..12c9a72 100644 --- a/sales-agent/src/connectors/exa.py +++ b/sales-agent/src/connectors/exa.py @@ -39,11 +39,14 @@ def _current_month() -> str: def _load_usage() -> dict: config.ensure_dirs() p = _usage_path() - if p.exists(): - data = json.loads(p.read_text()) - if data.get("month") == _current_month(): - return data - # New file or a new month -> reset the counter. + try: + if p.exists(): + data = json.loads(p.read_text()) + if data.get("month") == _current_month() and isinstance(data.get("count"), int): + return data + except (OSError, ValueError): + pass # unreadable/corrupt file -> reset below + # New file, new month, or malformed -> reset the counter. return {"month": _current_month(), "count": 0} diff --git a/sales-agent/src/llm.py b/sales-agent/src/llm.py index eeae0b7..d9655c3 100644 --- a/sales-agent/src/llm.py +++ b/sales-agent/src/llm.py @@ -164,9 +164,36 @@ def _call_gemini(system: str, user: str, max_tokens: int) -> dict: _keychain_token_cache: dict = {} +def _read_keychain_cred() -> dict: + import subprocess + raw = subprocess.run( + ["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"], + capture_output=True, text=True, timeout=10, + ).stdout.strip() + return json.loads(raw).get("claudeAiOauth", {}) + + +def _refresh_via_claude_cli() -> bool: + """Make Claude Code refresh its own OAuth token by running a tiny headless + command. Returns True if the CLI ran (the keychain should then be fresh).""" + import subprocess + try: + print(" [llm] keychain OAuth token expired — auto-refreshing via `claude -p` ...") + subprocess.run( + ["claude", "-p", "reply: ok", "--model", "haiku"], + capture_output=True, text=True, timeout=120, + ) + return True + except Exception as e: + print(f" [llm] auto-refresh failed: {str(e)[:80]}") + return False + + def _anthropic_oauth_token() -> str: """Resolve an OAuth access token: env var first, then the Claude Code - credential stored in the macOS keychain (kept fresh by Claude Code).""" + credential in the macOS keychain. If the keychain token is expired and + ANTHROPIC_AUTOREFRESH=1 (default), a headless `claude` call refreshes it + automatically — the loop self-heals without human intervention.""" if config.ANTHROPIC_OAUTH_TOKEN: return config.ANTHROPIC_OAUTH_TOKEN if not config.ANTHROPIC_USE_KEYCHAIN: @@ -176,20 +203,19 @@ def _anthropic_oauth_token() -> str: if _keychain_token_cache.get("expires", 0) > now: return _keychain_token_cache["token"] try: - import subprocess - raw = subprocess.run( - ["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"], - capture_output=True, text=True, timeout=10, - ).stdout.strip() - cred = json.loads(raw).get("claudeAiOauth", {}) + cred = _read_keychain_cred() + expired = cred.get("accessToken") and cred.get("expiresAt", 0) / 1000 < now + if expired and config.ANTHROPIC_AUTOREFRESH and _refresh_via_claude_cli(): + cred = _read_keychain_cred() # re-read after refresh + expired = cred.get("accessToken") and cred.get("expiresAt", 0) / 1000 < time.time() token = cred.get("accessToken", "") - exp_ms = cred.get("expiresAt", 0) - if token and exp_ms and exp_ms / 1000 < now: + if token and expired: raise LLMNotConfigured( "The Claude Code OAuth token in the keychain is EXPIRED " - f"(expired {time.strftime('%Y-%m-%d %H:%M', time.localtime(exp_ms/1000))}). " - "Run any command in the Claude Code CLI (e.g. `claude -p hi`) to refresh it, " - "or set ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY in .env." + f"(expired {time.strftime('%Y-%m-%d %H:%M', time.localtime(cred.get('expiresAt', 0)/1000))}) " + "and auto-refresh did not produce a fresh one. Run `claude -p hi` manually, " + "or set ANTHROPIC_OAUTH_TOKEN (long-lived token from `claude setup-token`) " + "or ANTHROPIC_API_KEY in .env." ) if token: _keychain_token_cache.update({"token": token, "expires": now + 60}) @@ -230,7 +256,38 @@ def _call_anthropic(system: str, user: str, max_tokens: int) -> dict: return _extract_json(text) +def _call_claude_cli(system: str, user: str, max_tokens: int) -> dict: + """Inference through Claude Code's official headless mode (`claude -p`). + + This is the sanctioned way to run subscription inference programmatically: + direct /v1/messages calls with a Claude Code OAuth token are policy-blocked + (the API returns a disguised 429 'Error' for non-Claude-Code traffic). + """ + import subprocess + cmd = [ + "claude", "-p", user, + "--append-system-prompt", system + "\nReturn ONLY a JSON object. No prose, no markdown fences.", + "--model", config.CLAUDE_CLI_MODEL, + ] + try: + proc = subprocess.run(cmd, capture_output=True, text=True, + timeout=config.CLAUDE_CLI_TIMEOUT) + except FileNotFoundError: + raise LLMNotConfigured("`claude` CLI not found on PATH (needed for LLM_PROVIDER=claude-cli).") + except subprocess.TimeoutExpired: + raise LLMRetryable(f"claude CLI timed out after {config.CLAUDE_CLI_TIMEOUT}s") + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "").strip()[:300] + # Usage-limit messages from the CLI are worth waiting out like a 429. + if "limit" in err.lower(): + raise LLMRetryable(f"claude CLI usage limit: {err}") + raise LLMError(f"claude CLI failed (rc={proc.returncode}): {err}") + return _extract_json(proc.stdout.strip()) + + def _dispatch(system: str, user: str, max_tokens: int) -> dict: + if config.LLM_PROVIDER == "claude-cli": + return _call_claude_cli(system, user, max_tokens) if config.LLM_PROVIDER == "openai": return _call_openai(system, user, max_tokens) if config.LLM_PROVIDER == "gemini": diff --git a/sales-agent/src/memory/store.py b/sales-agent/src/memory/store.py index c7143e6..363dd20 100644 --- a/sales-agent/src/memory/store.py +++ b/sales-agent/src/memory/store.py @@ -19,8 +19,13 @@ def _account_path(account_id: str) -> Path: def load_memory(account_id: str) -> dict: config.ensure_dirs() p = _account_path(account_id) - if p.exists(): - return json.loads(p.read_text()) + try: + if p.exists(): + return json.loads(p.read_text()) + except (PermissionError, OSError) as e: + # macOS TCC/endpoint security can lock individual files (com.apple.macl); + # start fresh rather than killing the whole run — memory is regenerable. + print(f" [memory] cannot read {p.name} ({e.__class__.__name__}) — starting fresh") return { "accountId": account_id, "signalHashes": [], # [{hash, discoveredAt, signalType}] diff --git a/sales-agent/src/pipeline.py b/sales-agent/src/pipeline.py index dbbcad1..5acf870 100644 --- a/sales-agent/src/pipeline.py +++ b/sales-agent/src/pipeline.py @@ -161,8 +161,12 @@ def run_all( total = len(accounts) if limit and limit > 0: accounts = accounts[:limit] + from . import capabilities + cap_set = capabilities.active_set() + n_caps = len(capabilities.load_library()["capabilities"]) print(f"Running prospecting loop over {len(accounts)}/{total} account(s) " f"(strategic + least-recently-run first) — LLM: {config.llm_label()}") + print(f"Capability set: {cap_set} ({n_caps} capabilit{'y' if n_caps == 1 else 'ies'})") if config.EXA_ENABLED: st = exa.usage_status() scope = "domain-scoped" if config.EXA_DOMAIN_FILTER else "broad web"