From 07eb9a541124421ed082746385f9779784a2a9e8 Mon Sep 17 00:00:00 2001 From: Chris Olson Date: Fri, 24 Jul 2026 08:42:19 -0400 Subject: [PATCH] claude-cli: pause until reset on session-limit; harden capability-set loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - On a Claude Code session/usage limit ('resets 7:10pm'), _call_claude_cli now pauses IN PLACE until the stated reset time + CLAUDE_CLI_LIMIT_BUFFER_SECONDS (default 60s past), then retries — up to CLAUDE_CLI_LIMIT_MAX_WAITS windows. Parses 12-hour reset times to their next occurrence; falls back to a fixed wait when no time is present. Makes long unattended runs survive reset windows. - load_library() reports invalid capability-set JSON as a clean SystemExit (line number + reason) instead of a traceback; fixed a trailing-comma typo in data/capability_sets/dx02.json. - Docs: USAGE.md + .env.example document the claude-cli provider and its knobs. Co-Authored-By: Claude Opus 4.8 --- sales-agent/.env.example | 19 +++-- sales-agent/USAGE.md | 9 ++- sales-agent/data/capability_sets/dx02.json | 55 +++++++++++++ sales-agent/src/capabilities.py | 15 ++-- sales-agent/src/config.py | 7 ++ sales-agent/src/llm.py | 92 ++++++++++++++++++---- 6 files changed, 170 insertions(+), 27 deletions(-) create mode 100644 sales-agent/data/capability_sets/dx02.json diff --git a/sales-agent/.env.example b/sales-agent/.env.example index cea9237..8eb4927 100644 --- a/sales-agent/.env.example +++ b/sales-agent/.env.example @@ -1,12 +1,19 @@ -# Copy to .env and fill in. The loop runs WITHOUT a key using a mock reasoner. +# Copy to .env and fill in. # --- LLM provider --- -# LLM_PROVIDER: anthropic | openai | gemini (auto-detected if blank). -LLM_PROVIDER=anthropic +# LLM_PROVIDER: claude-cli | anthropic | openai | gemini. +# claude-cli = run inference through Claude Code headless mode (`claude -p`), +# the sanctioned way to use a Claude subscription programmatically. +# (Direct-API calls with a Claude Code OAuth token are policy-blocked.) +LLM_PROVIDER=claude-cli +CLAUDE_CLI_MODEL=sonnet +CLAUDE_CLI_TIMEOUT=300 +# On a session-limit hit ("resets 7:10pm"), pause until reset + buffer, then retry: +CLAUDE_CLI_LIMIT_BUFFER_SECONDS=60 +CLAUDE_CLI_LIMIT_MAX_WAITS=6 +CLAUDE_CLI_LIMIT_FALLBACK_WAIT_SECONDS=3600 -# Anthropic via OAuth (Claude subscription) — token from ANTHROPIC_OAUTH_TOKEN, -# else the Claude Code credential in the macOS keychain (auto-refreshed by -# Claude Code; run any `claude -p ...` command if it has expired). +# Direct Anthropic API (real API key) — use LLM_PROVIDER=anthropic: LLM_MODEL=claude-sonnet-5 ANTHROPIC_EFFORT=low # low | medium | high | max ANTHROPIC_USE_KEYCHAIN=1 diff --git a/sales-agent/USAGE.md b/sales-agent/USAGE.md index 75b517e..38b3ba4 100644 --- a/sales-agent/USAGE.md +++ b/sales-agent/USAGE.md @@ -150,8 +150,13 @@ entitlements.json are gitignored and never leave the machine. | Var | Default | Meaning | |---|---|---| -| `LLM_PROVIDER` | auto | `anthropic` \| `openai` \| `gemini` (auto-detects from keys if blank) | -| `LLM_MODEL` | `claude-sonnet-5` | Anthropic model id | +| `LLM_PROVIDER` | `claude-cli` | `claude-cli` (subscription via Claude Code headless) \| `anthropic` (direct API key) \| `openai` \| `gemini` | +| `CLAUDE_CLI_MODEL` | `sonnet` | Model alias for the `claude -p` headless provider | +| `CLAUDE_CLI_TIMEOUT` | `300` | Per-call timeout (s) for the headless CLI | +| `CLAUDE_CLI_LIMIT_BUFFER_SECONDS` | `60` | On a session-limit hit, pause until the stated reset time **plus this** (1 min past), then retry | +| `CLAUDE_CLI_LIMIT_MAX_WAITS` | `6` | Max reset windows to wait through in one call before giving up | +| `CLAUDE_CLI_LIMIT_FALLBACK_WAIT_SECONDS` | `3600` | Wait used only when no reset time can be parsed from the CLI message | +| `LLM_MODEL` | `claude-sonnet-5` | Anthropic model id (direct-API `anthropic` provider) | | `ANTHROPIC_EFFORT` | `low` | Reasoning effort: `low` \| `medium` \| `high` \| `max` | | `ANTHROPIC_USE_KEYCHAIN` | `1` | Read the Claude Code OAuth token from the macOS keychain (kept fresh by Claude Code; refresh with any `claude -p ...` call if expired) | | `ANTHROPIC_OAUTH_TOKEN` | — | Explicit OAuth token (overrides keychain) | diff --git a/sales-agent/data/capability_sets/dx02.json b/sales-agent/data/capability_sets/dx02.json new file mode 100644 index 0000000..34c887a --- /dev/null +++ b/sales-agent/data/capability_sets/dx02.json @@ -0,0 +1,55 @@ +{ + "version": "1.0", + "lastUpdated": "2026-07-16", + "_source": "https://www.broadcom.com/products/software/aiops-observability/operational-observability", + "_note": "One-off targeted capability set. Select at runtime: `loop --capability dx02`. Add more sets as data/capability_sets/.json with this same schema.", + "capabilities": [ + { + "id": "cap_es_dx_apm", + "grouping": "Enterprise Software", + "category": "DX — Observability & AIOps", + "name": "DX Application Performance Management (DX APM)", + "description": "Full-stack application performance monitoring with AI-driven analytics to detect, triage, and resolve performance issues fast.", + "businessProblems": [ + "Application performance incidents hurting customer experience", + "Slow root-cause analysis across complex stacks", + "Siloed monitoring tools with no end-to-end view" + ], + "buyingSignals": [ + "Executive posts about reliability, uptime, or customer experience", + "Hiring SRE, observability, or performance engineers", + "Mentions of major outage or digital experience initiatives", + "Cloud migration increasing monitoring complexity", + "public complaints by users about application performance or availability", + "Major new mobile application announced", + "Mention of costly monitoring tools, specifically DataDog, Dynatrace, AppDynamics" + ], + "antiSignals": ["Already owns DX APM per CRM", "Standardized on a competing APM/observability vendor"], + "targetBuyer": { "titles": ["VP IT Operations", "Head of SRE", "CIO"], "departments": ["IT", "Operations"] }, + "keywords": ["apm", "dx apm", "observability", "application performance", "monitoring", "sre", "reliability", "root cause", "uptime"] + }, + { + "id": "cap_es_dx_aiops", + "grouping": "Enterprise Software", + "category": "DX — Observability & AIOps", + "name": "DX Operational Observability & AIOps", + "description": "Unifies metrics, events, logs, and traces with AIOps to reduce noise, predict issues, and automate IT operations.", + "businessProblems": [ + "Alert storms and tool sprawl overwhelming ops teams", + "Reactive operations with slow MTTR", + "No correlated view across hybrid infrastructure" + ], + "buyingSignals": [ + "Executive posts about AIOps, noise reduction, or MTTR", + "Hiring observability/AIOps roles", + "Mentions of consolidating monitoring tools", + "Major incident driving operations modernization" + ], + "antiSignals": ["Already owns DX OI/AIOps per CRM"], + "targetBuyer": { "titles": ["VP IT Operations", "Head of Observability", "CIO"], "departments": ["IT", "Operations"] }, + "keywords": ["aiops", "observability", "operational intelligence", "mttr", "noise reduction", "events", "logs", "traces", "automation"] + } + ], + "bundleSignals": [], + "globalAntiSignals": [] +} diff --git a/sales-agent/src/capabilities.py b/sales-agent/src/capabilities.py index 42caaa1..debfb50 100644 --- a/sales-agent/src/capabilities.py +++ b/sales-agent/src/capabilities.py @@ -50,11 +50,16 @@ def active_set() -> str: @lru_cache(maxsize=1) def load_library() -> dict: - 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() - ) + path = (config.DATA_DIR / "capabilities.json" if _active_set == "ALL" + else config.DATA_DIR / "capability_sets" / f"{_active_set}.json") + try: + lib = json.loads(path.read_text()) + except json.JSONDecodeError as e: + raise SystemExit(f"Capability set '{_active_set}' has invalid JSON " + f"({path.name}, line {e.lineno}): {e.msg}. Fix the file and re-run.") + if not lib.get("capabilities"): + raise SystemExit(f"Capability set '{_active_set}' ({path.name}) has no 'capabilities'.") + return lib def capability_by_id(cap_id: str) -> dict | None: diff --git a/sales-agent/src/config.py b/sales-agent/src/config.py index e9c9a39..6fff274 100644 --- a/sales-agent/src/config.py +++ b/sales-agent/src/config.py @@ -82,6 +82,13 @@ ANTHROPIC_AUTOREFRESH = os.environ.get("ANTHROPIC_AUTOREFRESH", "1").strip() == # 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")) +# When `claude -p` reports a session/usage limit ("resets 7:10pm"), pause IN +# PLACE until that reset time plus this buffer, then retry — so unattended runs +# survive the subscription's rolling reset windows. +CLAUDE_CLI_LIMIT_BUFFER_SECONDS = int(os.environ.get("CLAUDE_CLI_LIMIT_BUFFER_SECONDS", "60")) +CLAUDE_CLI_LIMIT_MAX_WAITS = int(os.environ.get("CLAUDE_CLI_LIMIT_MAX_WAITS", "6")) +# Used only when a reset time can't be parsed from the CLI message. +CLAUDE_CLI_LIMIT_FALLBACK_WAIT_SECONDS = int(os.environ.get("CLAUDE_CLI_LIMIT_FALLBACK_WAIT_SECONDS", "3600")) # Effort level for reasoning models (Sonnet 5 etc.): low | medium | high | max. ANTHROPIC_EFFORT = os.environ.get("ANTHROPIC_EFFORT", "low").strip() diff --git a/sales-agent/src/llm.py b/sales-agent/src/llm.py index d9655c3..f0ba5dc 100644 --- a/sales-agent/src/llm.py +++ b/sales-agent/src/llm.py @@ -256,12 +256,68 @@ def _call_anthropic(system: str, user: str, max_tokens: int) -> dict: return _extract_json(text) +# Matches the CLI's session/usage-limit banners, e.g. +# "You've hit your session limit · resets 7:10pm" / "5-hour limit reached ∙ resets 3pm". +_USAGE_LIMIT_RE = re.compile(r"(session|usage|\d+-hour)\s+limit|hit your .*limit", re.I) +_RESET_TIME_RE = re.compile(r"resets?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*([ap])\.?\s*m\.?", re.I) + + +def _is_usage_limit(text: str) -> bool: + return bool(_USAGE_LIMIT_RE.search(text or "")) + + +def _reset_epoch(text: str) -> float | None: + """Parse the reset wall-clock time (local tz) into an epoch for its NEXT + occurrence. Returns None if no time is present.""" + from datetime import datetime, timedelta + m = _RESET_TIME_RE.search(text or "") + if not m: + return None + hour = int(m.group(1)) % 12 + if m.group(3).lower() == "p": + hour += 12 + minute = int(m.group(2) or 0) + now = datetime.now() + cand = now.replace(hour=hour, minute=minute, second=0, microsecond=0) + if cand <= now: # already passed today -> next occurrence + cand += timedelta(days=1) + return cand.timestamp() + + +def _wait_out_session_limit(text: str) -> None: + """Pause in place until the stated reset time + buffer (or a fallback wait).""" + epoch = _reset_epoch(text) + now = time.time() + if epoch is None: + resume = now + config.CLAUDE_CLI_LIMIT_FALLBACK_WAIT_SECONDS + note = "no reset time parsed; using fallback wait" + else: + resume = epoch + config.CLAUDE_CLI_LIMIT_BUFFER_SECONDS + note = f"1 min past reset" + resume = max(resume, now + 1) + mins = int((resume - now) // 60) + hhmm = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(resume)) + banner = (text or "").strip().replace("\n", " ")[:90] + print(f" [llm] Claude CLI session limit — pausing until {hhmm} " + f"(~{mins} min, {note}) then resuming. «{banner}»") + # Chunked sleep so a long pause heartbeats and stays Ctrl-C responsive. + while True: + remaining = resume - time.time() + if remaining <= 0: + break + time.sleep(min(remaining, 300)) + print(" [llm] reset window elapsed — resuming.") + + 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). + + On a session/usage limit the call pauses IN PLACE until the stated reset + time (+buffer) and retries, so long unattended runs survive reset windows. """ import subprocess cmd = [ @@ -269,20 +325,28 @@ def _call_claude_cli(system: str, user: str, max_tokens: int) -> dict: "--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()) + for wait_i in range(config.CLAUDE_CLI_LIMIT_MAX_WAITS + 1): + 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") + + out, err = (proc.stdout or "").strip(), (proc.stderr or "").strip() + # A session/usage limit surfaces as a CLI-level message (stderr, or + # stdout on a nonzero exit) — never inside a successful JSON answer. + limit_text = err if _is_usage_limit(err) else (out if (proc.returncode != 0 and _is_usage_limit(out)) else "") + if limit_text: + if wait_i >= config.CLAUDE_CLI_LIMIT_MAX_WAITS: + raise LLMError(f"claude CLI session limit persisted after " + f"{config.CLAUDE_CLI_LIMIT_MAX_WAITS} wait(s): {limit_text[:200]}") + _wait_out_session_limit(limit_text) + continue + if proc.returncode != 0: + raise LLMError(f"claude CLI failed (rc={proc.returncode}): {(err or out)[:300]}") + return _extract_json(out) def _dispatch(system: str, user: str, max_tokens: int) -> dict: