co-agent-coder 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 (51) hide show
  1. co_agent_coder-0.1.0.dist-info/METADATA +396 -0
  2. co_agent_coder-0.1.0.dist-info/RECORD +51 -0
  3. co_agent_coder-0.1.0.dist-info/WHEEL +5 -0
  4. co_agent_coder-0.1.0.dist-info/entry_points.txt +5 -0
  5. co_agent_coder-0.1.0.dist-info/licenses/LICENSE +201 -0
  6. co_agent_coder-0.1.0.dist-info/top_level.txt +1 -0
  7. coagent/__init__.py +84 -0
  8. coagent/__main__.py +6 -0
  9. coagent/agent/__init__.py +27 -0
  10. coagent/agent/budget.py +220 -0
  11. coagent/agent/loop.py +1102 -0
  12. coagent/agent/prompts.py +228 -0
  13. coagent/agent/state.py +181 -0
  14. coagent/cli.py +351 -0
  15. coagent/config.py +307 -0
  16. coagent/context/__init__.py +26 -0
  17. coagent/context/artifacts.py +431 -0
  18. coagent/context/repo_map.py +590 -0
  19. coagent/context/retrieval.py +375 -0
  20. coagent/context/tokens.py +93 -0
  21. coagent/evals/__init__.py +0 -0
  22. coagent/gitx/__init__.py +52 -0
  23. coagent/gitx/commit.py +91 -0
  24. coagent/gitx/durable.py +147 -0
  25. coagent/gitx/git.py +351 -0
  26. coagent/gitx/lock.py +157 -0
  27. coagent/gitx/log.py +85 -0
  28. coagent/gitx/resume.py +199 -0
  29. coagent/gitx/session.py +233 -0
  30. coagent/llm/__init__.py +83 -0
  31. coagent/llm/base.py +235 -0
  32. coagent/llm/grammar.py +306 -0
  33. coagent/llm/registry.py +147 -0
  34. coagent/llm/transformers_backend.py +326 -0
  35. coagent/py.typed +0 -0
  36. coagent/safety/__init__.py +24 -0
  37. coagent/safety/denylist.py +146 -0
  38. coagent/safety/policy.py +219 -0
  39. coagent/safety/redact.py +146 -0
  40. coagent/templates/config.toml +104 -0
  41. coagent/tools/__init__.py +155 -0
  42. coagent/tools/artifacts.py +307 -0
  43. coagent/tools/base.py +226 -0
  44. coagent/tools/fs.py +507 -0
  45. coagent/tools/notebooks.py +875 -0
  46. coagent/tools/pyt.py +240 -0
  47. coagent/tools/shell.py +558 -0
  48. coagent/ui/__init__.py +50 -0
  49. coagent/ui/gradio_app.py +353 -0
  50. coagent/ui/notebook.py +342 -0
  51. coagent/ui/render.py +422 -0
@@ -0,0 +1,875 @@
1
+ """Notebook tools: read, edit, and execute `.ipynb` files (PRD FR-TOL-6).
2
+
3
+ Why notebooks get their own module
4
+ ----------------------------------
5
+ A notebook is JSON, not text, so SEARCH/REPLACE on the raw file is a trap: the
6
+ model would be editing escaped `\n` inside a string, and a half-matched cell
7
+ would produce a file that opens in Colab as a corrupt notebook. Three
8
+ consequences shape this module.
9
+
10
+ **Cells are addressed by id, never by index.** Indices shift under insert and
11
+ delete, so "edit cell 3" silently edits the wrong code the moment an earlier cell
12
+ is removed. `nbformat` 4.5+ gives every cell an `id`; `rust_cell()` assigns
13
+ stable ones on first read, so an old notebook acquires ids before it is edited
14
+ rather than after a wrong edit.
15
+
16
+ **Read-before-write applies here too** (DESIGN §5.4). `edit_notebook` refuses a
17
+ path the session has not read, exactly like `edit_file`.
18
+
19
+ **Cell outputs never enter context** (FR-CTX-4). A single `display(df)` can blow
20
+ the whole 12k window. Execution reads outputs from a second notebook file, folds
21
+ them into a compact report — the last traceback, or a capped tail of stdout — and
22
+ hands the full outputs to the loop as artifacts.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import re
28
+ import sys
29
+ import tempfile
30
+ from dataclasses import dataclass, field
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ from ..context.artifacts import ArtifactError
35
+ from ..safety.policy import Verdict
36
+ from .base import ArgSpec, SafetyClass, ToolError, ToolResult
37
+ from .fs import _PolicyTool
38
+ from .shell import ExecConfig, run_subprocess
39
+
40
+ __all__ = [
41
+ "CellView",
42
+ "NotebookDoc",
43
+ "ReadNotebookTool",
44
+ "EditNotebookTool",
45
+ "ExecNotebookTool",
46
+ "load_notebook",
47
+ "render_cells",
48
+ "summarise_execution",
49
+ "MAX_CELL_LINES",
50
+ "MAX_TAIL_LINES",
51
+ "MAX_LINE_CHARS",
52
+ ]
53
+
54
+ #: Per-cell source cap in the read view. A 600-line cell is a file, not a cell;
55
+ #: the model can still read the notebook as text if it truly needs all of it.
56
+ MAX_CELL_LINES = 60
57
+
58
+ #: FR-CTX-4: stdout/stderr that does enter context is capped at 40 lines.
59
+ MAX_TAIL_LINES = 40
60
+
61
+ #: How much of a traceback to keep. The last frame is what names the bug.
62
+ MAX_TRACEBACK_LINES = 30
63
+
64
+ _CELL_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
65
+
66
+
67
+ # --------------------------------------------------------------------- document
68
+
69
+
70
+ @dataclass
71
+ class CellView:
72
+ """One cell as the model sees it: id, type, source. Never outputs."""
73
+
74
+ id: str
75
+ index: int # 0-based position, shown only as a hint
76
+ cell_type: str
77
+ source: str
78
+ execution_count: int | None = None
79
+ outputs: int = 0
80
+
81
+ @property
82
+ def line_count(self) -> int:
83
+ return len(self.source.splitlines())
84
+
85
+
86
+ @dataclass
87
+ class NotebookDoc:
88
+ """A parsed notebook plus the ids we normalised.
89
+
90
+ Held separately from `nbformat` so the reader and the renderer stay testable
91
+ without the dependency; `nbformat` is only required for reading/writing the
92
+ real file, and is imported inside the io helpers.
93
+ """
94
+
95
+ path: Path
96
+ rel: str
97
+ nb: Any # nbformat.NotebookNode
98
+ assigned_ids: int = 0
99
+ metadata: dict[str, Any] = field(default_factory=dict)
100
+ fingerprint: str = ""
101
+
102
+ @property
103
+ def cells(self) -> list[Any]:
104
+ return list(self.nb.cells)
105
+
106
+ @property
107
+ def kernel(self) -> str:
108
+ name = (self.nb.metadata or {}).get("kernelspec", {}).get("name")
109
+ return str(name) if name else "python3"
110
+
111
+ @property
112
+ def language(self) -> str:
113
+ meta = (self.nb.metadata or {}).get("language_info", {})
114
+ return str(meta.get("name") or "python")
115
+
116
+ def views(self) -> list[CellView]:
117
+ return [self.view(i) for i in range(len(self.nb.cells))]
118
+
119
+ def view(self, index: int) -> CellView:
120
+ cell = self.nb.cells[index]
121
+ source = cell_source(cell)
122
+ outputs = cell.get("outputs", []) or []
123
+ return CellView(
124
+ id=str(cell.get("id") or ""),
125
+ index=index,
126
+ cell_type=str(cell.get("cell_type", "code")),
127
+ source=source,
128
+ execution_count=cell.get("execution_count"),
129
+ outputs=len(outputs),
130
+ )
131
+
132
+ def find(self, cell_id: str) -> tuple[int, Any]:
133
+ """Locate a cell by id, with a corrective message listing real ids.
134
+
135
+ Ids are the contract. An unknown id is a model mistake that costs a turn,
136
+ so the error names every valid id rather than saying "not found".
137
+ """
138
+ cells = self.nb.cells
139
+ for index, cell in enumerate(cells):
140
+ if str(cell.get("id") or "") == cell_id:
141
+ return index, cell
142
+ known = _id_list(cells)
143
+ if cell_id.strip().isdigit():
144
+ raise ToolError(
145
+ f"cells are addressed by id, not index. {_valid_ids_note(known)}"
146
+ )
147
+ raise ToolError(f"no cell with id {cell_id!r}. {_valid_ids_note(known)}")
148
+
149
+ def markdown_heading(self, cell_id: str) -> str:
150
+ _, cell = self.find(cell_id)
151
+ return cell_source(cell).strip().splitlines()[0].lstrip("# ").strip() if cell else ""
152
+
153
+
154
+ def _id_list(cells: list[Any]) -> list[str]:
155
+ return [str(c.get("id") or "") for c in cells]
156
+
157
+
158
+ def _valid_ids_note(known: list[str]) -> str:
159
+ shown = ", ".join(k for k in known if k) or "(none)"
160
+ return f"Valid cell ids: {shown}."
161
+
162
+
163
+ def cell_source(cell: Any) -> str:
164
+ """Notebook cell source is a str or a list of lines; normalise to str."""
165
+ src = cell.get("source", "")
166
+ if isinstance(src, list):
167
+ return "".join(str(s) for s in src)
168
+ return str(src)
169
+
170
+
171
+ def set_cell_source(cell: Any, text: str) -> None:
172
+ """Store source as a list of lines, which is what nbformat and git diffs like."""
173
+ cell["source"] = text.splitlines(keepends=True)
174
+
175
+
176
+ def load_notebook(path: Path, rel: str) -> NotebookDoc:
177
+ """Read and normalise a notebook, assigning missing cell ids.
178
+
179
+ `nbformat` validation is deliberately *not* run here: Colab writes notebooks
180
+ that are missing the odd required field, and refusing to read them would make
181
+ the common case the broken case. Validation runs on write, where it protects
182
+ the file we are about to hand back to Colab.
183
+ """
184
+ try:
185
+ import nbformat
186
+ except ImportError as exc: # pragma: no cover - depends on install extras
187
+ raise ToolError(
188
+ "reading notebooks needs nbformat. Install it with: pip install 'coagent[ui]'"
189
+ ) from exc
190
+ try:
191
+ raw = path.read_text(encoding="utf-8")
192
+ except OSError as exc:
193
+ raise ToolError(f"cannot read {rel}: {exc}") from exc
194
+ try:
195
+ nb = nbformat.reads(raw, as_version=4)
196
+ except Exception as exc: # noqa: BLE001 - nbformat raises unrelated types
197
+ raise ToolError(
198
+ f"{rel} is not a valid notebook ({exc}). Fix the JSON, or use read_file "
199
+ "to inspect it as text."
200
+ ) from exc
201
+
202
+ doc = NotebookDoc(path=path, rel=rel, nb=nb, fingerprint=_fingerprint(raw))
203
+ doc.assigned_ids = assign_cell_ids(nb)
204
+ doc.metadata = dict(nb.get("metadata") or {})
205
+ return doc
206
+
207
+
208
+ def assign_cell_ids(nb: Any) -> int:
209
+ """Give every cell a valid id, returning how many were assigned.
210
+
211
+ Colab still emits notebooks under nbformat 4.4, where cells have no id at
212
+ all. Assigning on read means the model always has a handle, and the id does
213
+ not change later because of an unrelated edit.
214
+ """
215
+ assigned = 0
216
+ used: set[str] = set()
217
+ for index, cell in enumerate(nb.cells):
218
+ existing = str(cell.get("id") or "")
219
+ if _CELL_ID_RE.match(existing) and existing not in used:
220
+ used.add(existing)
221
+ continue
222
+ candidate = f"c{index:02d}"
223
+ suffix = 0
224
+ while candidate in used:
225
+ suffix += 1
226
+ candidate = f"c{index:02d}-{suffix}"
227
+ cell["id"] = candidate
228
+ used.add(candidate)
229
+ assigned += 1
230
+ return assigned
231
+
232
+
233
+ def _fingerprint(raw: str) -> str:
234
+ import hashlib
235
+
236
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
237
+
238
+
239
+ # ---------------------------------------------------------------------- render
240
+
241
+
242
+ def render_cells(doc: NotebookDoc, *, max_cell_lines: int = MAX_CELL_LINES, header: bool = True) -> str:
243
+ """The model's view of a notebook: one block per cell, sources only.
244
+
245
+ Outputs are summarised as a count, not printed, because FR-CTX-4 says cell
246
+ outputs are artifacts and a `display(df)` would evict the window. The model
247
+ sees *that* a cell ran and *where* it failed, never the dataframe.
248
+
249
+ ``header=False`` drops the notebook-level line so an edit diff shows only the
250
+ cell change; nbformat bumps its minor version on save, which would otherwise
251
+ appear as a spurious first-line change on every edit.
252
+ """
253
+ cells = doc.cells
254
+ if not cells:
255
+ return f"{doc.rel}: empty notebook (no cells)"
256
+
257
+ out: list[str] = []
258
+ if header:
259
+ head = (
260
+ f"{doc.rel} — {len(cells)} cells, kernel {doc.kernel}, "
261
+ f"nbformat {doc.nb.get('nbformat', '?')}.{doc.nb.get('nbformat_minor', '?')}"
262
+ )
263
+ if doc.assigned_ids:
264
+ head += f" ({doc.assigned_ids} cell id(s) assigned)"
265
+ out.extend([head, ""])
266
+
267
+ for view in doc.views():
268
+ label = f"cell {view.id} [{view.cell_type}"
269
+ if view.cell_type == "code":
270
+ label += f", exec {view.execution_count if view.execution_count is not None else '-'}"
271
+ if view.outputs:
272
+ label += f", {view.outputs} output(s) hidden"
273
+ label += f"] lines {view.index + 1}"
274
+ out.append(f"--- {label} ---")
275
+
276
+ body = view.source.rstrip("\n")
277
+ if not body.strip():
278
+ out.append("(empty)")
279
+ else:
280
+ lines = body.splitlines()
281
+ if len(lines) > max_cell_lines:
282
+ head = lines[: max_cell_lines - 5]
283
+ tail = lines[-4:]
284
+ out.extend(head)
285
+ out.append(f"… [{len(lines) - max_cell_lines} lines omitted in this cell]")
286
+ out.extend(tail)
287
+ else:
288
+ out.extend(lines)
289
+ out.append("")
290
+ return "\n".join(out).rstrip()
291
+
292
+
293
+ def write_notebook(doc: NotebookDoc) -> None:
294
+ """Serialise back to disk, validating first so Colab always opens it."""
295
+ try:
296
+ import nbformat
297
+ except ImportError as exc: # pragma: no cover
298
+ raise ToolError("writing notebooks needs nbformat") from exc
299
+
300
+ nb = doc.nb
301
+ # Writing sets a minimum version that supports ids; without this, a notebook
302
+ # read as 4.4 would serialize ids it is not allowed to have.
303
+ nb.nbformat = max(int(nb.get("nbformat", 4)), 4)
304
+ if nb.nbformat == 4:
305
+ nb.nbformat_minor = max(int(nb.get("nbformat_minor", 0)), 5)
306
+ try:
307
+ nbformat.validate(nb)
308
+ except Exception as exc: # noqa: BLE001 - ValidationError needs jsonschema
309
+ raise ToolError(f"refusing to write an invalid notebook: {exc}") from exc
310
+
311
+ text = nbformat.writes(nb, version=4)
312
+ doc.path.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
313
+
314
+
315
+ # ------------------------------------------------------------------ python gate
316
+
317
+
318
+ def _python_syntax_error(cell: Any, language: str) -> str:
319
+ """Reject a cell that will not parse as Python (FR-VER-4, DESIGN §6.1).
320
+
321
+ IPython magics (`%matplotlib`, `!pip`) and `%%` cells are not parseable as
322
+ Python but are perfectly valid notebook cells, so they are skipped rather
323
+ than flagged. Anything else that fails `ast.parse` is a real error and is
324
+ caught before it reaches the file — the alternative is a model that only
325
+ learns about the typo after a 300 s execution.
326
+ """
327
+ if language.lower() not in {"python", "python3", ""}:
328
+ return ""
329
+ if cell.get("cell_type") != "code":
330
+ return ""
331
+ source = cell_source(cell)
332
+ if not source.strip():
333
+ return ""
334
+ stripped = source.lstrip()
335
+ if stripped.startswith("%%") or stripped.startswith("!"):
336
+ return ""
337
+ # Line magics and shell escapes are valid in IPython only; neutralise them so
338
+ # the rest of the cell can still be checked.
339
+ neutralised = "\n".join(
340
+ "" if line.lstrip().startswith(("%", "!", "?")) else line
341
+ for line in source.splitlines()
342
+ )
343
+ import ast
344
+
345
+ try:
346
+ ast.parse(neutralised)
347
+ except SyntaxError as exc:
348
+ return f"{exc.msg} at line {exc.lineno}"
349
+ return ""
350
+
351
+
352
+ # --------------------------------------------------------------------- output
353
+
354
+
355
+ @dataclass
356
+ class ExecutionOutput:
357
+ """One output from an executed notebook, as an artifact candidate."""
358
+
359
+ cell_id: str
360
+ kind: str # "stream" | "error" | "image" | "html" | "text"
361
+ mime: str = ""
362
+ data: str = ""
363
+
364
+
365
+ def parse_outputs(doc: NotebookDoc) -> list[ExecutionOutput]:
366
+ """Flatten every cell output into artifacts (never into context verbatim)."""
367
+ collected: list[ExecutionOutput] = []
368
+ for view in doc.views():
369
+ _, cell = doc.find(view.id)
370
+ for output in cell.get("outputs", []) or []:
371
+ kind = str(output.get("output_type", ""))
372
+ if kind == "stream":
373
+ collected.append(
374
+ ExecutionOutput(
375
+ view.id, "stream", str(output.get("name", "stdout")), "".join(output.get("text", ""))
376
+ )
377
+ )
378
+ elif kind == "error":
379
+ collected.append(
380
+ ExecutionOutput(
381
+ view.id,
382
+ "error",
383
+ "traceback",
384
+ "\n".join(output.get("traceback", []))
385
+ or f"{output.get('ename')}: {output.get('evalue')}",
386
+ )
387
+ )
388
+ elif kind in {"display_data", "execute_result"}:
389
+ data = output.get("data", {}) or {}
390
+ for mime in ("image/png", "image/jpeg", "text/html", "text/plain"):
391
+ if mime in data:
392
+ value = data[mime]
393
+ if isinstance(value, list):
394
+ value = "".join(value)
395
+ kind_name = "image" if mime.startswith("image/") else (
396
+ "html" if mime == "text/html" else "text"
397
+ )
398
+ collected.append(ExecutionOutput(view.id, kind_name, mime, str(value)))
399
+ break
400
+ return collected
401
+
402
+
403
+ def summarise_execution(doc: NotebookDoc, *, tail_lines: int = MAX_TAIL_LINES) -> tuple[str, bool, dict[str, Any]]:
404
+ """Condense outputs into what may enter context (FR-CTX-4).
405
+
406
+ Returns `(summary, ok, meta)`. Only the last traceback and a capped tail of
407
+ stdout are included; the rest is in `meta["outputs"]` for the artifact tools.
408
+ """
409
+ outputs = parse_outputs(doc)
410
+ errors = [o for o in outputs if o.kind == "error"]
411
+ streams = [o for o in outputs if o.kind == "stream"]
412
+ images = [o for o in outputs if o.kind == "image"]
413
+ html = [o for o in outputs if o.kind == "html"]
414
+
415
+ ok = not errors
416
+ sections: list[str] = []
417
+ if errors:
418
+ last = errors[-1]
419
+ tb_lines = [_cap_line(line) for line in _strip_ansi(last.data).splitlines()]
420
+ kept = tb_lines[-MAX_TRACEBACK_LINES:]
421
+ sections.append(f"Traceback (cell {last.cell_id}, last {len(kept)} lines):\n" + "\n".join(kept))
422
+ text_out = "\n".join(_strip_ansi(o.data) for o in streams).strip()
423
+ if text_out:
424
+ lines = [_cap_line(line) for line in text_out.splitlines()]
425
+ if len(lines) > tail_lines:
426
+ omitted = len(lines) - tail_lines
427
+ sections.append(
428
+ f"stdout/stderr (tail {tail_lines} of {len(lines)} lines, {omitted} omitted):\n"
429
+ + "\n".join(lines[-tail_lines:])
430
+ )
431
+ else:
432
+ # Join the capped lines, not `text_out`: a single huge line must not
433
+ # bypass the 40-line budget (FR-CTX-4).
434
+ sections.append("stdout/stderr:\n" + "\n".join(lines))
435
+
436
+ if images or html:
437
+ bits = []
438
+ if images:
439
+ bits.append(f"{len(images)} image(s)")
440
+ if html:
441
+ bits.append(f"{len(html)} HTML output(s)")
442
+ sections.append("rendered: " + ", ".join(bits) + " (sent to artifacts, not context)")
443
+
444
+ summary = "\n\n".join(sections) if sections else "executed with no output"
445
+ meta = {
446
+ "ok": ok,
447
+ "cells_with_output": len({o.cell_id for o in outputs}),
448
+ "images": len(images),
449
+ "errors": len(errors),
450
+ "outputs": outputs,
451
+ }
452
+ return summary, ok, meta
453
+
454
+
455
+ #: A line cap as well as a line count. `print(x * 200)` is one line and would
456
+ #: otherwise walk straight past the 40-line budget (FR-CTX-4).
457
+ MAX_LINE_CHARS = 300
458
+
459
+
460
+ def _cap_line(line: str, limit: int = MAX_LINE_CHARS) -> str:
461
+ return line if len(line) <= limit else line[: limit - 20] + " …[truncated]"
462
+
463
+
464
+ def _decode_base64(text: str) -> bytes | None:
465
+ """Decode an image payload, tolerating the newlines nbformat keeps."""
466
+ import base64
467
+ import binascii
468
+
469
+ try:
470
+ return base64.b64decode(text, validate=False)
471
+ except (binascii.Error, ValueError):
472
+ return None
473
+
474
+
475
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
476
+
477
+
478
+ def _strip_ansi(text: str) -> str:
479
+ return _ANSI_RE.sub("", text)
480
+
481
+
482
+ # --------------------------------------------------------------------- tools
483
+
484
+
485
+ class ReadNotebookTool(_PolicyTool):
486
+ name = "read_notebook"
487
+ description = (
488
+ "Read a Jupyter notebook (.ipynb) as cells: every cell is shown with its "
489
+ "id, type, and source. Cell outputs are NOT shown (they go to artifacts). "
490
+ "Read before editing, exactly like read_file."
491
+ )
492
+ safety = SafetyClass.READ
493
+ args = (
494
+ ArgSpec("path", "string", "Repo-relative path to the .ipynb file."),
495
+ ArgSpec("max_cell_lines", "integer", "Truncate each cell to this many lines.", required=False, default=MAX_CELL_LINES),
496
+ )
497
+
498
+ def run(self, path: str, max_cell_lines: int = MAX_CELL_LINES) -> ToolResult:
499
+ resolved = self._read_path(path)
500
+ if not resolved.is_file():
501
+ raise ToolError(f"{path} does not exist. Use list_dir to find it.")
502
+ doc = load_notebook(resolved, self.session.rel(resolved))
503
+ self.session.mark_read(resolved)
504
+ body = render_cells(doc, max_cell_lines=max(5, min(int(max_cell_lines), 400)))
505
+ return ToolResult.success(
506
+ body,
507
+ path=doc.rel,
508
+ cells=len(doc.cells),
509
+ ids_assigned=doc.assigned_ids,
510
+ fingerprint=doc.fingerprint,
511
+ )
512
+
513
+
514
+ class EditNotebookTool(_PolicyTool):
515
+ name = "edit_notebook"
516
+ description = (
517
+ "Edit a notebook cell by id. Modes: `replace_source` (rewrite the cell), "
518
+ "`insert` (add a cell after a given id, or at the top with id \"\"), "
519
+ "`delete` (remove the cell), `replace_metadata` (set kernelspec name). "
520
+ "Read the notebook first. The file must stay a valid notebook."
521
+ )
522
+ safety = SafetyClass.WRITE
523
+ args = (
524
+ ArgSpec("path", "string", "Repo-relative path to the .ipynb file."),
525
+ ArgSpec(
526
+ "mode",
527
+ "string",
528
+ "One of: replace_source, insert, delete, replace_metadata.",
529
+ enum=("replace_source", "insert", "delete", "replace_metadata"),
530
+ ),
531
+ ArgSpec("cell_id", "string", "Target cell id. Use \"\" with mode=insert to insert at the top.", required=False, default=""),
532
+ ArgSpec("cell_type", "string", "Cell type for insert: code or markdown.", required=False, default="code", enum=("code", "markdown")),
533
+ ArgSpec("source", "string", "New cell source (replace_source, insert).", required=False, default=""),
534
+ ArgSpec("kernel", "string", "Kernel name for replace_metadata, e.g. python3.", required=False, default=""),
535
+ ArgSpec("cite", "string", "Optional hint about why you are editing (advisory).", required=False, default=""),
536
+ )
537
+
538
+ def run(
539
+ self,
540
+ path: str,
541
+ mode: str,
542
+ cell_id: str = "",
543
+ cell_type: str = "code",
544
+ source: str = "",
545
+ kernel: str = "",
546
+ cite: str = "",
547
+ ) -> ToolResult:
548
+ resolved = self._write_path(path)
549
+ if not self.session.was_read(resolved):
550
+ raise ToolError(
551
+ f"{path} has not been read this session. Call read_notebook on it first, "
552
+ "then edit by cell id."
553
+ )
554
+ if not resolved.is_file():
555
+ raise ToolError(f"{path} does not exist; create it with write_file (JSON) or `coagent init`.")
556
+
557
+ doc = load_notebook(resolved, self.session.rel(resolved))
558
+ before = render_cells(doc, header=False)
559
+ language = doc.language
560
+ detail = ""
561
+
562
+ if mode == "replace_source":
563
+ if not cell_id:
564
+ raise ToolError("replace_source needs `cell_id`. Use read_notebook to get the ids.")
565
+ index, cell = doc.find(cell_id)
566
+ if not source.strip() and cell.get("cell_type") == "code":
567
+ raise ToolError(
568
+ f"the new source for cell {cell_id} is empty. To remove a cell use mode=delete."
569
+ )
570
+ set_cell_source(cell, source)
571
+ error = _python_syntax_error(cell, language)
572
+ if error:
573
+ raise ToolError(
574
+ f"cell {cell_id} would not parse as Python ({error}). Fix the source and retry; "
575
+ "the notebook was not changed."
576
+ )
577
+ cell["outputs"] = [] # stale outputs would describe code that no longer exists
578
+ cell["execution_count"] = None
579
+ detail = f"replaced source of cell {cell_id} (index {index + 1})"
580
+
581
+ elif mode == "insert":
582
+ if not source.strip():
583
+ raise ToolError("insert needs non-empty `source`.")
584
+ new_cell = self._new_cell(cell_type, source)
585
+ error = _python_syntax_error(new_cell, language)
586
+ if error:
587
+ raise ToolError(
588
+ f"the new cell would not parse as Python ({error}). The notebook was not changed."
589
+ )
590
+ if not cell_id:
591
+ doc.nb.cells.insert(0, new_cell)
592
+ detail = f"inserted {cell_type} cell at the top"
593
+ else:
594
+ index, _ = doc.find(cell_id)
595
+ doc.nb.cells.insert(index + 1, new_cell)
596
+ detail = f"inserted {cell_type} cell after {cell_id} (new index {index + 2})"
597
+ detail += f" with id {new_cell['id']}"
598
+
599
+ elif mode == "delete":
600
+ if not cell_id:
601
+ raise ToolError("delete needs `cell_id`.")
602
+ index, _ = doc.find(cell_id)
603
+ if len(doc.nb.cells) <= 1:
604
+ raise ToolError(
605
+ "refusing to delete the only cell: an empty notebook is usually not what "
606
+ "you mean. Use replace_source instead."
607
+ )
608
+ doc.nb.cells.pop(index)
609
+ detail = f"deleted cell {cell_id} (was index {index + 1})"
610
+
611
+ elif mode == "replace_metadata":
612
+ if not kernel:
613
+ raise ToolError("replace_metadata needs `kernel`, e.g. \"python3\".")
614
+ meta = dict(doc.nb.get("metadata") or {})
615
+ spec = dict(meta.get("kernelspec") or {})
616
+ spec["name"] = kernel
617
+ spec.setdefault("display_name", kernel)
618
+ spec.setdefault("language", language or "python")
619
+ meta["kernelspec"] = spec
620
+ doc.nb["metadata"] = meta
621
+ detail = f"set kernelspec.name to {kernel}"
622
+ else: # pragma: no cover - the enum blocks this
623
+ raise ToolError(f"unknown mode {mode!r}")
624
+
625
+ try:
626
+ write_notebook(doc)
627
+ except OSError as exc:
628
+ raise ToolError(f"cannot write {path}: {exc}") from exc
629
+ self.session.mark_written(resolved)
630
+
631
+ after = render_cells(doc, header=False)
632
+ from .fs import _unified_diff
633
+
634
+ diff = _unified_diff(before, after, doc.rel)
635
+ return ToolResult.success(
636
+ f"{detail} in {path}\n{diff}",
637
+ path=doc.rel,
638
+ diff=diff,
639
+ mode=mode,
640
+ cell_id=cell_id,
641
+ cite=cite,
642
+ )
643
+
644
+ def _new_cell(self, cell_type: str, source: str) -> Any:
645
+ from nbformat.v4 import new_code_cell, new_markdown_cell
646
+
647
+ maker = new_markdown_cell if cell_type == "markdown" else new_code_cell
648
+ return maker(source=source)
649
+
650
+
651
+ class ExecNotebookTool(_PolicyTool):
652
+ name = "exec_notebook"
653
+ description = (
654
+ "Execute a notebook in a subprocess via nbconvert, in place, with a "
655
+ "300 s timeout. Returns only the last traceback or a capped tail of stdout; "
656
+ "images and the full outputs become artifacts. Use this to prove a cell runs."
657
+ )
658
+ safety = SafetyClass.EXEC
659
+ args = (
660
+ ArgSpec("path", "string", "Repo-relative path to the .ipynb file."),
661
+ ArgSpec("timeout_s", "integer", "Timeout override in seconds.", required=False, default=0),
662
+ )
663
+
664
+ def __init__(self, session: Any) -> None:
665
+ super().__init__(session)
666
+ self.session = session
667
+ self.exec_cfg: ExecConfig = session.exec_cfg
668
+
669
+ def run(self, path: str, timeout_s: int = 0) -> ToolResult:
670
+ resolved = self._write_path(path)
671
+ if not resolved.is_file():
672
+ raise ToolError(f"{path} does not exist; nothing to execute.")
673
+ doc = load_notebook(resolved, self.session.rel(resolved))
674
+ self.session.mark_written(resolved) # execution rewrites the file in place
675
+
676
+ limit = int(timeout_s) or self.exec_cfg_notebook_timeout()
677
+ outcome = self._execute_in_subprocess(resolved, doc, timeout_s=limit)
678
+
679
+ # nbconvert writes into the file itself. Reading it back is how we get
680
+ # outputs without trusting stdout, which nbconvert keeps empty.
681
+ try:
682
+ executed = load_notebook(resolved, doc.rel)
683
+ except ToolError as exc:
684
+ return ToolResult.failure(
685
+ f"the notebook was rewritten by nbconvert but cannot be re-read ({exc}). "
686
+ "Restore it with git checkout and re-run."
687
+ )
688
+
689
+ summary, ok, meta = summarise_execution(executed)
690
+ # Two ways to exceed the budget: nbconvert's own `ExecutePreprocessor`
691
+ # timeout (which exits 1 with a CellTimeoutError and keeps the notebook),
692
+ # or our process-group kill when even nbconvert hangs. Both are the same
693
+ # thing to the model, so they are reported the same way.
694
+ cell_timeout = outcome.output.strip() and _looks_like_cell_timeout(outcome.output)
695
+ timed_out = outcome.timed_out or bool(cell_timeout)
696
+ if cell_timeout:
697
+ ok = False
698
+
699
+ if timed_out:
700
+ status = "timeout"
701
+ elif not ok:
702
+ # nbconvert exits 0 even with failing cells (`allow_errors`), so the
703
+ # status line must report the cell verdict, not the exit code.
704
+ status = f"{meta['errors']} failing cell(s)"
705
+ else:
706
+ status = f"exit {outcome.returncode}"
707
+ header = f"$ jupyter nbconvert --execute {doc.rel}\n[{status}, {outcome.duration_s:.1f}s, limit {limit}s]"
708
+ body = f"{header}\n\n{summary}"
709
+ if timed_out:
710
+ killed = " and its process group was killed" if outcome.timed_out else ""
711
+ body += (
712
+ f"\n\n[execution exceeded {limit}s{killed}. A long-running cell is the "
713
+ "usual cause; narrow the work or raise `tools.notebook_timeout_s`.]"
714
+ )
715
+ elif outcome.output.strip() and not ok and not meta["errors"]:
716
+ # nbconvert's own log is only useful when the notebook came back
717
+ # without a traceback (e.g. it never started). When a cell failed, the
718
+ # traceback above already says everything and the log is 20 lines of
719
+ # ANSI-coloured noise competing for the observation slot.
720
+ body += f"\n\nnbconvert output (tail):\n{_tail(outcome.output, 12)}"
721
+
722
+ images = meta.pop("outputs", [])
723
+ # Cell outputs are artifacts (FR-CTX-4). Saving them here, rather than
724
+ # asking the model to call save_artifact again, is what makes "the cell
725
+ # ran and the figure saved" one action instead of two — and the model is
726
+ # not in the loop for bytes it never sees.
727
+ saved = self._store_outputs(images, doc.rel)
728
+ if saved:
729
+ body += "\n\nartifacts: " + ", ".join(r.path for r in saved)
730
+
731
+ return ToolResult(
732
+ ok=ok and not timed_out,
733
+ content=body,
734
+ truncated=outcome.truncated,
735
+ meta={
736
+ **meta,
737
+ "path": doc.rel,
738
+ "timed_out": timed_out,
739
+ "returncode": outcome.returncode,
740
+ "outputs": images,
741
+ "artifacts": [r.path for r in saved],
742
+ },
743
+ )
744
+
745
+ def _store_outputs(self, outputs: list[Any], notebook_rel: str) -> list[Any]:
746
+ """Store images and HTML from a run; streams are not artifacts.
747
+
748
+ A stream output is the cell's own logging and belongs in the tail; a
749
+ figure or a rendered table is a product. Each is saved under a name
750
+ derived from the cell id, so re-running replaces the previous file
751
+ instead of piling up `output-1.png`, `output-2.png`, …
752
+ """
753
+ store = getattr(self.session, "artifact_store", None)
754
+ if store is None or not outputs:
755
+ return []
756
+ stem = Path(notebook_rel).stem or "notebook"
757
+ saved = []
758
+ for output in outputs:
759
+ if output.kind not in {"image", "html"}:
760
+ continue
761
+ ext = ".png" if output.mime == "image/png" else (
762
+ ".jpg" if output.mime == "image/jpeg" else ".html"
763
+ )
764
+ if output.kind == "image":
765
+ data = _decode_base64(output.data)
766
+ if data is None:
767
+ continue
768
+ else:
769
+ data = output.data.encode("utf-8", errors="replace")
770
+ try:
771
+ saved.append(
772
+ store.save_bytes(
773
+ data,
774
+ f"{stem}-{output.cell_id}{ext}",
775
+ mime=output.mime or "image/png",
776
+ subdir="notebooks",
777
+ )
778
+ )
779
+ except ArtifactError:
780
+ # A failed artifact save must not fail the execution: the cell
781
+ # ran, and that is the fact the verify gate needs.
782
+ continue
783
+ return saved
784
+
785
+ def exec_cfg_notebook_timeout(self) -> int:
786
+ return int(getattr(self.exec_cfg, "notebook_timeout_s", 300) or 300)
787
+
788
+ def _execute_in_subprocess(self, path: Path, doc: NotebookDoc, *, timeout_s: int) -> Any:
789
+ """Run nbconvert against a *copy* so a crash cannot corrupt the repo file.
790
+
791
+ Copy-execute-move is the safe order: if nbconvert dies mid-write, the
792
+ tracked notebook is untouched and the session keeps a clean tree.
793
+ """
794
+ workdir = Path(tempfile.mkdtemp(prefix="coagent-nb-", dir=str(self._scratch())))
795
+ copy = workdir / path.name
796
+ try:
797
+ self._gate_execution(doc, timeout_s)
798
+ copy.write_bytes(path.read_bytes())
799
+ argv = [
800
+ sys.executable,
801
+ "-m",
802
+ "jupyter",
803
+ "nbconvert",
804
+ "--to",
805
+ "notebook",
806
+ "--execute",
807
+ "--inplace",
808
+ f"--ExecutePreprocessor.timeout={timeout_s}",
809
+ f"--ExecutePreprocessor.kernel_name={doc.kernel}",
810
+ "--ExecutePreprocessor.allow_errors=True",
811
+ str(copy),
812
+ ]
813
+ outcome = run_subprocess(
814
+ argv,
815
+ cfg=self.exec_cfg,
816
+ cwd=workdir,
817
+ timeout_s=timeout_s + 30,
818
+ )
819
+ if copy.is_file() and not outcome.timed_out:
820
+ path.write_bytes(copy.read_bytes())
821
+ return outcome
822
+ finally:
823
+ import shutil
824
+
825
+ shutil.rmtree(workdir, ignore_errors=True)
826
+
827
+ def _gate_execution(self, doc: NotebookDoc, timeout_s: int) -> None:
828
+ """Execution is gated like shell: the policy sees every code cell.
829
+
830
+ A notebook that shells out or installs a package must be approved by the
831
+ same matrix that governs `run_shell`, otherwise `exec_notebook` is a way
832
+ around the approval system.
833
+ """
834
+ policy = self.session.policy
835
+ for view in doc.views():
836
+ if view.cell_type != "code":
837
+ continue
838
+ shell_lines = [
839
+ line for line in view.source.splitlines() if line.lstrip().startswith("!")
840
+ ]
841
+ decision = policy.check_shell(" ".join(shell_lines) if shell_lines else _SHELL_NEUTRAL)
842
+ if decision.verdict is Verdict.DENY:
843
+ raise ToolError(f"cell {view.id} is denied by policy: {decision.reason}")
844
+ if decision.verdict is Verdict.ASK:
845
+ raise ToolError(
846
+ f"cell {view.id} needs approval before it can run: {decision.reason}. "
847
+ "Ask the user, or remove the command."
848
+ )
849
+
850
+ def _scratch(self) -> Path:
851
+ scratch = self.session.policy.scratch
852
+ if scratch is None:
853
+ scratch = self.session.repo_root / ".coagent" / "tmp"
854
+ scratch.mkdir(parents=True, exist_ok=True)
855
+ return scratch
856
+
857
+
858
+ #: A string no deny rule matches; used when a cell has no shell escapes so the
859
+ #: policy check is a no-op rather than a false positive.
860
+ _SHELL_NEUTRAL = "python-notebook-cell"
861
+
862
+
863
+ def _tail(text: str, lines: int) -> str:
864
+ parts = _strip_ansi(text).splitlines()
865
+ return "\n".join(parts[-lines:])
866
+
867
+
868
+ #: nbconvert's `ExecutePreprocessor` timeout surfaces as a `CellTimeoutError`
869
+ #: traceback and exit 1; it does not hang. Both that and our own kill are a
870
+ #: timeout to the model, so both are detected here.
871
+ _TIMEOUT_MARKERS = ("CellTimeoutError", "Timeout waiting for execute reply", "CellExecutionTimeout")
872
+
873
+
874
+ def _looks_like_cell_timeout(output: str) -> bool:
875
+ return any(marker in output for marker in _TIMEOUT_MARKERS)