syntheogenesis / dee /core /agent_tools.py
Tengo Gzirishvili
Fix DE tool "forgetting" a just-fetched sequence — it needed protein, got DNA
6758027
Raw
History Blame Contribute Delete
24.2 kB
"""Tools the Turing agent (dee/core/agent.py) can call via OpenRouter
function-calling — thin wrappers around the SAME dee/core/*.py functions
the REST API already calls, with the SAME input bounds those routes
already enforce (an LLM-supplied argument is no more trustworthy than a
raw POST body — every bound here mirrors dee/server.py's own route).
Scope, deliberately: only clean, self-contained actions that can complete
within one HTTP request. The full async /api/run pipeline (job-id +
polling, unbounded protein length, up to 50k search steps) is NOT wired
up — design_variant_library below is a bounded, synchronous sibling of it
instead, capped the same way POST /api/de/round2 already proves safe in
production (500 aa, same settings shape) rather than a new, unproven
bound. Round-2 proposal itself (needs prior bench measurements a fresh
chat won't have) and CRISPR's genome off-target / Ensembl exon lookups
(slow external NCBI/Ensembl fetches with a "still building" state the
REST UI can poll for but a single chat turn can't) stay out for now.
dee/core/ stays auth-agnostic by convention (see dee/auth.py's own
docstring) — execute_tool takes a plain `auth_anonymous: bool`, not the
AuthContext object, so this module never imports dee.auth.
"""
from __future__ import annotations
import logging
import re
from typing import Any, Callable, Dict, List
logger = logging.getLogger("dee.agent_tools")
def _tool_fetch_sequence(args: Dict[str, Any]) -> Dict[str, Any]:
"""Resolve a gene symbol / accession / raw paste to an actual DNA
sequence — the same dee.core.resolve.resolve_target() that backs the
"paste anything" box in the CRISPR UI (POST /api/crispr/resolve), now
reachable from chat too. Previously a chat request naming a gene instead
of pasting its sequence was a dead end (found 2026-07-11: "it's not
capable of searching www") even though this exact lookup already existed
and is fast/synchronous (2 Ensembl REST calls, cached) — unlike the
genome-scale off-target/exon work that's deliberately kept out of chat
for being slow enough to need polling (see module docstring)."""
from dee.core import resolve as _resolve
text = str(args.get("text") or "").strip()
if not text:
return {"ok": False, "error": "missing 'text'"}
# Same 1 Mbp cap as POST /api/crispr/resolve.
if len(text) > 1_000_000:
return {"ok": False, "error": "Input too long. Cap is 1 Mbp."}
organism = str(args.get("organism", "")).lower().strip()
if organism not in ("", "ecoli", "human", "mouse"):
organism = ""
try:
result = _resolve.resolve_target(text, organism=organism)
except Exception: # noqa: BLE001
logger.exception("fetch_sequence resolve failed")
return {"ok": False, "error": "Lookup failed — check the name/accession and try again."}
if not result.get("ok"):
return {"ok": False, "error": result.get("error") or "Couldn't resolve that input."}
sequence = result.get("sequence", "")
return {
"ok": True,
"kind": result.get("kind"),
"sequence": sequence,
"length": len(sequence),
"gene_symbol": result.get("gene_symbol", ""),
"label": result.get("label", ""),
"source": result.get("source", ""),
}
def _tool_design_crispr_guides(args: Dict[str, Any]) -> Dict[str, Any]:
from dee.core.crispr import find_guides
seq = str(args.get("sequence") or "").strip()
if not seq:
return {"ok": False, "error": "missing 'sequence'"}
# Same 1 Mbp cap as POST /api/crispr/design.
if len(seq) > 1_000_000:
return {"ok": False, "error": (
"Sequence too long. Cap is 1 Mbp — paste just the gene/region "
"you're editing, not a whole chromosome."
)}
enzyme = str(args.get("enzyme", "cas9")).lower()
if enzyme not in ("cas9", "cas12a"):
enzyme = "cas9"
mode = str(args.get("mode", "knockout")).lower()
if mode not in ("knockout", "base_edit"):
mode = "knockout"
try:
max_results = int(args.get("max_results", 20))
except (TypeError, ValueError):
max_results = 20
# Smaller default/cap than the REST UI's 500 — this reply is read
# inline in chat, not scrolled through a results table.
max_results = max(1, min(50, max_results))
try:
guides = find_guides(seq, enzyme=enzyme, max_results=max_results, mode=mode)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
guide_dicts = [
{
"rank": g.rank,
"strand": g.strand,
"position": g.position,
"spacer": g.spacer,
"pam": g.pam,
"composite_score": round(g.composite_score, 3),
"on_target_score": round(g.on_target_score, 3),
"gc_pct": round(g.gc_pct, 1),
"notes": g.notes,
}
for g in guides
]
# Same calibration as POST /api/crispr/design (server.py) — fold the
# cross-user aggregate into each guide's score, then re-rank. The chat
# tool skipped this entirely before (found 2026-07-11); no-op until a
# rebuild has populated public.outcome_priors for "crispr".
field_prior_n = 0
try:
from dee.core import outcomes as _O
prior = _O.load_cached_tool_prior("crispr")
if prior and prior.effects:
field_prior_n = len(prior.effects)
guide_dicts = _O.calibrate_crispr_guides(guide_dicts, prior)
except Exception: # noqa: BLE001
logger.exception("field-prior calibration skipped in design_crispr_guides")
return {
"ok": True,
"n_guides": len(guide_dicts),
"enzyme": enzyme,
"mode": mode,
"field_prior_keys": field_prior_n,
"guides": guide_dicts,
}
def _tool_design_primers(args: Dict[str, Any]) -> Dict[str, Any]:
from dee.core import primers as _P
template = re.sub(r"\s+", "", str(args.get("template") or "")).upper()
if not template:
return {"ok": False, "error": "Paste a template to design primers against."}
if not re.fullmatch(r"[ACGTN]+", template):
return {"ok": False, "error": "Template must be DNA (A/C/G/T/N) only."}
# Same cap as POST /api/primers/design.
if len(template) > _P.MAX_TEMPLATE:
return {"ok": False, "error": (
f"Template longer than {_P.MAX_TEMPLATE:,} bp — trim to the region of interest."
)}
def _oi(v):
try:
return int(v)
except (TypeError, ValueError):
return None
try:
result = _P.design_primers(template, _oi(args.get("target_start")), _oi(args.get("target_end")))
except Exception as exc: # noqa: BLE001 — mirrors the REST route's own catch-all
logger.exception("design_primers tool call failed")
return {"ok": False, "error": "Primer design failed — check the inputs."}
if not result.get("ok"):
return result
pairs = result.get("pairs") or []
return {
"ok": True,
"n_forward": result.get("n_forward"),
"n_reverse": result.get("n_reverse"),
"warnings": result.get("warnings") or [],
# Trimmed to what's useful read inline in chat — drop internal
# candidate metadata (hairpin/self-dimer scores, etc.) the REST
# results table shows but a chat reply doesn't need.
"pairs": [
{
"forward_seq": p.get("forward", {}).get("seq"),
"forward_tm": p.get("forward", {}).get("tm"),
"reverse_seq": p.get("reverse", {}).get("seq"),
"reverse_tm": p.get("reverse", {}).get("tm"),
"product_size": p.get("product_size"),
}
for p in pairs
],
}
# Cap mirrors POST /api/de/round2's _DE_ROUND2_MAX_AA (dee/server.py) — that
# route already proves 500 aa synchronous ESM-2 scoring is safe in
# production on this deploy's CPU tier; reusing the same number rather than
# inventing an unproven bound for this tool.
_MAX_PROTEIN_AA = 500
def _tool_design_variant_library(args: Dict[str, Any]) -> Dict[str, Any]:
raw = re.sub(r"\s+", "", str(args.get("sequence") or "")).upper()
if not raw:
return {"ok": False, "error": "missing 'sequence'"}
# Auto-detect + translate DNA input. fetch_sequence (and the CRISPR/
# primer tools) all deal in DNA — a model chaining "fetch this gene,
# then DE it" naturally ends up holding DNA, not protein, and this tool
# used to require protein only with no bridge, no translate tool to
# call either. The DNA just failed plain alphabet validation with no
# path forward, which from the user's side looked exactly like the
# model forgetting the sequence it had just fetched (found 2026-07-12).
# Best-effort and silent-fallback rather than a hard error: a protein
# made entirely of A/C/G/T-coded residues (Ala/Cys/Gly/Thr) is legal
# and technically indistinguishable from DNA by alphabet alone, so if
# this doesn't validate as a real CDS, fall through to treating the
# input as protein instead of failing outright.
protein = raw
if len(raw) >= 30 and len(raw) % 3 == 0 and re.match(r"^[ACGTN]+$", raw):
try:
from dee.core.sequence import translate_dna
protein = translate_dna(raw, identifier="chat-input").protein
except Exception: # noqa: BLE001
protein = raw
if not re.match(r"^[ACDEFGHIKLMNPQRSTVWY*]+$", protein):
return {"ok": False, "error": "Sequence must be a protein (standard amino-acid letters), not DNA."}
if len(protein) > _MAX_PROTEIN_AA:
return {"ok": False, "error": (
f"Sequence too long for a chat reply — cap is {_MAX_PROTEIN_AA} aa. "
"Use the Directed Evolution tool in the sidebar for longer "
"proteins; it runs in the background instead of one request."
)}
host = str(args.get("host", "e_coli")).lower()
if host not in ("e_coli", "yeast", "human"):
host = "e_coli"
try:
percentile = float(args.get("percentile", 85.0))
except (TypeError, ValueError):
percentile = 85.0
percentile = max(1.0, min(99.0, percentile))
try:
k = int(args.get("k", 10))
except (TypeError, ValueError):
k = 10
# Smaller cap than the REST route's 200 — a chat reply lists variants
# inline, it doesn't render a scrollable results table.
k = max(1, min(20, k))
try:
max_mutations = int(args.get("max_mutations", 3))
except (TypeError, ValueError):
max_mutations = 3
max_mutations = max(1, min(6, max_mutations))
min_mutations = min(2, max_mutations)
from dee.core import scoring
from dee.models.scorer import top_percentile_pool
from dee.optimizer.search import SearchConfig, evolve
try:
scorer = scoring.get_scorer("small")
# Bounded wait, not scoring.score_guarded's default indefinite
# block — this is one HTTP request's worth of budget, not the REST
# pipeline's async job with its own polling UI. Fail fast and
# clearly instead of holding the chat turn open.
scores_df = scoring.score_guarded(scorer, protein, wait_timeout=20.0)
except scoring.ScoringBusyError:
return {"ok": False, "kind": "busy", "error": (
"The design engine is busy scoring another request right now — try again in a few seconds."
)}
except Exception: # noqa: BLE001
logger.exception("design_variant_library scoring failed")
return {"ok": False, "error": "Scoring failed — check the sequence and try again."}
pool = top_percentile_pool(scores_df, percentile=percentile)
# Same soft blend as the REST /api/run job (server.py): fold the
# cross-user aggregate into round-1 scores by amino-acid substitution
# type, before search. Previously ONLY the REST job did this — the chat
# tool went straight from scoring to search, so a chat-driven design
# never reflected the aggregate no matter how much data existed (found
# 2026-07-11 while checking why it couldn't be tested from chat). No-op
# until a rebuild has actually populated public.mutation_priors.
field_prior_n = 0
try:
from dee.core import aggregate as _agg
_gp = _agg.load_cached_global_prior()
if _gp and _gp.effects:
field_prior_n = len(_gp.effects)
pool = pool.copy()
pool["delta_ll"] = [
float(r.delta_ll) + 0.3 * _gp.effects.get(
(str(r.wt_aa).upper(), str(r.mut_aa).upper()), 0.0)
for r in pool.itertuples(index=False)
]
except Exception: # noqa: BLE001
logger.exception("global-prior blend skipped in design_variant_library")
try:
# Reduced search budget vs. the REST defaults (restarts=8,
# steps=1200) — the pool is already scored, this part is pure
# numpy/pandas simulated annealing (no more ESM-2 calls), but kept
# small anyway to leave headroom in the request's total time budget
# for the OpenRouter round trips before and after this tool call.
variants = evolve(pool, SearchConfig(
k=k, max_mutations=max_mutations, min_mutations=min_mutations,
n_restarts=4, steps_per_restart=600,
))
except ValueError as exc:
return {"ok": False, "error": str(exc)}
# Reverse-translate each variant to actual, orderable DNA — same step
# the REST /api/run job uses (variants_to_dataframe: host-aware codon
# optimization + forbidden-restriction-site scrubbing). Previously this
# tool stopped at mutation labels ("W44K") with no sequence at all, so a
# chat-driven design had nothing to copy/paste or order (found
# 2026-07-12). Pure computation, no network — safe for one chat turn;
# best-effort so a rare encoding failure degrades to labels-only instead
# of losing the whole result.
dna_rows: List[Dict[str, Any]] = []
try:
from dee.core.codon import variants_to_dataframe, DEFAULT_FORBIDDEN_SITES
dna_rows = variants_to_dataframe(
protein, variants, host=host, forbidden_sites=DEFAULT_FORBIDDEN_SITES,
).to_dict(orient="records")
except Exception: # noqa: BLE001
logger.exception("DNA encoding failed in design_variant_library")
return {
"ok": True,
"model": "small",
"host": host,
"n_scored_positions": len(scores_df),
"n_pool": len(pool),
"field_prior_substitutions": field_prior_n,
"variants": [
dict(
{"rank": v.rank, "mutations": list(v.mutation_labels), "fitness": round(v.fitness, 3)},
**({
"dna": dna_rows[i]["Optimized_DNA_Seq"],
"length_bp": dna_rows[i]["Length_bp"],
"protein": dna_rows[i]["Mutant_AA_Seq"],
# 0-indexed AMINO ACID positions that changed — NOT a
# DNA-level diff against the WT sequence. WT and each
# variant are reverse-translated + restriction-site-
# scrubbed independently, so their DNA can legitimately
# differ at unmutated codons too (a different silent
# codon choice to clear a forbidden site) — diffing the
# two DNA strings would mark those as "the mutation,"
# which is simply wrong. The client maps each position p
# to nt range [p*3, p*3+3) — reverse_translate() is a
# strict 1:1 codon-per-residue mapping with no leading
# offset, so this is exact, not an approximation.
"mutated_positions": [m.position for m in v.mutations],
} if i < len(dna_rows) else {}),
)
for i, v in enumerate(variants)
],
}
# ─── Registry + OpenRouter tool specs ───────────────────────────────────
# requires_signin mirrors each function's REST route exactly: /api/crispr/
# design, /api/primers/design, and /api/run (the REST sibling of
# design_variant_library) all require a full account, not just the
# anonymous-trial quota /api/agent/step itself is gated by.
_TOOLS: Dict[str, Dict[str, Any]] = {
"fetch_sequence": {"fn": _tool_fetch_sequence, "requires_signin": True},
"design_crispr_guides": {"fn": _tool_design_crispr_guides, "requires_signin": True},
"design_primers": {"fn": _tool_design_primers, "requires_signin": True},
"design_variant_library": {"fn": _tool_design_variant_library, "requires_signin": True},
}
TOOL_SPECS: List[Dict[str, Any]] = [
{
"type": "function",
"function": {
"name": "fetch_sequence",
"description": (
"Resolve a gene symbol (e.g. GFP, TP53), an accession "
"(Ensembl ENST.../ENSG..., or RefSeq NM_.../NR_...), or a "
"raw pasted sequence into an actual DNA sequence, with a "
"human-readable label. Call this FIRST whenever the user "
"names a gene/protein/accession instead of pasting a "
"sequence themselves, then pass the returned 'sequence' "
"into design_crispr_guides / design_primers / "
"design_variant_library as needed — don't ask the user to "
"go paste it manually if this can fetch it. Gene-symbol "
"lookups need 'organism' set to human or mouse; accessions "
"and raw sequences don't. Requires the user to be signed in."
),
"parameters": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "Gene symbol, accession, or raw DNA/FASTA. Max 1,000,000 bp.",
},
"organism": {
"type": "string",
"enum": ["human", "mouse", "ecoli"],
"description": "Required for a gene-symbol lookup; ignored for accessions and raw sequences.",
},
},
"required": ["text"],
},
},
},
{
"type": "function",
"function": {
"name": "design_crispr_guides",
"description": (
"Design and rank CRISPR guide RNAs against a DNA sequence for "
"gene knockout or base editing. Requires the user to be signed in."
),
"parameters": {
"type": "object",
"properties": {
"sequence": {
"type": "string",
"description": "DNA sequence to target (FASTA headers/whitespace tolerated). Max 1,000,000 bp.",
},
"enzyme": {
"type": "string",
"enum": ["cas9", "cas12a"],
"description": "Cas enzyme. Defaults to cas9.",
},
"mode": {
"type": "string",
"enum": ["knockout", "base_edit"],
"description": "Editing mode. Defaults to knockout.",
},
"max_results": {
"type": "integer",
"description": "Max guides to return, 1-50. Defaults to 20.",
},
},
"required": ["sequence"],
},
},
},
{
"type": "function",
"function": {
"name": "design_primers",
"description": (
"Design forward/reverse PCR primer pairs to amplify a region of "
"a DNA template. Requires the user to be signed in."
),
"parameters": {
"type": "object",
"properties": {
"template": {
"type": "string",
"description": f"DNA template (A/C/G/T/N only). Max {60_000:,} bp.",
},
"target_start": {
"type": "integer",
"description": "1-based start of the region the product must span. Omit to amplify the whole template.",
},
"target_end": {
"type": "integer",
"description": "1-based end of the region the product must span. Omit to amplify the whole template.",
},
},
"required": ["template"],
},
},
},
{
"type": "function",
"function": {
"name": "design_variant_library",
"description": (
"Score every possible single-amino-acid substitution in a protein "
"with ESM-2 and propose a ranked library of multi-mutant variants. "
f"Chat-sized version of the Directed Evolution tool — capped at "
f"{_MAX_PROTEIN_AA} aa and a smaller search budget so it completes "
"within one reply; for longer proteins or a deeper search, use the "
"Directed Evolution tool in the sidebar instead. Requires the user "
"to be signed in."
),
"parameters": {
"type": "object",
"properties": {
"sequence": {
"type": "string",
"description": (
f"Protein sequence (standard amino acids), OR a DNA coding "
f"sequence (CDS) — auto-translated, so the raw output of "
f"fetch_sequence can be passed straight through with no "
f"separate translation step. Max {_MAX_PROTEIN_AA} aa."
),
},
"host": {
"type": "string",
"enum": ["e_coli", "yeast", "human"],
"description": "Expression host, used for codon-usage-aware downstream steps. Defaults to e_coli.",
},
"k": {
"type": "integer",
"description": "Number of ranked variants to return, 1-20. Defaults to 10.",
},
"max_mutations": {
"type": "integer",
"description": "Max simultaneous substitutions per variant, 1-6. Defaults to 3.",
},
"percentile": {
"type": "number",
"description": "Keep single-site mutations above this ΔLL percentile before searching multi-mutants, 1-99. Defaults to 85.",
},
},
"required": ["sequence"],
},
},
},
]
def execute_tool(name: str, arguments: Dict[str, Any], auth_anonymous: bool) -> Dict[str, Any]:
"""Run one tool call. Never raises — always returns a JSON-serializable
dict, {"ok": False, "error": ...} on any failure the model can read
and recover from."""
spec = _TOOLS.get(name)
if spec is None:
return {"ok": False, "error": f"unknown tool {name!r}"}
if spec["requires_signin"] and auth_anonymous:
return {
"ok": False,
"kind": "signin_required",
"error": "This requires a free account. Sign in to continue.",
}
try:
return spec["fn"](arguments)
except Exception: # noqa: BLE001 — a tool must never crash the agent loop
logger.exception("tool %r failed", name)
return {"ok": False, "error": f"{name} failed — check the inputs and try again."}