danaaubakirova HF Staff commited on
Commit
687fa23
·
verified ·
1 Parent(s): 83295b1

Add an Inspect @tool example and the carbon-dna skill for coding agents

Browse files
README.md CHANGED
@@ -324,9 +324,97 @@ while True:
324
  print(message.content)
325
  ```
326
 
327
- Coding agents such as Claude Code or Codex CLI can use the same function as a skill: a short `SKILL.md` that says when to consult Carbon-X, plus a script that calls `ask_carbon` on sequence files in the workspace.
328
 
329
- Tips for agent use:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
 
331
  - **Pass DNA by reference.** Keep sequences in files or a lookup table and let the agent name them.
332
  - **Ask for Carbon-X explicitly.** Agents often answer DNA questions on their own instead of calling a tool. If you want Carbon-X's view, say so in the system prompt.
 
324
  print(message.content)
325
  ```
326
 
 
327
 
328
+ ### With Inspect
329
+
330
+ In [Inspect](https://inspect.aisi.org.uk/), wrap Carbon-X in an `@tool` and give it to a `react()` agent. Each sample
331
+ keeps its DNA in metadata, so the agent refers to sequences by name.
332
+
333
+ ```python
334
+ from inspect_ai import Task, eval, task
335
+ from inspect_ai.agent import as_solver, react
336
+ from inspect_ai.dataset import Sample
337
+ from inspect_ai.scorer import includes
338
+ from inspect_ai.solver import solver
339
+ from inspect_ai.tool import ToolError, tool
340
+ from inspect_ai.util import store
341
+ from openai import AsyncOpenAI
342
+
343
+ carbon = AsyncOpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY")
344
+
345
+
346
+ @tool
347
+ def ask_carbon():
348
+ async def execute(question: str, sequences: list[str], context: str = "") -> str:
349
+ """Ask Carbon-X, a DNA language model, about this task's DNA sequences. Its answers are predictions.
350
+
351
+ Args:
352
+ question: One self-contained question.
353
+ sequences: Names of the sequences to send, as given in the task.
354
+ context: Known facts, such as the gene, positions or alleles.
355
+ """
356
+ dna = store().get("sequences")
357
+ if missing := [name for name in sequences if name not in dna]:
358
+ raise ToolError(f"Unknown sequence names: {missing}. Available: {list(dna)}")
359
+ parts = [context, question] if context else [question]
360
+ parts += [f"{name}: <dna>{dna[name]}</dna>" for name in sequences]
361
+ response = await carbon.chat.completions.create(
362
+ model="HuggingFaceBio/Carbon-X",
363
+ messages=[{"role": "user", "content": "\n\n".join(parts)}],
364
+ max_tokens=4096,
365
+ extra_body={"chat_template_kwargs": {"enable_thinking": False}},
366
+ )
367
+ return response.choices[0].message.content
368
+
369
+ return execute
370
+
371
+
372
+ @solver
373
+ def load_sequences():
374
+ async def solve(state, generate):
375
+ store().set("sequences", state.metadata["sequences"]) # the tool reads DNA from here
376
+ return state
377
+
378
+ return solve
379
+
380
+
381
+ @task
382
+ def carbon_agent():
383
+ sample = Sample(
384
+ input="Sequence `variant` differs from `reference` at position 20 (A>T). Is the variant benign or pathogenic?",
385
+ target="pathogenic",
386
+ metadata={"sequences": {
387
+ "reference": "ATGGTGCATCTGACTCCTGAGGAGAAGTCTGCCGTTACTGCCCTGTGGGGCAAGG",
388
+ "variant": "ATGGTGCATCTGACTCCTGTGGAGAAGTCTGCCGTTACTGCCCTGTGGGGCAAGG",
389
+ }},
390
+ )
391
+ agent = react(prompt="Consult Carbon-X with the ask_carbon tool before you answer.", tools=[ask_carbon()])
392
+ return Task(dataset=[sample], solver=[load_sequences(), as_solver(agent)], scorer=includes())
393
+
394
+
395
+ eval(carbon_agent(), model="openai/gpt-5") # any Inspect model with tool calling as the agent
396
+ ```
397
+
398
+ ### As a skill for coding agents
399
+
400
+ This repository includes a ready-made skill in [`skills/carbon-dna/`](skills/carbon-dna/): a `SKILL.md` that tells the
401
+ agent when and how to consult Carbon-X, and a standard-library Python script with two commands, `ask` (a question about
402
+ named DNA files) and `compare` (rank equal-length variants by sequence likelihood).
403
+
404
+ ```bash
405
+ hf download HuggingFaceBio/Carbon-X --include "skills/*" --local-dir carbon-x
406
+ cp -r carbon-x/skills/carbon-dna ~/.claude/skills/ # Claude Code; Codex CLI: ~/.codex/skills/
407
+ export CARBON_BASE_URL=http://localhost:8000/v1 # your Carbon-X server
408
+ ```
409
+
410
+ The script also runs on its own:
411
+
412
+ ```bash
413
+ python carbon-x/skills/carbon-dna/scripts/carbon.py ask --sequence reference=ref.fa --sequence variant=alt.fa \
414
+ --question "Is the variant benign or pathogenic?" --context "Position 20: A>T"
415
+ ```
416
+
417
+ ### Tips for agent use
418
 
419
  - **Pass DNA by reference.** Keep sequences in files or a lookup table and let the agent name them.
420
  - **Ask for Carbon-X explicitly.** Agents often answer DNA questions on their own instead of calling a tool. If you want Carbon-X's view, say so in the system prompt.
skills/carbon-dna/SKILL.md ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: carbon-dna
3
+ description: "Consult Carbon-X, a DNA language model, about DNA sequences in files: variant effects, regulatory elements, organism of origin, sequence features, and likelihood comparisons between variants."
4
+ ---
5
+
6
+ # Consult Carbon-X about DNA
7
+
8
+ Carbon-X is a DNA language model. Use it for biological interpretation of DNA alongside your own analysis. Its answers
9
+ are model predictions, not verified facts or clinical classifications.
10
+
11
+ `scripts/carbon.py` (next to this file) talks to a server running Carbon-X. It needs only Python 3.10+. The user
12
+ configures the server with `CARBON_BASE_URL` (default `http://localhost:8000/v1`), `CARBON_MODEL` and, if needed,
13
+ `CARBON_API_KEY`. Resolve the script's absolute path from this skill's directory.
14
+
15
+ Put each sequence in its own file (plain DNA or one FASTA record) and refer to files by name. Do not retype DNA into
16
+ commands: copying long sequences by hand introduces errors.
17
+
18
+ Ask a question about one or more sequences:
19
+
20
+ ```sh
21
+ python3 /path/to/carbon-dna/scripts/carbon.py ask \
22
+ --sequence reference=reference.fa --sequence variant=variant.fa \
23
+ --question "variant differs from reference at one base. Is the change likely benign or pathogenic?" \
24
+ --context "Position 20 (1-based): A>T. Gene and coordinates, if known."
25
+ ```
26
+
27
+ Rank equal-length variants by how typical Carbon-X finds each sequence (a likelihood, not a pathogenicity score):
28
+
29
+ ```sh
30
+ python3 /path/to/carbon-dna/scripts/carbon.py compare --reference reference.fa --alternative variant=variant.fa
31
+ ```
32
+
33
+ From Python, add `scripts/` to `sys.path` and call `ask(question, {name: dna}, context)` or
34
+ `compare(reference_dna, {name: dna})`.
35
+
36
+ Guidance:
37
+
38
+ - Work out exact facts in code first (positions, reference and alternate bases, reading frame, lengths) and pass them
39
+ as `--context`. Carbon-X can miscount positions in long sequences.
40
+ - Ask one self-contained question per call, and keep any answer choices from the user's question.
41
+ - Carbon-X reads A/C/G/T only. The script trims `N` padding at the ends and refuses other characters.
42
+ - Check `truncated` in the result; for next-base or continuation questions, ask for only the length you need.
skills/carbon-dna/scripts/carbon.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ask Carbon-X about DNA files, or compare DNA variants by model likelihood.
2
+
3
+ Talks to any OpenAI-compatible server running Carbon-X, such as `vllm serve HuggingFaceBio/Carbon-X --trust-remote-code`.
4
+ Configure it with environment variables:
5
+ CARBON_BASE_URL server URL including /v1 (default http://localhost:8000/v1)
6
+ CARBON_MODEL served model name (default HuggingFaceBio/Carbon-X)
7
+ CARBON_API_KEY bearer token, if the server needs one (for example an HF token for an Inference Endpoint)
8
+
9
+ Usage:
10
+ python carbon.py ask --question "..." --sequence reference=ref.fa --sequence variant=alt.fa [--context "..."]
11
+ python carbon.py compare --reference ref.fa --alternative alt1=alt1.fa [--alternative alt2=alt2.fa]
12
+ Only the Python standard library is needed. Output is JSON.
13
+ """
14
+
15
+ import argparse
16
+ import json
17
+ import os
18
+ import re
19
+ import sys
20
+ import urllib.request
21
+ from pathlib import Path
22
+
23
+ BASE_URL = os.environ.get("CARBON_BASE_URL", "http://localhost:8000/v1").rstrip("/")
24
+ MODEL = os.environ.get("CARBON_MODEL", "HuggingFaceBio/Carbon-X")
25
+ API_KEY = os.environ.get("CARBON_API_KEY", "")
26
+
27
+
28
+ def read_dna(path: str) -> str:
29
+ """Read plain DNA or one FASTA record; keep only A/C/G/T after trimming N padding at the ends."""
30
+ lines = [line.strip() for line in Path(path).read_text().splitlines() if line.strip()]
31
+ if lines and lines[0].startswith(">"):
32
+ lines = lines[1:]
33
+ if any(line.startswith(">") for line in lines):
34
+ sys.exit(f"{path}: one sequence per file, please (multi-record FASTA found).")
35
+ dna = "".join(lines).upper().strip("N")
36
+ if not re.fullmatch("[ACGT]+", dna):
37
+ sys.exit(f"{path}: Carbon-X reads A/C/G/T; remove other characters first.")
38
+ return dna
39
+
40
+
41
+ def post(route: str, payload: dict) -> dict:
42
+ request = urllib.request.Request(f"{BASE_URL}/{route}", json.dumps({"model": MODEL, **payload}).encode(),
43
+ {"Content-Type": "application/json", **({"Authorization": f"Bearer {API_KEY}"} if API_KEY else {})})
44
+ with urllib.request.urlopen(request, timeout=600) as response:
45
+ return json.load(response)
46
+
47
+
48
+ def ask(question: str, sequences: dict[str, str], context: str = "", max_tokens: int = 4096) -> dict:
49
+ """Ask a question about named DNA sequences. Returns Carbon-X's answer, a model prediction."""
50
+ parts = [context, question] if context else [question]
51
+ parts += [f"{name}: <dna>{dna}</dna>" for name, dna in sequences.items()]
52
+ response = post("chat/completions", {"messages": [{"role": "user", "content": "\n\n".join(parts)}],
53
+ "max_tokens": max_tokens, "chat_template_kwargs": {"enable_thinking": False}})
54
+ choice = response["choices"][0]
55
+ return {"answer": choice["message"]["content"], "truncated": choice["finish_reason"] == "length"}
56
+
57
+
58
+ def log_likelihood(dna: str) -> float:
59
+ """Total log-likelihood (nats) of the DNA tokens of `<dna>{dna}</dna>` under Carbon-X."""
60
+ response = post("completions", {"prompt": f"<dna>{dna}</dna>", "max_tokens": 1, "temperature": 1.0,
61
+ "prompt_logprobs": 0, "add_special_tokens": False, "return_token_ids": True})
62
+ choice = response["choices"][0]
63
+ ids, logprobs = choice["prompt_token_ids"], choice["prompt_logprobs"]
64
+ n = (len(dna) + 5) // 6 # one token per 6 bases; a shorter tail gets its own token
65
+ if len(ids) != n + 2: # <dna>, n DNA tokens, </dna>
66
+ sys.exit("The server did not tokenize the DNA as expected; is it serving Carbon-X with --trust-remote-code?")
67
+ return sum(logprobs[i][str(ids[i])]["logprob"] for i in range(1, n + 1))
68
+
69
+
70
+ def compare(reference: str, alternatives: dict[str, str]) -> dict:
71
+ """Rank equal-length alternatives by log-likelihood relative to the reference (higher is more typical DNA).
72
+
73
+ This is sequence likelihood, not a calibrated pathogenicity score.
74
+ """
75
+ if any(len(dna) != len(reference) for dna in alternatives.values()):
76
+ sys.exit("compare needs equal-length sequences; for insertions or deletions, use ask.")
77
+ base = log_likelihood(reference)
78
+ rows = [{"name": name, "delta_log_likelihood": log_likelihood(dna) - base,
79
+ "substitutions": sum(a != b for a, b in zip(reference, dna))} for name, dna in alternatives.items()]
80
+ return {"reference_log_likelihood": base, "alternatives": sorted(rows, key=lambda r: -r["delta_log_likelihood"])}
81
+
82
+
83
+ def named(items: list[str]) -> dict[str, str]:
84
+ pairs = [item.split("=", 1) for item in items]
85
+ if any(len(pair) != 2 for pair in pairs):
86
+ sys.exit("Use NAME=FILE for each sequence.")
87
+ return {name: read_dna(path) for name, path in pairs}
88
+
89
+
90
+ if __name__ == "__main__":
91
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
92
+ sub = parser.add_subparsers(dest="command", required=True)
93
+ a = sub.add_parser("ask")
94
+ a.add_argument("--question", required=True)
95
+ a.add_argument("--sequence", action="append", default=[], metavar="NAME=FILE")
96
+ a.add_argument("--context", default="")
97
+ c = sub.add_parser("compare")
98
+ c.add_argument("--reference", required=True, metavar="FILE")
99
+ c.add_argument("--alternative", action="append", required=True, metavar="NAME=FILE")
100
+ args = parser.parse_args()
101
+ if args.command == "ask":
102
+ result = ask(args.question, named(args.sequence), args.context)
103
+ else:
104
+ result = compare(read_dna(args.reference), named(args.alternative))
105
+ print(json.dumps(result, indent=2))