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
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unified system prompt builder for the Claude Agent SDK runner.
|
|
3
|
+
|
|
4
|
+
Merges the domain knowledge primer, workflow guides, bioinformatics code-gen
|
|
5
|
+
hints, synthesis rules, tool catalog, and dynamic data context into a single
|
|
6
|
+
system prompt that Claude uses throughout the agentic loop.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("ct.system_prompt")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Identity / role preamble
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
_IDENTITY = """\
|
|
22
|
+
You are **celltype-cli**, an autonomous drug discovery research agent.
|
|
23
|
+
|
|
24
|
+
You have access to 190+ domain tools covering target discovery, chemistry,
|
|
25
|
+
expression, viability, safety, clinical development, omics, genomics, literature,
|
|
26
|
+
and more — plus a persistent Python sandbox (``run_python``) for custom analyses.
|
|
27
|
+
|
|
28
|
+
Your job: take a research question and answer it **completely**, using the right
|
|
29
|
+
tools and code, self-correcting as you go, and producing a publication-quality
|
|
30
|
+
synthesis at the end.
|
|
31
|
+
|
|
32
|
+
## Operating Mode
|
|
33
|
+
- You are in an agentic loop: call tools, see results, call more tools, then
|
|
34
|
+
write your final answer as plain text (no tool call).
|
|
35
|
+
- Think step-by-step. Use tools to gather evidence, then synthesize.
|
|
36
|
+
- If a tool fails or returns unhelpful data, try a different approach or use
|
|
37
|
+
your own knowledge to fill gaps.
|
|
38
|
+
- For data analysis questions, use ``run_python`` to load data, explore it,
|
|
39
|
+
and compute the answer. Variables persist between calls.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Synthesis instructions (injected at the end)
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
_SYNTHESIS_INSTRUCTIONS = """\
|
|
48
|
+
|
|
49
|
+
## When You Are Ready to Answer
|
|
50
|
+
|
|
51
|
+
Write your final answer as a direct text response (do NOT call any more tools).
|
|
52
|
+
Your answer should be:
|
|
53
|
+
|
|
54
|
+
1. **Complete**: Address every part of the question. Decompose the question into
|
|
55
|
+
sub-parts and make sure each is answered with specifics.
|
|
56
|
+
2. **Accurate**: Use tool results as primary evidence. Supplement with your
|
|
57
|
+
domain knowledge. Never fabricate data.
|
|
58
|
+
3. **Data-rich**: Include specific gene names, cell lines, p-values, effect
|
|
59
|
+
sizes, IC50 values, trial names, mutation positions, etc.
|
|
60
|
+
4. **Mechanistic**: Explain the biological *why*, not just the *what*.
|
|
61
|
+
5. **Actionable**: End with 3-5 specific experimental next steps (named assays,
|
|
62
|
+
cell lines, concentrations, readouts).
|
|
63
|
+
|
|
64
|
+
BANNED PHRASES — never write these:
|
|
65
|
+
- "cannot be answered with the data retrieved"
|
|
66
|
+
- "failed to retrieve" / "failed to identify"
|
|
67
|
+
- "insufficient data" / "insufficient evidence"
|
|
68
|
+
- "No results were obtained"
|
|
69
|
+
If tools failed, pivot to answering from your knowledge instead.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Builder
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def build_system_prompt(
|
|
78
|
+
session,
|
|
79
|
+
tool_names: list[str] | None = None,
|
|
80
|
+
data_context: str | None = None,
|
|
81
|
+
history: str | None = None,
|
|
82
|
+
) -> str:
|
|
83
|
+
"""Build the unified system prompt for the Agent SDK runner.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
session: Active ct Session.
|
|
87
|
+
tool_names: Names of tools available in the MCP server (for reference).
|
|
88
|
+
data_context: Free-text description of available data files / directories.
|
|
89
|
+
history: Prior conversation turns (for interactive multi-turn sessions).
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
The complete system prompt string.
|
|
93
|
+
"""
|
|
94
|
+
parts: list[str] = []
|
|
95
|
+
|
|
96
|
+
# 1. Identity
|
|
97
|
+
parts.append(_IDENTITY)
|
|
98
|
+
|
|
99
|
+
# 2. Tool catalog (concise reference — full descriptions are in MCP tool defs)
|
|
100
|
+
# NOTE: The Agent SDK exposes tool names+descriptions+schemas via MCP natively.
|
|
101
|
+
# We only include a brief orientation here, NOT the full tool_descriptions_for_llm()
|
|
102
|
+
# which would blow up the system prompt to 155K chars and hit ARG_MAX limits.
|
|
103
|
+
if tool_names:
|
|
104
|
+
parts.append(f"\n## Available Tools ({len(tool_names)} total)\n")
|
|
105
|
+
parts.append(
|
|
106
|
+
"You have access to all tools via MCP. Key tools:\n"
|
|
107
|
+
"- **run_python**: Execute Python code in a sandbox (pd, np, plt, scipy, sklearn, pysam, gseapy, pydeseq2, BioPython). Variables persist between calls.\n"
|
|
108
|
+
"- **run_r**: Execute R code directly. Prefer run_r over run_python for: natural splines (ns()), wilcox.test(), p.adjust(), fisher.test(), lm()/predict(), organism-specific KEGG ORA (use KEGGREST package: keggList, keggLink, keggGet for any organism code), and any analysis where R is the reference implementation. R and Python give DIFFERENT results for splines, multiple testing correction, and nonparametric tests — when the expected answer was computed in R, use R.\n"
|
|
109
|
+
"- **literature.pubmed_search**, **literature.chembl_query**, **literature.openalex_search**: Literature/DB search\n"
|
|
110
|
+
"- **data_api.opentargets_search**, **data_api.depmap_search**, **data_api.uniprot_lookup**: Platform APIs\n"
|
|
111
|
+
"- **omics.geo_search**, **omics.geo_fetch**, **omics.deseq2**, **omics.dataset_info**: Omics data discovery + analysis\n"
|
|
112
|
+
"- **expression.pathway_enrichment**, **expression.l1000_similarity**: Expression/pathway tools\n"
|
|
113
|
+
"- **viability.dose_response**, **clinical.indication_map**, **clinical.trial_search**: Viability/clinical\n"
|
|
114
|
+
"- **chemistry.descriptors**, **chemistry.sar_analyze**, **chemistry.pubchem_lookup**: Chemistry tools\n"
|
|
115
|
+
"- **target.coessentiality**, **genomics.gwas_lookup**, **protein.function_predict**: Target/genomics tools\n"
|
|
116
|
+
"- **safety.classify**, **safety.admet_predict**: Safety/ADMET tools\n"
|
|
117
|
+
"\nFor data analysis questions, prefer **run_python** — it's the most powerful tool.\n"
|
|
118
|
+
"For drug discovery questions, combine domain tools with your knowledge.\n"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# 3. Workflow guides (compact — key sequences for common tasks)
|
|
122
|
+
try:
|
|
123
|
+
from ct.agent.workflows import format_workflows_for_llm
|
|
124
|
+
workflows = format_workflows_for_llm()
|
|
125
|
+
if workflows:
|
|
126
|
+
parts.append(workflows)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
logger.warning("Could not load workflows: %s", e)
|
|
129
|
+
|
|
130
|
+
# 4. Domain knowledge primer (CRITICAL for drug discovery accuracy)
|
|
131
|
+
# NOTE: The KNOWLEDGE_PRIMER contains both tool orientation AND domain facts.
|
|
132
|
+
# The tool orientation section overlaps with MCP tool descriptions but the
|
|
133
|
+
# cross-disciplinary thinking patterns and domain-specific accuracy anchors
|
|
134
|
+
# are essential. Include in full.
|
|
135
|
+
try:
|
|
136
|
+
from ct.agent.knowledge import KNOWLEDGE_PRIMER
|
|
137
|
+
parts.append("\n" + KNOWLEDGE_PRIMER)
|
|
138
|
+
except Exception as e:
|
|
139
|
+
logger.warning("Could not load knowledge primer: %s", e)
|
|
140
|
+
|
|
141
|
+
# 6. Bioinformatics code-gen hints (CRITICAL for BixBench performance)
|
|
142
|
+
try:
|
|
143
|
+
from ct.tools.code import BIOINFORMATICS_CODE_GEN_PROMPT, AGENTIC_CODE_ADDENDUM
|
|
144
|
+
# Strip the template placeholders and include the raw hints
|
|
145
|
+
hints = BIOINFORMATICS_CODE_GEN_PROMPT
|
|
146
|
+
# Remove the {namespace_description} and {data_files_description} placeholders
|
|
147
|
+
hints = hints.replace("{namespace_description}", "(see run_python tool description)")
|
|
148
|
+
hints = hints.replace("{data_files_description}", "(see data context below)")
|
|
149
|
+
parts.append("\n## Bioinformatics Code Generation Guide\n")
|
|
150
|
+
parts.append(
|
|
151
|
+
"When using ``run_python`` for bioinformatics analysis, follow these "
|
|
152
|
+
"patterns and guidelines:\n"
|
|
153
|
+
)
|
|
154
|
+
parts.append(hints)
|
|
155
|
+
parts.append(AGENTIC_CODE_ADDENDUM)
|
|
156
|
+
except Exception as e:
|
|
157
|
+
logger.warning("Could not load code-gen hints: %s", e)
|
|
158
|
+
|
|
159
|
+
# 7. Synthesis rules
|
|
160
|
+
try:
|
|
161
|
+
from ct.agent.knowledge import SYNTHESIZER_PRIMER
|
|
162
|
+
parts.append("\n## Synthesis Guidelines\n")
|
|
163
|
+
parts.append(SYNTHESIZER_PRIMER)
|
|
164
|
+
except Exception as e:
|
|
165
|
+
logger.warning("Could not load synthesizer primer: %s", e)
|
|
166
|
+
|
|
167
|
+
# 8. Synthesis instructions
|
|
168
|
+
parts.append(_SYNTHESIS_INSTRUCTIONS)
|
|
169
|
+
|
|
170
|
+
# 9. Dynamic data context
|
|
171
|
+
if data_context:
|
|
172
|
+
parts.append("\n## Data Context\n")
|
|
173
|
+
parts.append(data_context)
|
|
174
|
+
|
|
175
|
+
# 10. Session history (for multi-turn interactive mode)
|
|
176
|
+
if history:
|
|
177
|
+
parts.append("\n## Prior Conversation\n")
|
|
178
|
+
parts.append(history)
|
|
179
|
+
|
|
180
|
+
prompt = "\n".join(parts)
|
|
181
|
+
logger.info(
|
|
182
|
+
"Built system prompt: %d chars, %d sections",
|
|
183
|
+
len(prompt),
|
|
184
|
+
len(parts),
|
|
185
|
+
)
|
|
186
|
+
return prompt
|
ct/agent/trace_store.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Trace store: full-fidelity capture of agent message streams.
|
|
3
|
+
|
|
4
|
+
Captures the complete sequence of text blocks, tool calls, and tool results
|
|
5
|
+
from each query execution. Used by the notebook exporter and other
|
|
6
|
+
downstream consumers that need richer data than the compact Trajectory.
|
|
7
|
+
|
|
8
|
+
Operates independently of Trajectory — Trajectory stores compact turn
|
|
9
|
+
summaries for LLM context injection; TraceStore captures the full stream
|
|
10
|
+
for export/replay.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import mimetypes
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("ct.trace_store")
|
|
21
|
+
|
|
22
|
+
# Marker used by MCP handlers to embed structured metadata in tool results
|
|
23
|
+
TRACE_META_MARKER = "\n__CT_TRACE_META__\n"
|
|
24
|
+
|
|
25
|
+
# Maximum file size for base64 embedding (10 MB)
|
|
26
|
+
_MAX_EMBED_BYTES = 10 * 1024 * 1024
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _sessions_dir() -> Path:
|
|
30
|
+
"""Return the sessions directory, creating it if needed."""
|
|
31
|
+
d = Path.home() / ".ct" / "sessions"
|
|
32
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
return d
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_trace_meta(result_text: str) -> dict | None:
|
|
37
|
+
"""Extract __CT_TRACE_META__ JSON from a tool result string.
|
|
38
|
+
|
|
39
|
+
Returns the parsed dict, or None if no marker found.
|
|
40
|
+
"""
|
|
41
|
+
if TRACE_META_MARKER not in result_text:
|
|
42
|
+
return None
|
|
43
|
+
try:
|
|
44
|
+
_, meta_json = result_text.split(TRACE_META_MARKER, 1)
|
|
45
|
+
return json.loads(meta_json.strip())
|
|
46
|
+
except (ValueError, json.JSONDecodeError) as e:
|
|
47
|
+
logger.warning("Failed to parse trace meta: %s", e)
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _embed_plots(event: dict) -> None:
|
|
52
|
+
"""Read plot files and add base64-encoded data to the event in-place."""
|
|
53
|
+
plots = event.get("plots", [])
|
|
54
|
+
if not plots:
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
embedded = []
|
|
58
|
+
for plot_path_str in plots:
|
|
59
|
+
plot_path = Path(plot_path_str)
|
|
60
|
+
if not plot_path.exists():
|
|
61
|
+
logger.warning("Plot file missing at capture time: %s", plot_path)
|
|
62
|
+
continue
|
|
63
|
+
if plot_path.stat().st_size > _MAX_EMBED_BYTES:
|
|
64
|
+
logger.warning(
|
|
65
|
+
"Plot file too large for embedding (%d bytes): %s",
|
|
66
|
+
plot_path.stat().st_size,
|
|
67
|
+
plot_path,
|
|
68
|
+
)
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
mime, _ = mimetypes.guess_type(str(plot_path))
|
|
72
|
+
if mime is None:
|
|
73
|
+
suffix_map = {
|
|
74
|
+
".png": "image/png",
|
|
75
|
+
".svg": "image/svg+xml",
|
|
76
|
+
".jpg": "image/jpeg",
|
|
77
|
+
".jpeg": "image/jpeg",
|
|
78
|
+
".pdf": "application/pdf",
|
|
79
|
+
}
|
|
80
|
+
mime = suffix_map.get(plot_path.suffix.lower(), "application/octet-stream")
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
data = base64.b64encode(plot_path.read_bytes()).decode("ascii")
|
|
84
|
+
embedded.append({
|
|
85
|
+
"filename": plot_path.name,
|
|
86
|
+
"mime": mime,
|
|
87
|
+
"data": data,
|
|
88
|
+
})
|
|
89
|
+
except OSError as e:
|
|
90
|
+
logger.warning("Failed to read plot file %s: %s", plot_path, e)
|
|
91
|
+
|
|
92
|
+
if embedded:
|
|
93
|
+
event["plots_base64"] = embedded
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TraceStore:
|
|
97
|
+
"""Captures and persists full agent message stream traces.
|
|
98
|
+
|
|
99
|
+
Usage::
|
|
100
|
+
|
|
101
|
+
store = TraceStore(session_id="abc-123")
|
|
102
|
+
events = []
|
|
103
|
+
|
|
104
|
+
# ... pass events list to process_messages() ...
|
|
105
|
+
|
|
106
|
+
store.add_events(events, query="my question", model="claude-sonnet-4-5")
|
|
107
|
+
store.flush() # persist to ~/.ct/sessions/abc-123.trace.jsonl
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(self, session_id: str):
|
|
111
|
+
self.session_id = session_id
|
|
112
|
+
self._events: list[dict] = []
|
|
113
|
+
self._path = _sessions_dir() / f"{session_id}.trace.jsonl"
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def path(self) -> Path:
|
|
117
|
+
return self._path
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def events(self) -> list[dict]:
|
|
121
|
+
return self._events
|
|
122
|
+
|
|
123
|
+
def add_event(self, event: dict) -> None:
|
|
124
|
+
"""Add a single trace event."""
|
|
125
|
+
if "timestamp" not in event:
|
|
126
|
+
event["timestamp"] = time.time()
|
|
127
|
+
self._events.append(event)
|
|
128
|
+
|
|
129
|
+
def add_events(
|
|
130
|
+
self,
|
|
131
|
+
events: list[dict],
|
|
132
|
+
query: str = "",
|
|
133
|
+
model: str = "",
|
|
134
|
+
duration_s: float = 0.0,
|
|
135
|
+
cost_usd: float = 0.0,
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Wrap a list of trace events with query_start/query_end and add them.
|
|
138
|
+
|
|
139
|
+
Also performs eager base64 embedding of plots.
|
|
140
|
+
"""
|
|
141
|
+
now = time.time()
|
|
142
|
+
|
|
143
|
+
# query_start
|
|
144
|
+
self._events.append({
|
|
145
|
+
"type": "query_start",
|
|
146
|
+
"session_id": self.session_id,
|
|
147
|
+
"query": query,
|
|
148
|
+
"model": model,
|
|
149
|
+
"timestamp": now,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
# Add all events (with plot embedding)
|
|
153
|
+
for event in events:
|
|
154
|
+
if event.get("type") == "tool_result" and event.get("plots"):
|
|
155
|
+
_embed_plots(event)
|
|
156
|
+
self._events.append(event)
|
|
157
|
+
|
|
158
|
+
# query_end
|
|
159
|
+
self._events.append({
|
|
160
|
+
"type": "query_end",
|
|
161
|
+
"duration_s": duration_s,
|
|
162
|
+
"cost_usd": cost_usd,
|
|
163
|
+
"timestamp": time.time(),
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
def flush(self, path: Path | None = None) -> Path:
|
|
167
|
+
"""Persist all events to a JSONL file. Appends if file exists."""
|
|
168
|
+
out = path or self._path
|
|
169
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
|
|
171
|
+
with open(out, "a", encoding="utf-8") as f:
|
|
172
|
+
for event in self._events:
|
|
173
|
+
f.write(json.dumps(event, default=str) + "\n")
|
|
174
|
+
|
|
175
|
+
count = len(self._events)
|
|
176
|
+
self._events.clear()
|
|
177
|
+
logger.info("Flushed %d trace events to %s", count, out)
|
|
178
|
+
return out
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def load(path: Path | str) -> list[dict]:
|
|
182
|
+
"""Load trace events from a JSONL file."""
|
|
183
|
+
path = Path(path)
|
|
184
|
+
if not path.exists():
|
|
185
|
+
raise FileNotFoundError(f"Trace file not found: {path}")
|
|
186
|
+
|
|
187
|
+
events = []
|
|
188
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
189
|
+
for line in f:
|
|
190
|
+
line = line.strip()
|
|
191
|
+
if line:
|
|
192
|
+
events.append(json.loads(line))
|
|
193
|
+
return events
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def find_trace(session_id: str | None = None) -> Path | None:
|
|
197
|
+
"""Find a trace file by session ID prefix, or return the most recent.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
session_id: Session ID or prefix to match. If None, returns
|
|
201
|
+
the most recent trace file.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Path to the trace file, or None if not found.
|
|
205
|
+
"""
|
|
206
|
+
sessions_dir = _sessions_dir()
|
|
207
|
+
traces = sorted(
|
|
208
|
+
sessions_dir.glob("*.trace.jsonl"),
|
|
209
|
+
key=lambda p: p.stat().st_mtime,
|
|
210
|
+
reverse=True,
|
|
211
|
+
)
|
|
212
|
+
if not traces:
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
if session_id is None:
|
|
216
|
+
return traces[0]
|
|
217
|
+
|
|
218
|
+
# Exact match first
|
|
219
|
+
exact = sessions_dir / f"{session_id}.trace.jsonl"
|
|
220
|
+
if exact.exists():
|
|
221
|
+
return exact
|
|
222
|
+
|
|
223
|
+
# Prefix match
|
|
224
|
+
for t in traces:
|
|
225
|
+
if t.stem.replace(".trace", "").startswith(session_id):
|
|
226
|
+
return t
|
|
227
|
+
|
|
228
|
+
return None
|
ct/agent/trajectory.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Trajectory: session memory across queries in interactive mode.
|
|
3
|
+
|
|
4
|
+
Records queries, answers, entities mentioned, and tools used so the planner
|
|
5
|
+
can reference prior context ("earlier you found X, now also check Y").
|
|
6
|
+
Supports persistence to JSONL for session resume.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field, asdict
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Turn:
|
|
17
|
+
"""A single query-answer turn in a session."""
|
|
18
|
+
query: str
|
|
19
|
+
answer: str
|
|
20
|
+
entities: list[str] = field(default_factory=list)
|
|
21
|
+
tools_used: list[str] = field(default_factory=list)
|
|
22
|
+
timestamp: float = field(default_factory=time.time)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Trajectory:
|
|
26
|
+
"""Session memory: records turns and provides context for the planner."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, max_turns: int = 20, session_id: str = None, title: str = None):
|
|
29
|
+
self.turns: list[Turn] = []
|
|
30
|
+
self.max_turns = max_turns
|
|
31
|
+
self.session_id = session_id
|
|
32
|
+
self.title = title
|
|
33
|
+
self.created_at = time.time()
|
|
34
|
+
|
|
35
|
+
def add_turn(self, query: str, answer: str, plan=None):
|
|
36
|
+
"""Record a completed turn with entity extraction."""
|
|
37
|
+
entities = []
|
|
38
|
+
tools_used = []
|
|
39
|
+
if plan and hasattr(plan, "steps"):
|
|
40
|
+
tools_used = [s.tool for s in plan.steps if s.status == "completed"]
|
|
41
|
+
|
|
42
|
+
turn = Turn(
|
|
43
|
+
query=query,
|
|
44
|
+
answer=answer,
|
|
45
|
+
entities=entities,
|
|
46
|
+
tools_used=tools_used,
|
|
47
|
+
)
|
|
48
|
+
self.turns.append(turn)
|
|
49
|
+
|
|
50
|
+
# Drop oldest turns if over limit
|
|
51
|
+
if len(self.turns) > self.max_turns:
|
|
52
|
+
self.turns = self.turns[-self.max_turns:]
|
|
53
|
+
|
|
54
|
+
def context_for_planner(self) -> str:
|
|
55
|
+
"""Format recent turns as context for the planner prompt."""
|
|
56
|
+
if not self.turns:
|
|
57
|
+
return ""
|
|
58
|
+
|
|
59
|
+
recent = self.turns[-5:]
|
|
60
|
+
lines = ["## Session Context (prior queries this session)", ""]
|
|
61
|
+
for i, turn in enumerate(recent, 1):
|
|
62
|
+
entities_str = ", ".join(turn.entities) if turn.entities else "none"
|
|
63
|
+
tools_str = ", ".join(turn.tools_used) if turn.tools_used else "none"
|
|
64
|
+
# Truncate answer to first 200 chars for context
|
|
65
|
+
answer_preview = turn.answer[:200] + "..." if len(turn.answer) > 200 else turn.answer
|
|
66
|
+
lines.append(f"**Turn {i}**: {turn.query}")
|
|
67
|
+
lines.append(f" Entities: {entities_str}")
|
|
68
|
+
lines.append(f" Tools: {tools_str}")
|
|
69
|
+
lines.append(f" Finding: {answer_preview}")
|
|
70
|
+
lines.append("")
|
|
71
|
+
|
|
72
|
+
all_entities = self.entities()
|
|
73
|
+
if all_entities:
|
|
74
|
+
lines.append(f"**All entities this session**: {', '.join(all_entities)}")
|
|
75
|
+
lines.append("")
|
|
76
|
+
|
|
77
|
+
lines.append(
|
|
78
|
+
"Use this context to: reference prior findings, avoid repeating work, "
|
|
79
|
+
"resolve ambiguous entity references (e.g., 'it', 'the compound')."
|
|
80
|
+
)
|
|
81
|
+
return "\n".join(lines)
|
|
82
|
+
|
|
83
|
+
def entities(self) -> list[str]:
|
|
84
|
+
"""All unique entities mentioned across the session, preserving order."""
|
|
85
|
+
seen = set()
|
|
86
|
+
result = []
|
|
87
|
+
for turn in self.turns:
|
|
88
|
+
for entity in turn.entities:
|
|
89
|
+
if entity not in seen:
|
|
90
|
+
seen.add(entity)
|
|
91
|
+
result.append(entity)
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
def save(self, path: Path):
|
|
95
|
+
"""Persist trajectory to a JSONL file."""
|
|
96
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
with open(path, "w") as f:
|
|
98
|
+
# First line: session metadata
|
|
99
|
+
meta = {
|
|
100
|
+
"type": "meta",
|
|
101
|
+
"session_id": self.session_id,
|
|
102
|
+
"title": self.title,
|
|
103
|
+
"created_at": self.created_at,
|
|
104
|
+
"n_turns": len(self.turns),
|
|
105
|
+
}
|
|
106
|
+
f.write(json.dumps(meta) + "\n")
|
|
107
|
+
# Subsequent lines: turns
|
|
108
|
+
for turn in self.turns:
|
|
109
|
+
record = {"type": "turn", **asdict(turn)}
|
|
110
|
+
f.write(json.dumps(record) + "\n")
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def load(cls, path: Path) -> "Trajectory":
|
|
114
|
+
"""Load a trajectory from a JSONL file."""
|
|
115
|
+
trajectory = cls()
|
|
116
|
+
with open(path) as f:
|
|
117
|
+
for line in f:
|
|
118
|
+
line = line.strip()
|
|
119
|
+
if not line:
|
|
120
|
+
continue
|
|
121
|
+
try:
|
|
122
|
+
record = json.loads(line)
|
|
123
|
+
except json.JSONDecodeError as e:
|
|
124
|
+
import logging
|
|
125
|
+
logging.getLogger("ct.trajectory").warning(
|
|
126
|
+
"Skipping corrupted line in trajectory %s: %s", path, e,
|
|
127
|
+
)
|
|
128
|
+
continue
|
|
129
|
+
if record.get("type") == "meta":
|
|
130
|
+
trajectory.session_id = record.get("session_id")
|
|
131
|
+
trajectory.title = record.get("title")
|
|
132
|
+
trajectory.created_at = record.get("created_at", 0)
|
|
133
|
+
elif record.get("type") == "turn":
|
|
134
|
+
turn = Turn(
|
|
135
|
+
query=record.get("query", ""),
|
|
136
|
+
answer=record.get("answer", ""),
|
|
137
|
+
entities=record.get("entities", []),
|
|
138
|
+
tools_used=record.get("tools_used", []),
|
|
139
|
+
timestamp=record.get("timestamp", 0),
|
|
140
|
+
)
|
|
141
|
+
trajectory.turns.append(turn)
|
|
142
|
+
return trajectory
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def sessions_dir() -> Path:
|
|
146
|
+
"""Return the directory where sessions are stored."""
|
|
147
|
+
d = Path.home() / ".ct" / "sessions"
|
|
148
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
return d
|
|
150
|
+
|
|
151
|
+
@classmethod
|
|
152
|
+
def list_sessions(cls) -> list[dict]:
|
|
153
|
+
"""List all saved sessions, most recent first."""
|
|
154
|
+
sessions_dir = cls.sessions_dir()
|
|
155
|
+
sessions = []
|
|
156
|
+
for path in sessions_dir.glob("*.jsonl"):
|
|
157
|
+
try:
|
|
158
|
+
with open(path) as f:
|
|
159
|
+
first_line = f.readline().strip()
|
|
160
|
+
if first_line:
|
|
161
|
+
meta = json.loads(first_line)
|
|
162
|
+
if meta.get("type") == "meta":
|
|
163
|
+
meta["path"] = str(path)
|
|
164
|
+
sessions.append(meta)
|
|
165
|
+
except (json.JSONDecodeError, OSError):
|
|
166
|
+
continue
|
|
167
|
+
# Sort by created_at descending (most recent first)
|
|
168
|
+
sessions.sort(key=lambda s: s.get("created_at", 0), reverse=True)
|
|
169
|
+
return sessions
|