celltype-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. celltype_cli-0.1.0.dist-info/METADATA +267 -0
  2. celltype_cli-0.1.0.dist-info/RECORD +89 -0
  3. celltype_cli-0.1.0.dist-info/WHEEL +4 -0
  4. celltype_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. celltype_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. ct/__init__.py +3 -0
  7. ct/agent/__init__.py +0 -0
  8. ct/agent/case_studies.py +426 -0
  9. ct/agent/config.py +523 -0
  10. ct/agent/doctor.py +544 -0
  11. ct/agent/knowledge.py +523 -0
  12. ct/agent/loop.py +99 -0
  13. ct/agent/mcp_server.py +478 -0
  14. ct/agent/orchestrator.py +733 -0
  15. ct/agent/runner.py +656 -0
  16. ct/agent/sandbox.py +481 -0
  17. ct/agent/session.py +145 -0
  18. ct/agent/system_prompt.py +186 -0
  19. ct/agent/trace_store.py +228 -0
  20. ct/agent/trajectory.py +169 -0
  21. ct/agent/types.py +182 -0
  22. ct/agent/workflows.py +462 -0
  23. ct/api/__init__.py +1 -0
  24. ct/api/app.py +211 -0
  25. ct/api/config.py +120 -0
  26. ct/api/engine.py +124 -0
  27. ct/cli.py +1448 -0
  28. ct/data/__init__.py +0 -0
  29. ct/data/compute_providers.json +59 -0
  30. ct/data/cro_database.json +395 -0
  31. ct/data/downloader.py +238 -0
  32. ct/data/loaders.py +252 -0
  33. ct/kb/__init__.py +5 -0
  34. ct/kb/benchmarks.py +147 -0
  35. ct/kb/governance.py +106 -0
  36. ct/kb/ingest.py +415 -0
  37. ct/kb/reasoning.py +129 -0
  38. ct/kb/schema_monitor.py +162 -0
  39. ct/kb/substrate.py +387 -0
  40. ct/models/__init__.py +0 -0
  41. ct/models/llm.py +370 -0
  42. ct/tools/__init__.py +195 -0
  43. ct/tools/_compound_resolver.py +297 -0
  44. ct/tools/biomarker.py +368 -0
  45. ct/tools/cellxgene.py +282 -0
  46. ct/tools/chemistry.py +1371 -0
  47. ct/tools/claude.py +390 -0
  48. ct/tools/clinical.py +1153 -0
  49. ct/tools/clue.py +249 -0
  50. ct/tools/code.py +1069 -0
  51. ct/tools/combination.py +397 -0
  52. ct/tools/compute.py +402 -0
  53. ct/tools/cro.py +413 -0
  54. ct/tools/data_api.py +2114 -0
  55. ct/tools/design.py +295 -0
  56. ct/tools/dna.py +575 -0
  57. ct/tools/experiment.py +604 -0
  58. ct/tools/expression.py +655 -0
  59. ct/tools/files.py +957 -0
  60. ct/tools/genomics.py +1387 -0
  61. ct/tools/http_client.py +146 -0
  62. ct/tools/imaging.py +319 -0
  63. ct/tools/intel.py +223 -0
  64. ct/tools/literature.py +743 -0
  65. ct/tools/network.py +422 -0
  66. ct/tools/notification.py +111 -0
  67. ct/tools/omics.py +3330 -0
  68. ct/tools/ops.py +1230 -0
  69. ct/tools/parity.py +649 -0
  70. ct/tools/pk.py +245 -0
  71. ct/tools/protein.py +678 -0
  72. ct/tools/regulatory.py +643 -0
  73. ct/tools/remote_data.py +179 -0
  74. ct/tools/report.py +181 -0
  75. ct/tools/repurposing.py +376 -0
  76. ct/tools/safety.py +1280 -0
  77. ct/tools/shell.py +178 -0
  78. ct/tools/singlecell.py +533 -0
  79. ct/tools/statistics.py +552 -0
  80. ct/tools/structure.py +882 -0
  81. ct/tools/target.py +901 -0
  82. ct/tools/translational.py +123 -0
  83. ct/tools/viability.py +218 -0
  84. ct/ui/__init__.py +0 -0
  85. ct/ui/markdown.py +31 -0
  86. ct/ui/status.py +258 -0
  87. ct/ui/suggestions.py +567 -0
  88. ct/ui/terminal.py +1456 -0
  89. ct/ui/traces.py +112 -0
ct/agent/sandbox.py ADDED
@@ -0,0 +1,481 @@
1
+ """
2
+ Sandboxed Python execution environment for ct code generation.
3
+
4
+ Provides a safe exec() environment with access to scientific Python libraries
5
+ and loaded datasets. Used by the code.execute tool.
6
+ """
7
+
8
+ import io
9
+ import os
10
+ import signal
11
+ import sys
12
+ import traceback
13
+ from pathlib import Path
14
+ from typing import Any, Optional
15
+
16
+
17
+ # Suppress matplotlib warnings before import
18
+ import warnings
19
+ warnings.filterwarnings("ignore", message="Unable to import Axes3D")
20
+ warnings.filterwarnings("ignore", category=UserWarning, module="matplotlib")
21
+
22
+ # Force non-interactive matplotlib backend before any import
23
+ import matplotlib
24
+ matplotlib.use("Agg")
25
+
26
+
27
+ # Modules blocked from import inside the sandbox
28
+ _BLOCKED_MODULES = frozenset({
29
+ "subprocess", "shutil", "socket", "http.server", "smtplib",
30
+ "ctypes",
31
+ })
32
+
33
+ # Allow os import but we'll provide a safe subset in namespace
34
+ _SAFE_OS_ATTRS = frozenset({
35
+ "path", "listdir", "walk", "getcwd", "sep", "linesep",
36
+ "stat", "fstat", "scandir", "DirEntry",
37
+ })
38
+
39
+
40
+ def _make_safe_import(real_import):
41
+ """Wrap __import__ to block dangerous modules."""
42
+ def _safe_import(name, *args, **kwargs):
43
+ base = name.split(".")[0]
44
+ if name in _BLOCKED_MODULES or base in _BLOCKED_MODULES:
45
+ raise ImportError(
46
+ f"Import of '{name}' is blocked in the ct sandbox for safety. "
47
+ f"Use pre-built tools for operations requiring system access."
48
+ )
49
+ return real_import(name, *args, **kwargs)
50
+ return _safe_import
51
+
52
+
53
+ def _is_within(path: Path, root: Path) -> bool:
54
+ """Return True if path is located under root."""
55
+ try:
56
+ path.relative_to(root)
57
+ return True
58
+ except ValueError:
59
+ return False
60
+
61
+
62
+ def _make_safe_open(output_dir: Path, extra_read_dirs: list[Path] = None):
63
+ """Restrict sandbox file I/O to safe paths."""
64
+ real_open = open
65
+ output_root = output_dir.resolve()
66
+ cwd_root = Path.cwd().resolve()
67
+ extra_roots = [d.resolve() for d in (extra_read_dirs or [])]
68
+
69
+ def _safe_open(file, mode="r", *args, **kwargs):
70
+ if isinstance(file, int):
71
+ # Allow file descriptors (stdout/stderr) used by internal libs.
72
+ return real_open(file, mode, *args, **kwargs)
73
+
74
+ path = Path(file).expanduser()
75
+ resolved = path.resolve() if path.is_absolute() else (cwd_root / path).resolve()
76
+
77
+ downloads_root = (Path.home() / ".ct" / "downloads").resolve()
78
+ tmp_root = Path("/tmp").resolve()
79
+ can_read = (
80
+ _is_within(resolved, cwd_root)
81
+ or _is_within(resolved, output_root)
82
+ or _is_within(resolved, downloads_root)
83
+ or _is_within(resolved, tmp_root)
84
+ or any(_is_within(resolved, d) for d in extra_roots)
85
+ )
86
+ if not can_read:
87
+ raise PermissionError(
88
+ f"Sandbox file reads are restricted to {cwd_root}, {output_root}, and {downloads_root}"
89
+ )
90
+
91
+ writes = any(flag in mode for flag in ("w", "a", "x", "+"))
92
+ tmp_root = Path("/tmp").resolve()
93
+ if writes and not (
94
+ _is_within(resolved, output_root)
95
+ or _is_within(resolved, tmp_root)
96
+ ):
97
+ raise PermissionError(
98
+ f"Sandbox file writes are restricted to {output_root} and /tmp"
99
+ )
100
+
101
+ if writes:
102
+ resolved.parent.mkdir(parents=True, exist_ok=True)
103
+
104
+ return real_open(resolved, mode, *args, **kwargs)
105
+
106
+ return _safe_open
107
+
108
+
109
+ _ALLOWED_SUBPROCESS_COMMANDS = frozenset({
110
+ "bwa", "samtools", "busco", "minimap2", "bowtie2",
111
+ "muscle", "mafft", "clustalw", "phykit",
112
+ })
113
+
114
+
115
+ def _make_safe_subprocess(allowed_commands: frozenset[str] = _ALLOWED_SUBPROCESS_COMMANDS):
116
+ """Create a restricted subprocess.run that only allows whitelisted bioinformatics commands."""
117
+ import subprocess as _subprocess
118
+
119
+ def safe_subprocess_run(cmd, **kwargs):
120
+ """Run a whitelisted command. Only bwa, samtools, busco, minimap2, bowtie2 are allowed."""
121
+ if not cmd:
122
+ raise PermissionError("Empty command")
123
+ binary = str(cmd[0]).split("/")[-1] # handle full paths
124
+ if binary not in allowed_commands:
125
+ raise PermissionError(
126
+ f"Command '{binary}' not in allowed list: {sorted(allowed_commands)}. "
127
+ f"Only bioinformatics CLI tools are permitted."
128
+ )
129
+ kwargs.setdefault("timeout", 600)
130
+ kwargs.setdefault("capture_output", True)
131
+ # Use text mode with error handling for non-UTF8 output
132
+ if "text" not in kwargs and "encoding" not in kwargs:
133
+ kwargs["encoding"] = "utf-8"
134
+ kwargs["errors"] = "replace"
135
+ return _subprocess.run(cmd, **kwargs)
136
+
137
+ return safe_subprocess_run
138
+
139
+
140
+ class Sandbox:
141
+ """Sandboxed execution environment for generated Python code."""
142
+
143
+ def __init__(self, timeout: int = 30, output_dir: Path = None, max_retries: int = 2,
144
+ extra_read_dirs: list[Path] = None):
145
+ self.timeout = timeout
146
+ self.output_dir = Path(output_dir) if output_dir else Path.cwd() / "outputs"
147
+ self.max_retries = max_retries
148
+ self.extra_read_dirs = [Path(d).resolve() for d in (extra_read_dirs or [])]
149
+ self._namespace: dict[str, Any] = {}
150
+ self._setup_namespace()
151
+
152
+ def _setup_namespace(self):
153
+ """Populate namespace with safe libraries and utilities."""
154
+ import numpy as np
155
+ import pandas as pd
156
+ import matplotlib.pyplot as plt
157
+ import json
158
+ import re
159
+ import math
160
+ import collections
161
+ import itertools
162
+ import datetime
163
+ import zipfile
164
+ import glob as glob_mod
165
+ import io
166
+ import tempfile
167
+ import struct
168
+ import csv
169
+ import gzip
170
+ import os
171
+ import os.path
172
+ import urllib.request
173
+
174
+ # Build safe builtins dict
175
+ if isinstance(__builtins__, dict):
176
+ safe_builtins = dict(__builtins__)
177
+ else:
178
+ safe_builtins = {k: getattr(__builtins__, k) for k in dir(__builtins__)}
179
+ safe_builtins["__import__"] = _make_safe_import(__import__)
180
+ safe_builtins["open"] = _make_safe_open(self.output_dir, self.extra_read_dirs)
181
+
182
+ self._namespace = {
183
+ # Core data science
184
+ "pd": pd,
185
+ "np": np,
186
+ "plt": plt,
187
+ "json": json,
188
+ "re": re,
189
+ "math": math,
190
+ "collections": collections,
191
+ "itertools": itertools,
192
+ "datetime": datetime,
193
+ "zipfile": zipfile,
194
+ "glob": glob_mod,
195
+ "io": io,
196
+ "tempfile": tempfile,
197
+ "struct": struct,
198
+ "csv": csv,
199
+ "gzip": gzip,
200
+ "os": os,
201
+ "os.path": os.path,
202
+ "urllib": __import__("urllib"),
203
+ "urllib.request": urllib.request,
204
+ "Path": Path,
205
+ # Output directory
206
+ "OUTPUT_DIR": self.output_dir,
207
+ # Safe import
208
+ "__builtins__": safe_builtins,
209
+ }
210
+
211
+ # Add safe_subprocess_run for whitelisted bioinformatics tools
212
+ self._namespace["safe_subprocess_run"] = _make_safe_subprocess()
213
+
214
+ # Optional libraries — add if available
215
+ try:
216
+ import scipy.stats as scipy_stats
217
+ self._namespace["scipy_stats"] = scipy_stats
218
+ self._namespace["scipy"] = __import__("scipy")
219
+ except ImportError:
220
+ pass
221
+
222
+ try:
223
+ import seaborn as sns
224
+ self._namespace["sns"] = sns
225
+ except ImportError:
226
+ pass
227
+
228
+ try:
229
+ import sklearn
230
+ self._namespace["sklearn"] = sklearn
231
+ except ImportError:
232
+ pass
233
+
234
+ # rpy2 for R model fitting (when questions explicitly require R)
235
+ try:
236
+ import rpy2.robjects as ro
237
+ self._namespace["ro"] = ro
238
+ self._namespace["rpy2"] = __import__("rpy2")
239
+ except ImportError:
240
+ pass
241
+
242
+ # Pre-built helper for parsimony informative sites (gap-correct)
243
+ from collections import Counter as _Counter
244
+
245
+ def compute_pi_percentage(seqs):
246
+ """Compute parsimony informative site percentage. EXCLUDES gap characters.
247
+
248
+ Only '-', 'X', and '*' are treated as gaps.
249
+ 'N' is NOT excluded (it's asparagine in protein alignments).
250
+ '?' is NOT excluded (it may represent valid ambiguity).
251
+ """
252
+ if not seqs or len(seqs) < 2:
253
+ return 0.0
254
+ aln_len = len(seqs[0])
255
+ pi_count = 0
256
+ for i in range(aln_len):
257
+ col = [s[i].upper() for s in seqs if i < len(s)]
258
+ col = [c for c in col if c not in ('-', 'X', '*')]
259
+ if len(col) < 2:
260
+ continue
261
+ counts = _Counter(col)
262
+ if sum(1 for c, n in counts.items() if n >= 2) >= 2:
263
+ pi_count += 1
264
+ return pi_count / aln_len * 100
265
+
266
+ self._namespace["compute_pi_percentage"] = compute_pi_percentage
267
+
268
+ def load_datasets(self) -> dict:
269
+ """Load configured datasets into the namespace. Returns dict of loaded names."""
270
+ loaded = {}
271
+ loaders = {
272
+ "crispr": "load_crispr",
273
+ "prism": "load_prism",
274
+ "l1000": "load_l1000",
275
+ "proteomics": "load_proteomics",
276
+ "mutations": "load_mutations",
277
+ "model_metadata": "load_model_metadata",
278
+ }
279
+ for name, func_name in loaders.items():
280
+ try:
281
+ from ct.data import loaders as data_loaders
282
+ loader_fn = getattr(data_loaders, func_name, None)
283
+ if loader_fn:
284
+ df = loader_fn()
285
+ self._namespace[name] = df
286
+ loaded[name] = f"DataFrame {df.shape[0]} rows x {df.shape[1]} cols"
287
+ except (FileNotFoundError, Exception):
288
+ pass # Dataset not configured — skip silently
289
+ return loaded
290
+
291
+ def get_variable(self, name: str, default=None):
292
+ """Retrieve a variable from the sandbox namespace."""
293
+ return self._namespace.get(name, default)
294
+
295
+ def inject_prior_results(self, prior_results: dict):
296
+ """Add prior step results into namespace as step_1, step_2, etc."""
297
+ if not prior_results:
298
+ return
299
+ for step_id, result in prior_results.items():
300
+ self._namespace[f"step_{step_id}"] = result
301
+
302
+ def describe_namespace(self) -> str:
303
+ """Generate a text description of available data and libraries for the LLM."""
304
+ lines = ["## Available in your namespace\n"]
305
+
306
+ # Libraries
307
+ libs = []
308
+ lib_names = ["pd", "np", "plt", "sns", "scipy_stats", "scipy", "sklearn",
309
+ "json", "re", "math", "collections", "itertools", "datetime",
310
+ "ro"]
311
+ for name in lib_names:
312
+ if name in self._namespace:
313
+ libs.append(name)
314
+ lines.append(f"**Libraries**: {', '.join(libs)}")
315
+ lines.append(f"**OUTPUT_DIR**: Path('{self.output_dir}') — save plots/CSVs here\n")
316
+
317
+ # Datasets
318
+ dataset_names = ["crispr", "prism", "l1000", "proteomics", "mutations", "model_metadata"]
319
+ for name in dataset_names:
320
+ if name in self._namespace:
321
+ df = self._namespace[name]
322
+ cols_preview = list(df.columns[:8])
323
+ if len(df.columns) > 8:
324
+ cols_preview.append(f"... ({len(df.columns)} total)")
325
+ lines.append(
326
+ f"**{name}**: DataFrame({df.shape[0]} rows x {df.shape[1]} cols), "
327
+ f"columns: {cols_preview}, index: {df.index.name or type(df.index).__name__}"
328
+ )
329
+
330
+ # Pre-built helpers
331
+ helpers = []
332
+ if "compute_pi_percentage" in self._namespace:
333
+ helpers.append("compute_pi_percentage(seqs) — computes parsimony informative site % with gap exclusion")
334
+ if "safe_subprocess_run" in self._namespace:
335
+ helpers.append("safe_subprocess_run(cmd) — run whitelisted bioinformatics CLI tools")
336
+ if helpers:
337
+ lines.append("**Pre-imported helper functions** (already available — just call them):")
338
+ for h in helpers:
339
+ lines.append(f" - `{h}`")
340
+ lines.append("")
341
+
342
+ # Prior results
343
+ steps = [k for k in self._namespace if k.startswith("step_")]
344
+ if steps:
345
+ lines.append(f"\n**Prior step results**: {', '.join(sorted(steps))}")
346
+ for s in sorted(steps):
347
+ val = self._namespace[s]
348
+ if isinstance(val, dict):
349
+ keys = list(val.keys())[:6]
350
+ lines.append(f" {s}: dict with keys {keys}")
351
+ else:
352
+ lines.append(f" {s}: {type(val).__name__}")
353
+
354
+ return "\n".join(lines)
355
+
356
+ def _protect_preimported_helpers(self, code: str) -> str:
357
+ """Remove any user redefinition of pre-imported helper functions.
358
+
359
+ The sandbox pre-imports correct versions of helpers like compute_pi_percentage.
360
+ LLM-generated code sometimes redefines these incorrectly (e.g., including gap
361
+ characters in PI computation). This method strips such redefinitions so the
362
+ pre-imported versions are used instead.
363
+ """
364
+ import ast
365
+
366
+ protected = {"compute_pi_percentage"}
367
+ # Only protect functions that are actually in the namespace
368
+ protected = {f for f in protected if f in self._namespace}
369
+ if not protected:
370
+ return code
371
+
372
+ try:
373
+ tree = ast.parse(code)
374
+ except SyntaxError:
375
+ return code # Can't parse — let execution handle the error
376
+
377
+ # Find line ranges of protected function definitions
378
+ lines = code.split('\n')
379
+ remove_ranges = []
380
+
381
+ for node in ast.walk(tree):
382
+ if isinstance(node, ast.FunctionDef) and node.name in protected:
383
+ start = node.lineno - 1 # 0-indexed
384
+ end = node.end_lineno # end_lineno is 1-indexed, exclusive after -1+1
385
+ remove_ranges.append((start, end))
386
+
387
+ if not remove_ranges:
388
+ return code
389
+
390
+ # Remove the function definitions (replace with comment)
391
+ new_lines = []
392
+ skip_until = -1
393
+ for i, line in enumerate(lines):
394
+ if i < skip_until:
395
+ continue
396
+ removed = False
397
+ for start, end in remove_ranges:
398
+ if i == start:
399
+ new_lines.append(f'# {line.strip()} — REMOVED: using pre-imported sandbox version')
400
+ skip_until = end
401
+ removed = True
402
+ break
403
+ if not removed and i >= skip_until:
404
+ new_lines.append(line)
405
+
406
+ return '\n'.join(new_lines)
407
+
408
+ def execute(self, code: str) -> dict:
409
+ """Execute code in the sandbox and return results.
410
+
411
+ Returns dict with: stdout, stderr, result, error, plots, exports.
412
+ """
413
+ # Ensure output directory exists
414
+ self.output_dir.mkdir(parents=True, exist_ok=True)
415
+
416
+ # Snapshot output dir contents before execution
417
+ existing_files = set(self.output_dir.iterdir()) if self.output_dir.exists() else set()
418
+
419
+ # Capture stdout/stderr
420
+ old_stdout, old_stderr = sys.stdout, sys.stderr
421
+ captured_out = io.StringIO()
422
+ captured_err = io.StringIO()
423
+
424
+ result = {
425
+ "stdout": "",
426
+ "stderr": "",
427
+ "result": None,
428
+ "error": None,
429
+ "plots": [],
430
+ "exports": [],
431
+ }
432
+
433
+ # Set up timeout (Unix only)
434
+ import threading
435
+ has_alarm = hasattr(signal, "SIGALRM") and threading.current_thread() is threading.main_thread()
436
+ if has_alarm:
437
+ def _timeout_handler(signum, frame):
438
+ raise TimeoutError(f"Code execution timed out after {self.timeout}s")
439
+ old_handler = signal.signal(signal.SIGALRM, _timeout_handler)
440
+ signal.alarm(self.timeout)
441
+
442
+ try:
443
+ sys.stdout = captured_out
444
+ sys.stderr = captured_err
445
+
446
+ # Strip any redefinition of pre-imported helpers to ensure sandbox versions are used
447
+ code = self._protect_preimported_helpers(code)
448
+
449
+ # Compile and execute
450
+ compiled = compile(code, "<ct-sandbox>", "exec")
451
+ exec(compiled, self._namespace)
452
+
453
+ # Capture the 'result' variable if set by the code
454
+ if "result" in self._namespace:
455
+ result["result"] = self._namespace["result"]
456
+
457
+ except TimeoutError as e:
458
+ result["error"] = str(e)
459
+ except Exception:
460
+ result["error"] = traceback.format_exc()
461
+ finally:
462
+ # Restore
463
+ sys.stdout = old_stdout
464
+ sys.stderr = old_stderr
465
+ if has_alarm:
466
+ signal.alarm(0)
467
+ signal.signal(signal.SIGALRM, old_handler)
468
+
469
+ result["stdout"] = captured_out.getvalue()
470
+ result["stderr"] = captured_err.getvalue()
471
+
472
+ # Detect new files in output dir
473
+ if self.output_dir.exists():
474
+ new_files = set(self.output_dir.iterdir()) - existing_files
475
+ for f in sorted(new_files):
476
+ if f.suffix in (".png", ".svg", ".jpg", ".jpeg", ".pdf"):
477
+ result["plots"].append(str(f))
478
+ elif f.suffix in (".csv", ".xlsx", ".tsv", ".json"):
479
+ result["exports"].append(str(f))
480
+
481
+ return result
ct/agent/session.py ADDED
@@ -0,0 +1,145 @@
1
+ """
2
+ Session management: holds config, LLM clients, and shared state for a ct session.
3
+ """
4
+
5
+ import time
6
+ from pathlib import Path
7
+ from rich.console import Console
8
+
9
+ from ct.agent.config import Config
10
+
11
+
12
+ class Session:
13
+ """Manages state for a ct research session."""
14
+
15
+ def __init__(self, config: Config = None, verbose: bool = False, mode: str = "batch"):
16
+ self.config = config or Config.load()
17
+ self.verbose = verbose
18
+ self.mode = mode # "interactive" or "batch"
19
+ self.console = Console()
20
+ self._llm = None
21
+ self._scratchpad = []
22
+ self._tool_health_failures: dict[str, list[float]] = {}
23
+ self._tool_health_suppressed_until: dict[str, float] = {}
24
+
25
+ def get_llm(self):
26
+ """Get or create the LLM client based on config."""
27
+ if self._llm is None:
28
+ self._llm = self._create_llm()
29
+ return self._llm
30
+
31
+ def _create_llm(self):
32
+ """Create LLM client from config."""
33
+ from ct.models.llm import LLMClient
34
+
35
+ provider = self.config.get("llm.provider", "anthropic")
36
+ model = self.config.get("llm.model", None)
37
+ api_key = self.config.llm_api_key(provider)
38
+
39
+ return LLMClient(
40
+ provider=provider,
41
+ model=model,
42
+ api_key=api_key,
43
+ )
44
+
45
+ def set_model(self, model: str, provider: str = None):
46
+ """Switch the LLM model mid-session. Resets the client."""
47
+ if provider:
48
+ self.config.set("llm.provider", provider)
49
+ self.config.set("llm.model", model)
50
+ self._llm = None # Force re-creation on next get_llm()
51
+
52
+ @property
53
+ def current_model(self) -> str:
54
+ """Return the current model name."""
55
+ if self._llm:
56
+ return self._llm.model
57
+ return self.config.get("llm.model") or "claude-sonnet-4-5-20250929"
58
+
59
+ def log(self, message: str):
60
+ """Log to scratchpad (for debugging/transparency)."""
61
+ self._scratchpad.append(message)
62
+ if self.verbose:
63
+ self.console.print(f" [dim]{message}[/dim]")
64
+
65
+ def save_scratchpad(self, path: Path):
66
+ """Save scratchpad to file for debugging."""
67
+ path.parent.mkdir(parents=True, exist_ok=True)
68
+ path.write_text("\n".join(self._scratchpad))
69
+
70
+ # --- Runtime tool-health tracking (for planner suppression) ---
71
+
72
+ def _tool_health_enabled(self) -> bool:
73
+ return bool(self.config.get("agent.tool_health_enabled", True))
74
+
75
+ def _tool_failure_window_seconds(self) -> int:
76
+ return int(self.config.get("agent.tool_health_failure_window_s", 1800))
77
+
78
+ def _tool_fail_threshold(self) -> int:
79
+ return int(self.config.get("agent.tool_health_fail_threshold", 2))
80
+
81
+ def _tool_suppress_seconds(self) -> int:
82
+ return int(self.config.get("agent.tool_health_suppress_seconds", 900))
83
+
84
+ def _is_transient_tool_error(self, error_text: str) -> bool:
85
+ text = str(error_text or "").lower()
86
+ transient_markers = (
87
+ "timeout",
88
+ "timed out",
89
+ "connection",
90
+ "dns",
91
+ "service unavailable",
92
+ "rate limit",
93
+ "429",
94
+ "500",
95
+ "502",
96
+ "503",
97
+ "504",
98
+ "expected json",
99
+ "invalid json response",
100
+ "temporarily unavailable",
101
+ )
102
+ return any(marker in text for marker in transient_markers)
103
+
104
+ def record_tool_success(self, tool_name: str):
105
+ """Clear runtime failure pressure after a successful execution."""
106
+ if not tool_name:
107
+ return
108
+ self._tool_health_failures.pop(tool_name, None)
109
+ self._tool_health_suppressed_until.pop(tool_name, None)
110
+
111
+ def record_tool_failure(self, tool_name: str, error_text: str = ""):
112
+ """Record transient tool failures and suppress flaky tools temporarily."""
113
+ if not self._tool_health_enabled() or not tool_name:
114
+ return
115
+ if not self._is_transient_tool_error(error_text):
116
+ return
117
+
118
+ now = time.time()
119
+ window_s = max(60, self._tool_failure_window_seconds())
120
+ threshold = max(1, self._tool_fail_threshold())
121
+ suppress_s = max(60, self._tool_suppress_seconds())
122
+
123
+ history = [t for t in self._tool_health_failures.get(tool_name, []) if now - t <= window_s]
124
+ history.append(now)
125
+ self._tool_health_failures[tool_name] = history
126
+
127
+ if len(history) >= threshold:
128
+ self._tool_health_suppressed_until[tool_name] = now + suppress_s
129
+
130
+ def tool_health_suppressed_tools(self) -> set[str]:
131
+ """Return tools currently suppressed due to repeated transient failures."""
132
+ if not self._tool_health_enabled():
133
+ return set()
134
+ now = time.time()
135
+ suppressed = set()
136
+ expired = []
137
+ for name, until in self._tool_health_suppressed_until.items():
138
+ if now < until:
139
+ suppressed.add(name)
140
+ else:
141
+ expired.append(name)
142
+ for name in expired:
143
+ self._tool_health_suppressed_until.pop(name, None)
144
+ self._tool_health_failures.pop(name, None)
145
+ return suppressed