Coverage for astrocyte/providers/claude_cli.py: 91%

202 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-10-29 22:10 +0000

1"""Claude Code CLI LLMProvider adapter — completion via the local ``claude`` binary. 

2 

3Runs ``claude -p`` (print mode) as a subprocess for every ``complete()`` call. 

4Authentication is the CLI's own logged-in subscription: no ANTHROPIC_API_KEY is 

5required, and any key present in the environment is deliberately NOT passed to 

6the subprocess, so calls bill the subscription rather than an API key. 

7 

8Usage programmatically (the bench harness path):: 

9 

10 from astrocyte.providers.claude_cli import ClaudeCliProvider 

11 

12 llm = ClaudeCliProvider(model="haiku") 

13 

14Limitations (inherent to the CLI surface, documented rather than hidden): 

15 

16- ``temperature`` and ``max_tokens`` are accepted for protocol compatibility 

17 but IGNORED — the CLI exposes no sampling controls. Results are therefore a 

18 separate ablation track, never comparable to API-provider runs. 

19- ``tools`` / native function calling is NOT supported and raises 

20 ``NotImplementedError`` (loud failure beats silently dropping tool calls). 

21- ``response_format`` (json_object / json_schema) is EMULATED by appending a 

22 JSON-only instruction to the prompt. Downstream Astrocyte stages already 

23 parse with ``_json_tolerant``, so prose slippage degrades gracefully. 

24- ``embed()`` is NOT supported — the CLI has no embeddings surface. Compose 

25 with :class:`~astrocyte.providers.local_embeddings.LocalEmbeddingsProvider` 

26 via :class:`~astrocyte.providers.composite.CompositeLLMProvider`. 

27- Each call pays ~1-3s of process startup on top of inference. Concurrency is 

28 bounded by a semaphore (``ASTROCYTE_CLAUDE_CLI_MAX_CONCURRENCY``, default 4). 

29 

30Resilience (added after the first claude-native n=90 run lost ~1300 ingest 

31calls to silent throttling and produced a bench over empty memories): 

32 

33- **Both streams captured on failure.** The CLI reports throttling on 

34 stdout; logging stderr alone made every failure look empty. 

35- **Rate-limit-aware backoff.** Output matching a throttle signature backs 

36 off on a minutes scale (``ASTROCYTE_CLAUDE_CLI_RATE_LIMIT_BACKOFF``, 

37 default 60s, doubling per attempt, capped at 600s) rather than the 2s/4s 

38 ladder used for ordinary errors. 

39- **Circuit breaker.** After ``ASTROCYTE_CLAUDE_CLI_BREAKER_THRESHOLD`` 

40 consecutive failures (default 5) *all* callers pause for 

41 ``ASTROCYTE_CLAUDE_CLI_BREAKER_COOLDOWN`` seconds (default 300, doubling 

42 up to 30min) — the pipeline stops rather than completing a run whose 

43 memories were never populated. 

44""" 

45 

46from __future__ import annotations 

47 

48import asyncio 

49import json 

50import logging 

51import os 

52import shutil 

53import tempfile 

54import time 

55from typing import ClassVar 

56 

57from astrocyte.types import ( 

58 Completion, 

59 ContentPart, 

60 LLMCapabilities, 

61 Message, 

62 ToolDefinition, 

63) 

64 

65logger = logging.getLogger("astrocyte.providers.claude_cli") 

66 

67 

68def _flatten_content(content: str | list[ContentPart]) -> str: 

69 """Render Message.content to plain text. Non-text parts are dropped 

70 with a log line — the CLI is a text surface.""" 

71 if isinstance(content, str): 

72 return content 

73 parts: list[str] = [] 

74 for part in content: 

75 text = getattr(part, "text", None) 

76 if text: 

77 parts.append(text) 

78 else: 

79 logger.debug("claude_cli: dropping non-text content part %r", type(part)) 

80 return "\n".join(parts) 

81 

82 

83def _render_prompt(messages: list[Message], response_format: dict | None) -> str: 

84 """Flatten a chat transcript into a single print-mode prompt. 

85 

86 System messages float to the top (in order); user/assistant turns are 

87 role-labelled only when there is more than one non-system message, so 

88 the common single-user-message case stays a clean bare prompt. 

89 """ 

90 system_parts = [_flatten_content(m.content) for m in messages if m.role == "system"] 

91 convo = [m for m in messages if m.role != "system"] 

92 

93 if len(convo) <= 1: 

94 body = _flatten_content(convo[0].content) if convo else "" 

95 else: 

96 body = "\n\n".join( 

97 f"[{m.role}]\n{_flatten_content(m.content)}" for m in convo 

98 ) 

99 

100 blocks: list[str] = [] 

101 if system_parts: 

102 blocks.append("\n\n".join(p for p in system_parts if p)) 

103 if body: 

104 blocks.append(body) 

105 

106 if response_format is not None: 

107 rf_type = response_format.get("type") if isinstance(response_format, dict) else None 

108 if rf_type == "json_schema": 

109 schema = response_format.get("json_schema", {}) 

110 blocks.append( 

111 "IMPORTANT: Respond with a single valid JSON object conforming " 

112 "to this JSON schema. No prose, no code fences.\n" 

113 f"{json.dumps(schema, ensure_ascii=False)}" 

114 ) 

115 else: # json_object and any future variants degrade to the generic rule 

116 blocks.append( 

117 "IMPORTANT: Respond with a single valid JSON object only. " 

118 "No prose, no code fences." 

119 ) 

120 

121 return "\n\n---\n\n".join(blocks) 

122 

123 

124def _normalize_json_output(text: str) -> str | None: 

125 """Best-effort normalization of model output to a bare JSON value. 

126 

127 Emulated JSON mode (see module docstring) means the model can wrap the 

128 object in ```json fences or surrounding prose. Returns the canonical 

129 JSON string when a parseable value can be recovered, else ``None``. 

130 

131 Recovery ladder (first hit wins): 

132 1. The text parses as-is. 

133 2. Code-fence stripped (```json ... ``` or bare ``` fences). 

134 3. Outermost ``{...}`` or ``[...]`` slice of the raw text. 

135 """ 

136 stripped = text.strip() 

137 if not stripped: 

138 return None 

139 try: 

140 json.loads(stripped) 

141 return stripped 

142 except json.JSONDecodeError: 

143 pass 

144 

145 if stripped.startswith("```"): 

146 newline = stripped.find("\n") 

147 inner = stripped[newline + 1 :] if newline != -1 else stripped[3:] 

148 inner = inner.rstrip() 

149 if inner.endswith("```"): 

150 inner = inner[:-3].rstrip() 

151 try: 

152 json.loads(inner) 

153 return inner 

154 except json.JSONDecodeError: 

155 stripped = inner # fall through to brace slicing on the unfenced body 

156 

157 for open_ch, close_ch in (("{", "}"), ("[", "]")): 

158 start = stripped.find(open_ch) 

159 end = stripped.rfind(close_ch) 

160 if start != -1 and end > start: 

161 candidate = stripped[start : end + 1] 

162 try: 

163 json.loads(candidate) 

164 return candidate 

165 except json.JSONDecodeError: 

166 continue 

167 return None 

168 

169 

170#: Substrings that indicate the CLI was refused for quota/rate reasons rather 

171#: than a genuine error. Matched case-insensitively against combined 

172#: stdout+stderr. The CLI reports these on stdout, which is why the first 

173#: claude-native bench run saw 1329 failures with empty error text. 

174_RATE_LIMIT_SIGNATURES: tuple[str, ...] = ( 

175 "rate limit", 

176 "rate-limit", 

177 "usage limit", 

178 "limit reached", 

179 "quota", 

180 "too many requests", 

181 "429", 

182 "overloaded", 

183 "try again later", 

184 "please wait", 

185 "capacity", 

186) 

187 

188 

189def _looks_rate_limited(text: str) -> bool: 

190 """True when CLI output smells like throttling rather than a hard error.""" 

191 low = text.lower() 

192 return any(sig in low for sig in _RATE_LIMIT_SIGNATURES) 

193 

194 

195class _CircuitBreaker: 

196 """Pauses *all* calls after a sustained failure burst. 

197 

198 Motivation: the first claude-native n=90 run ground through ~1300 failed 

199 ingest calls and produced questions whose memories were empty — a bench 

200 that reports a number but measures nothing. A burst of failures means the 

201 upstream is refusing us; the correct response is to stop and wait, not to 

202 keep spending quota on calls that will fail and silently degrade the data. 

203 

204 Opens after ``threshold`` consecutive failures, holds every caller for 

205 ``cooldown`` seconds, then lets a single probe through. A successful call 

206 closes it; a failure re-opens it with the cooldown doubled (capped). 

207 """ 

208 

209 def __init__(self, *, threshold: int = 5, cooldown: float = 300.0, max_cooldown: float = 1800.0) -> None: 

210 self._threshold = threshold 

211 self._base_cooldown = cooldown 

212 self._cooldown = cooldown 

213 self._max_cooldown = max_cooldown 

214 self._consecutive = 0 

215 self._open_until = 0.0 

216 self._lock = asyncio.Lock() 

217 

218 async def await_closed(self) -> None: 

219 """Block until the breaker is closed. No-op in the healthy path.""" 

220 while True: 

221 async with self._lock: 

222 remaining = self._open_until - time.monotonic() 

223 if remaining <= 0: 

224 return 

225 logger.warning( 

226 "claude_cli: circuit OPEN — pausing %.0fs before retrying " 

227 "(sustained failures suggest rate limiting)", remaining, 

228 ) 

229 await asyncio.sleep(min(remaining, 30.0)) 

230 

231 async def record_success(self) -> None: 

232 async with self._lock: 

233 if self._consecutive or self._cooldown != self._base_cooldown: 

234 logger.info("claude_cli: circuit CLOSED — calls succeeding again") 

235 self._consecutive = 0 

236 self._cooldown = self._base_cooldown 

237 self._open_until = 0.0 

238 

239 async def record_failure(self) -> None: 

240 async with self._lock: 

241 self._consecutive += 1 

242 if self._consecutive < self._threshold: 

243 return 

244 self._open_until = time.monotonic() + self._cooldown 

245 logger.error( 

246 "claude_cli: %d consecutive failures — opening circuit for %.0fs. " 

247 "Ingest is PAUSED rather than proceeding with degraded memories.", 

248 self._consecutive, self._cooldown, 

249 ) 

250 self._cooldown = min(self._cooldown * 2, self._max_cooldown) 

251 self._consecutive = 0 

252 

253 

254#: Isolate provider calls from the user's Claude Code configuration: no MCP 

255#: servers (--strict-mcp-config with none given) and no hooks. 

256HERMETIC_ARGS: tuple[str, ...] = ("--strict-mcp-config", "--settings", '{"disableAllHooks": true}') 

257 

258#: Disable Claude Code's built-in tools. Separate from HERMETIC_ARGS on 

259#: purpose: that public constant shipped in 0.16.0 and means "isolate from the 

260#: USER's Claude Code config" (MCP servers, hooks); changing its value is a 

261#: breaking change under the additive-first API policy (griffe). Built-in tools 

262#: are Claude Code's own defaults, a different concern. This provider never 

263#: uses them (``complete(tools=...)`` raises NotImplementedError), yet their 

264#: definitions were ~26,500 of the ~33,000 input tokens on every call 

265#: (measured 2026-10-03: 33,142 -> 6,676 with the flag), and a model that 

266#: tried one hit ``--max-turns 1`` and failed with "Reached max turns (1)" 

267#: (observed in a benchmark run). 

268_BUILTIN_TOOLS_OFF: tuple[str, ...] = ("--tools", "") 

269 

270 

271class ClaudeCliProvider: 

272 """LLMProvider whose ``complete()`` shells out to the Claude Code CLI.""" 

273 

274 SPI_VERSION: ClassVar[int] = 1 

275 

276 def __init__( 

277 self, 

278 *, 

279 model: str = "haiku", 

280 binary: str | None = None, 

281 timeout: float = 180.0, 

282 max_retries: int = 4, 

283 max_concurrency: int | None = None, 

284 rate_limit_backoff: float | None = None, 

285 max_thinking_tokens: int | None = None, 

286 ) -> None: 

287 resolved = binary or os.environ.get("CLAUDE_CLI_BIN") or shutil.which("claude") 

288 if not resolved: 

289 raise RuntimeError( 

290 "ClaudeCliProvider requires the Claude Code CLI on PATH " 

291 "(or CLAUDE_CLI_BIN=/path/to/claude)." 

292 ) 

293 self._bin = resolved 

294 self._model = model 

295 self._timeout = timeout 

296 self._max_retries = max(1, max_retries) 

297 # Throttling clears on a minutes-scale window; seconds-scale retries 

298 # just burn the budget inside it. First delay, doubled per attempt. 

299 self._rate_limit_backoff = rate_limit_backoff or float( 

300 os.environ.get("ASTROCYTE_CLAUDE_CLI_RATE_LIMIT_BACKOFF", "60") 

301 ) 

302 conc = max_concurrency or int( 

303 os.environ.get("ASTROCYTE_CLAUDE_CLI_MAX_CONCURRENCY", "4") 

304 ) 

305 self._sem = asyncio.Semaphore(max(1, conc)) 

306 # Extended-thinking budget passed to the CLI as MAX_THINKING_TOKENS. 

307 # ``None`` leaves Claude Code's default (thinking ON); ``0`` turns it 

308 # off. Measured on structured extraction (18 real LongMemEval batches, 

309 # 2026-10-03): ~85% of output tokens were hidden thinking; with it off 

310 # a call took 31 s instead of 68 s with 55% fewer output tokens, and — 

311 # once metadata was joined by chunk_index — no alignment or entity- 

312 # grounding penalty. Default unchanged because that evidence covers 

313 # extraction, not reflect/synthesis quality. 

314 if max_thinking_tokens is None: 

315 env_budget = os.environ.get("ASTROCYTE_CLAUDE_CLI_MAX_THINKING_TOKENS") 

316 max_thinking_tokens = int(env_budget) if env_budget not in (None, "") else None 

317 if max_thinking_tokens is not None and max_thinking_tokens < 0: 

318 raise ValueError(f"max_thinking_tokens must be >= 0, got {max_thinking_tokens}") 

319 self._max_thinking_tokens = max_thinking_tokens 

320 # Private empty cwd: print-mode runs must not pick up any project's 

321 # CLAUDE.md context. 

322 self._cwd = tempfile.mkdtemp(prefix="astrocyte-claude-cli-") 

323 self._warned_model_override = False 

324 # Shared across every call on this provider: a failure burst pauses 

325 # the whole pipeline instead of letting it grind through with broken 

326 # ingest. Tunable for tests via the env vars below. 

327 self._breaker = _CircuitBreaker( 

328 threshold=int(os.environ.get("ASTROCYTE_CLAUDE_CLI_BREAKER_THRESHOLD", "5")), 

329 cooldown=float(os.environ.get("ASTROCYTE_CLAUDE_CLI_BREAKER_COOLDOWN", "300")), 

330 ) 

331 

332 def capabilities(self) -> LLMCapabilities: 

333 return LLMCapabilities( 

334 supports_multimodal_completion=False, 

335 modalities_supported=("text",), 

336 supports_multimodal_embedding=False, 

337 supports_batch_embed=False, 

338 ) 

339 

340 async def complete( 

341 self, 

342 messages: list[Message], 

343 model: str | None = None, 

344 max_tokens: int = 1024, # noqa: ARG002 — CLI limitation, see module docstring 

345 temperature: float = 0.0, # noqa: ARG002 — CLI limitation 

346 tools: list[ToolDefinition] | None = None, 

347 tool_choice: str | None = None, # noqa: ARG002 

348 response_format: dict | None = None, 

349 ) -> Completion: 

350 if tools: 

351 raise NotImplementedError( 

352 "ClaudeCliProvider does not support native function calling " 

353 "(tools=...). Use an API-backed provider for agentic paths." 

354 ) 

355 # Per-call model overrides carry OpenAI names from legacy call sites 

356 # (e.g. summarizer_model='gpt-4o-mini'); the CLI model wins. Warn once. 

357 if model and model != self._model and not self._warned_model_override: 

358 logger.warning( 

359 "claude_cli: ignoring per-call model override %r — using %r " 

360 "(further overrides silenced)", model, self._model, 

361 ) 

362 self._warned_model_override = True 

363 

364 prompt = _render_prompt(messages, response_format) 

365 env = {k: v for k, v in os.environ.items() if k != "ANTHROPIC_API_KEY"} 

366 # This is an LLM used as an API: it must not run the user's hooks or 

367 # load their MCP servers. Without this, every call loaded whatever MCP 

368 # servers the user had registered (changing the prompt) and ran their 

369 # hooks — including Astrocyte's own capture hooks, which then captured 

370 # the daemon's internal prompts and triggered more calls (observed). 

371 env["ASTROCYTE_HOOKS"] = "off" 

372 if self._max_thinking_tokens is not None: 

373 env["MAX_THINKING_TOKENS"] = str(self._max_thinking_tokens) 

374 

375 last_error: str = "" 

376 rate_limited = False 

377 attempt = 0 

378 while attempt < self._max_retries: 

379 # A sustained failure burst pauses every caller here rather than 

380 # letting the pipeline grind on with failing ingest. 

381 await self._breaker.await_closed() 

382 try: 

383 # The prompt goes in through a file, not a pipe written after 

384 # spawn: `claude -p` waits only 3 s for stdin and then fails 

385 # with "no stdin data received". Any event-loop stall between 

386 # spawn and the write (a CPU-bound embedding, a GC pause) lost 

387 # that race about once per benchmark chunk, and each failure 

388 # fed the rate-limit breaker. A file has the whole prompt 

389 # readable at exec. TemporaryFile is unlinked on POSIX, so the 

390 # prompt (user memory) never sits on disk under a name. 

391 async with self._sem: 

392 with tempfile.TemporaryFile() as prompt_file: 

393 prompt_file.write(prompt.encode("utf-8")) 

394 prompt_file.flush() 

395 prompt_file.seek(0) 

396 proc = await asyncio.create_subprocess_exec( 

397 self._bin, 

398 "-p", 

399 "--model", self._model, 

400 "--output-format", "text", 

401 "--max-turns", "1", 

402 *HERMETIC_ARGS, 

403 *_BUILTIN_TOOLS_OFF, 

404 stdin=prompt_file, 

405 stdout=asyncio.subprocess.PIPE, 

406 stderr=asyncio.subprocess.PIPE, 

407 cwd=self._cwd, 

408 env=env, 

409 ) 

410 try: 

411 stdout, stderr = await asyncio.wait_for( 

412 proc.communicate(), 

413 timeout=self._timeout, 

414 ) 

415 except asyncio.TimeoutError: 

416 proc.kill() 

417 await proc.wait() 

418 raise 

419 if proc.returncode == 0: 

420 text = stdout.decode("utf-8", "replace").strip() 

421 if response_format is not None: 

422 # Emulated JSON mode: normalize fences/prose to bare 

423 # JSON so downstream ``json.loads`` behaves as it 

424 # would under native constrained decoding. An 

425 # unparseable response is retried; after the final 

426 # attempt we fall back to the RAW text (never raise) 

427 # — pipeline stages have tolerant parsing and their 

428 # own graceful-degradation paths. 

429 normalized = _normalize_json_output(text) 

430 if normalized is not None: 

431 text = normalized 

432 elif attempt < self._max_retries - 1: 

433 last_error = "unparseable JSON-mode output" 

434 logger.warning( 

435 "claude_cli: JSON-mode output unparseable " 

436 "(attempt %d/%d) — retrying", 

437 attempt + 1, self._max_retries, 

438 ) 

439 attempt += 1 

440 await asyncio.sleep(2 * attempt) 

441 continue 

442 else: 

443 logger.warning( 

444 "claude_cli: JSON-mode output unparseable " 

445 "after %d attempts — returning raw text", 

446 self._max_retries, 

447 ) 

448 await self._breaker.record_success() 

449 return Completion( 

450 text=text, 

451 model=self._model, 

452 usage=None, # CLI print mode reports no token usage 

453 tool_calls=None, 

454 ) 

455 # Non-zero exit. The CLI reports throttling on STDOUT, so both 

456 # streams are captured — logging only stderr is what made the 

457 # first claude-native run's 1329 failures look empty. 

458 out = stdout.decode("utf-8", "replace").strip() 

459 err = stderr.decode("utf-8", "replace").strip() 

460 combined = (err + "\n" + out).strip() 

461 rate_limited = _looks_rate_limited(combined) 

462 last_error = (combined or f"exit {proc.returncode}, no output")[:500] 

463 logger.warning( 

464 "claude_cli complete attempt %d/%d exited %s%s: %s", 

465 attempt + 1, self._max_retries, proc.returncode, 

466 " [RATE-LIMITED]" if rate_limited else "", last_error, 

467 ) 

468 except asyncio.TimeoutError: 

469 last_error = f"timeout after {self._timeout}s" 

470 rate_limited = False 

471 logger.warning( 

472 "claude_cli complete attempt %d/%d timed out", 

473 attempt + 1, self._max_retries, 

474 ) 

475 except Exception as exc: # noqa: BLE001 

476 last_error = str(exc)[:500] 

477 rate_limited = _looks_rate_limited(last_error) 

478 logger.warning( 

479 "claude_cli complete attempt %d/%d failed: %s", 

480 attempt + 1, self._max_retries, exc, 

481 ) 

482 

483 attempt += 1 

484 if attempt < self._max_retries: 

485 if rate_limited: 

486 # Throttling clears in minutes, not seconds. The old 

487 # 2s/4s ladder burned all retries inside a window that 

488 # had barely started. 

489 delay = min(self._rate_limit_backoff * (2 ** (attempt - 1)), 600.0) 

490 logger.warning( 

491 "claude_cli: rate limited — backing off %.0fs before " 

492 "attempt %d/%d", delay, attempt + 1, self._max_retries, 

493 ) 

494 else: 

495 delay = 2.0 * attempt 

496 await asyncio.sleep(delay) 

497 

498 await self._breaker.record_failure() 

499 raise RuntimeError( 

500 f"claude_cli complete failed after {self._max_retries} attempts" 

501 f"{' (rate limited)' if rate_limited else ''}: {last_error}" 

502 ) 

503 

504 async def embed( 

505 self, 

506 texts: list[str], 

507 model: str | None = None, 

508 ) -> list[list[float]]: 

509 raise NotImplementedError( 

510 "The Claude Code CLI has no embeddings surface. Compose " 

511 "ClaudeCliProvider with LocalEmbeddingsProvider via " 

512 "CompositeLLMProvider (astrocyte.providers.composite)." 

513 )