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.
- celltype_cli-0.1.0.dist-info/METADATA +267 -0
- celltype_cli-0.1.0.dist-info/RECORD +89 -0
- celltype_cli-0.1.0.dist-info/WHEEL +4 -0
- celltype_cli-0.1.0.dist-info/entry_points.txt +2 -0
- celltype_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- ct/__init__.py +3 -0
- ct/agent/__init__.py +0 -0
- ct/agent/case_studies.py +426 -0
- ct/agent/config.py +523 -0
- ct/agent/doctor.py +544 -0
- ct/agent/knowledge.py +523 -0
- ct/agent/loop.py +99 -0
- ct/agent/mcp_server.py +478 -0
- ct/agent/orchestrator.py +733 -0
- ct/agent/runner.py +656 -0
- ct/agent/sandbox.py +481 -0
- ct/agent/session.py +145 -0
- ct/agent/system_prompt.py +186 -0
- ct/agent/trace_store.py +228 -0
- ct/agent/trajectory.py +169 -0
- ct/agent/types.py +182 -0
- ct/agent/workflows.py +462 -0
- ct/api/__init__.py +1 -0
- ct/api/app.py +211 -0
- ct/api/config.py +120 -0
- ct/api/engine.py +124 -0
- ct/cli.py +1448 -0
- ct/data/__init__.py +0 -0
- ct/data/compute_providers.json +59 -0
- ct/data/cro_database.json +395 -0
- ct/data/downloader.py +238 -0
- ct/data/loaders.py +252 -0
- ct/kb/__init__.py +5 -0
- ct/kb/benchmarks.py +147 -0
- ct/kb/governance.py +106 -0
- ct/kb/ingest.py +415 -0
- ct/kb/reasoning.py +129 -0
- ct/kb/schema_monitor.py +162 -0
- ct/kb/substrate.py +387 -0
- ct/models/__init__.py +0 -0
- ct/models/llm.py +370 -0
- ct/tools/__init__.py +195 -0
- ct/tools/_compound_resolver.py +297 -0
- ct/tools/biomarker.py +368 -0
- ct/tools/cellxgene.py +282 -0
- ct/tools/chemistry.py +1371 -0
- ct/tools/claude.py +390 -0
- ct/tools/clinical.py +1153 -0
- ct/tools/clue.py +249 -0
- ct/tools/code.py +1069 -0
- ct/tools/combination.py +397 -0
- ct/tools/compute.py +402 -0
- ct/tools/cro.py +413 -0
- ct/tools/data_api.py +2114 -0
- ct/tools/design.py +295 -0
- ct/tools/dna.py +575 -0
- ct/tools/experiment.py +604 -0
- ct/tools/expression.py +655 -0
- ct/tools/files.py +957 -0
- ct/tools/genomics.py +1387 -0
- ct/tools/http_client.py +146 -0
- ct/tools/imaging.py +319 -0
- ct/tools/intel.py +223 -0
- ct/tools/literature.py +743 -0
- ct/tools/network.py +422 -0
- ct/tools/notification.py +111 -0
- ct/tools/omics.py +3330 -0
- ct/tools/ops.py +1230 -0
- ct/tools/parity.py +649 -0
- ct/tools/pk.py +245 -0
- ct/tools/protein.py +678 -0
- ct/tools/regulatory.py +643 -0
- ct/tools/remote_data.py +179 -0
- ct/tools/report.py +181 -0
- ct/tools/repurposing.py +376 -0
- ct/tools/safety.py +1280 -0
- ct/tools/shell.py +178 -0
- ct/tools/singlecell.py +533 -0
- ct/tools/statistics.py +552 -0
- ct/tools/structure.py +882 -0
- ct/tools/target.py +901 -0
- ct/tools/translational.py +123 -0
- ct/tools/viability.py +218 -0
- ct/ui/__init__.py +0 -0
- ct/ui/markdown.py +31 -0
- ct/ui/status.py +258 -0
- ct/ui/suggestions.py +567 -0
- ct/ui/terminal.py +1456 -0
- 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
|