claude-cli: pause until reset on session-limit; harden capability-set loader
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 ---
|
||||||
# LLM_PROVIDER: anthropic | openai | gemini (auto-detected if blank).
|
# LLM_PROVIDER: claude-cli | anthropic | openai | gemini.
|
||||||
LLM_PROVIDER=anthropic
|
# 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,
|
# Direct Anthropic API (real API key) — use LLM_PROVIDER=anthropic:
|
||||||
# else the Claude Code credential in the macOS keychain (auto-refreshed by
|
|
||||||
# Claude Code; run any `claude -p ...` command if it has expired).
|
|
||||||
LLM_MODEL=claude-sonnet-5
|
LLM_MODEL=claude-sonnet-5
|
||||||
ANTHROPIC_EFFORT=low # low | medium | high | max
|
ANTHROPIC_EFFORT=low # low | medium | high | max
|
||||||
ANTHROPIC_USE_KEYCHAIN=1
|
ANTHROPIC_USE_KEYCHAIN=1
|
||||||
|
|||||||
@@ -150,8 +150,13 @@ entitlements.json are gitignored and never leave the machine.
|
|||||||
|
|
||||||
| Var | Default | Meaning |
|
| Var | Default | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `LLM_PROVIDER` | auto | `anthropic` \| `openai` \| `gemini` (auto-detects from keys if blank) |
|
| `LLM_PROVIDER` | `claude-cli` | `claude-cli` (subscription via Claude Code headless) \| `anthropic` (direct API key) \| `openai` \| `gemini` |
|
||||||
| `LLM_MODEL` | `claude-sonnet-5` | Anthropic model id |
|
| `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_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_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) |
|
| `ANTHROPIC_OAUTH_TOKEN` | — | Explicit OAuth token (overrides keychain) |
|
||||||
|
|||||||
55
sales-agent/data/capability_sets/dx02.json
Normal file
55
sales-agent/data/capability_sets/dx02.json
Normal file
@@ -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/<name>.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": []
|
||||||
|
}
|
||||||
@@ -50,11 +50,16 @@ def active_set() -> str:
|
|||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def load_library() -> dict:
|
def load_library() -> dict:
|
||||||
if _active_set == "ALL":
|
path = (config.DATA_DIR / "capabilities.json" if _active_set == "ALL"
|
||||||
return json.loads((config.DATA_DIR / "capabilities.json").read_text())
|
else config.DATA_DIR / "capability_sets" / f"{_active_set}.json")
|
||||||
return json.loads(
|
try:
|
||||||
(config.DATA_DIR / "capability_sets" / f"{_active_set}.json").read_text()
|
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:
|
def capability_by_id(cap_id: str) -> dict | None:
|
||||||
|
|||||||
@@ -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.
|
# 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_MODEL = os.environ.get("CLAUDE_CLI_MODEL", "sonnet").strip()
|
||||||
CLAUDE_CLI_TIMEOUT = int(os.environ.get("CLAUDE_CLI_TIMEOUT", "300"))
|
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.
|
# Effort level for reasoning models (Sonnet 5 etc.): low | medium | high | max.
|
||||||
ANTHROPIC_EFFORT = os.environ.get("ANTHROPIC_EFFORT", "low").strip()
|
ANTHROPIC_EFFORT = os.environ.get("ANTHROPIC_EFFORT", "low").strip()
|
||||||
|
|
||||||
|
|||||||
@@ -256,12 +256,68 @@ def _call_anthropic(system: str, user: str, max_tokens: int) -> dict:
|
|||||||
return _extract_json(text)
|
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:
|
def _call_claude_cli(system: str, user: str, max_tokens: int) -> dict:
|
||||||
"""Inference through Claude Code's official headless mode (`claude -p`).
|
"""Inference through Claude Code's official headless mode (`claude -p`).
|
||||||
|
|
||||||
This is the sanctioned way to run subscription inference programmatically:
|
This is the sanctioned way to run subscription inference programmatically:
|
||||||
direct /v1/messages calls with a Claude Code OAuth token are policy-blocked
|
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).
|
(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
|
import subprocess
|
||||||
cmd = [
|
cmd = [
|
||||||
@@ -269,6 +325,7 @@ 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.",
|
"--append-system-prompt", system + "\nReturn ONLY a JSON object. No prose, no markdown fences.",
|
||||||
"--model", config.CLAUDE_CLI_MODEL,
|
"--model", config.CLAUDE_CLI_MODEL,
|
||||||
]
|
]
|
||||||
|
for wait_i in range(config.CLAUDE_CLI_LIMIT_MAX_WAITS + 1):
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(cmd, capture_output=True, text=True,
|
proc = subprocess.run(cmd, capture_output=True, text=True,
|
||||||
timeout=config.CLAUDE_CLI_TIMEOUT)
|
timeout=config.CLAUDE_CLI_TIMEOUT)
|
||||||
@@ -276,13 +333,20 @@ def _call_claude_cli(system: str, user: str, max_tokens: int) -> dict:
|
|||||||
raise LLMNotConfigured("`claude` CLI not found on PATH (needed for LLM_PROVIDER=claude-cli).")
|
raise LLMNotConfigured("`claude` CLI not found on PATH (needed for LLM_PROVIDER=claude-cli).")
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise LLMRetryable(f"claude CLI timed out after {config.CLAUDE_CLI_TIMEOUT}s")
|
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:
|
if proc.returncode != 0:
|
||||||
err = (proc.stderr or proc.stdout or "").strip()[:300]
|
raise LLMError(f"claude CLI failed (rc={proc.returncode}): {(err or out)[:300]}")
|
||||||
# Usage-limit messages from the CLI are worth waiting out like a 429.
|
return _extract_json(out)
|
||||||
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:
|
def _dispatch(system: str, user: str, max_tokens: int) -> dict:
|
||||||
|
|||||||
Reference in New Issue
Block a user