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.
- co_agent_coder-0.1.0.dist-info/METADATA +396 -0
- co_agent_coder-0.1.0.dist-info/RECORD +51 -0
- co_agent_coder-0.1.0.dist-info/WHEEL +5 -0
- co_agent_coder-0.1.0.dist-info/entry_points.txt +5 -0
- co_agent_coder-0.1.0.dist-info/licenses/LICENSE +201 -0
- co_agent_coder-0.1.0.dist-info/top_level.txt +1 -0
- coagent/__init__.py +84 -0
- coagent/__main__.py +6 -0
- coagent/agent/__init__.py +27 -0
- coagent/agent/budget.py +220 -0
- coagent/agent/loop.py +1102 -0
- coagent/agent/prompts.py +228 -0
- coagent/agent/state.py +181 -0
- coagent/cli.py +351 -0
- coagent/config.py +307 -0
- coagent/context/__init__.py +26 -0
- coagent/context/artifacts.py +431 -0
- coagent/context/repo_map.py +590 -0
- coagent/context/retrieval.py +375 -0
- coagent/context/tokens.py +93 -0
- coagent/evals/__init__.py +0 -0
- coagent/gitx/__init__.py +52 -0
- coagent/gitx/commit.py +91 -0
- coagent/gitx/durable.py +147 -0
- coagent/gitx/git.py +351 -0
- coagent/gitx/lock.py +157 -0
- coagent/gitx/log.py +85 -0
- coagent/gitx/resume.py +199 -0
- coagent/gitx/session.py +233 -0
- coagent/llm/__init__.py +83 -0
- coagent/llm/base.py +235 -0
- coagent/llm/grammar.py +306 -0
- coagent/llm/registry.py +147 -0
- coagent/llm/transformers_backend.py +326 -0
- coagent/py.typed +0 -0
- coagent/safety/__init__.py +24 -0
- coagent/safety/denylist.py +146 -0
- coagent/safety/policy.py +219 -0
- coagent/safety/redact.py +146 -0
- coagent/templates/config.toml +104 -0
- coagent/tools/__init__.py +155 -0
- coagent/tools/artifacts.py +307 -0
- coagent/tools/base.py +226 -0
- coagent/tools/fs.py +507 -0
- coagent/tools/notebooks.py +875 -0
- coagent/tools/pyt.py +240 -0
- coagent/tools/shell.py +558 -0
- coagent/ui/__init__.py +50 -0
- coagent/ui/gradio_app.py +353 -0
- coagent/ui/notebook.py +342 -0
- 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)
|