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
coagent/tools/shell.py ADDED
@@ -0,0 +1,558 @@
1
+ """Execution tools: shell and Python, isolated at the process level (FR-TOL-3).
2
+
3
+ There is no container on Colab, so isolation is process-level and the docs say
4
+ so. What this module guarantees:
5
+
6
+ * a scrubbed environment — tokens and API keys never reach a child process
7
+ (FR-TOL-5), and GPU visibility is hidden for verification runs (PRD D4);
8
+ * a timeout that kills the whole process group, not just the parent, so a
9
+ grandchild cannot outlive the turn;
10
+ * an output cap that keeps the head and the tail and elides the middle, because
11
+ the interesting parts of a build log are the invocation and the error;
12
+ * a policy gate, applied to the command string before anything is spawned.
13
+
14
+ `RunTestsTool` is a thin shell over `tools/pyt.py`: pytest output is parsed into
15
+ failing test names plus a bounded digest, because a 500-line pytest dump evicts
16
+ the context the model needs to fix the failure (DESIGN §7.5).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import contextlib
22
+ import os
23
+ import re
24
+ import signal
25
+ import subprocess
26
+ import sys
27
+ import tempfile
28
+ import time
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ from ..safety.policy import Policy, SafetyClass, Verdict
34
+ from .base import ArgSpec, BaseTool, ToolError, ToolResult
35
+ from .pyt import parse_pytest_output, summarise_pytest
36
+
37
+ __all__ = [
38
+ "ExecConfig",
39
+ "ExecOutcome",
40
+ "RunPythonTool",
41
+ "RunShellTool",
42
+ "RunTestsTool",
43
+ "run_subprocess",
44
+ "scrubbed_env",
45
+ ]
46
+
47
+ #: Environment names that must never reach a child process (FR-TOL-5).
48
+ #: Matched as a substring, case-insensitively, against the variable name.
49
+ _SECRET_MARKERS = (
50
+ "token",
51
+ "secret",
52
+ "password",
53
+ "passwd",
54
+ "api_key",
55
+ "apikey",
56
+ "access_key",
57
+ "private_key",
58
+ "credential",
59
+ "auth",
60
+ "session_key",
61
+ )
62
+
63
+ #: Names that look like secrets but are safe and needed to run anything.
64
+ _SECRET_ALLOWLIST = frozenset(
65
+ {
66
+ "SSH_AUTH_SOCK", # a socket path, not a secret
67
+ "GIT_AUTHOR_NAME",
68
+ "GIT_AUTHOR_EMAIL",
69
+ "GIT_COMMITTER_NAME",
70
+ "GIT_COMMITTER_EMAIL",
71
+ }
72
+ )
73
+
74
+ #: Separator inserted where output was elided.
75
+ _OMITTED = "\n... [{n} bytes omitted] ...\n"
76
+
77
+
78
+ @dataclass(slots=True)
79
+ class ExecConfig:
80
+ """Everything the exec layer needs, fixed once per session."""
81
+
82
+ repo_root: Path
83
+ max_output_bytes: int = 65536
84
+ timeout_s: int = 120
85
+ notebook_timeout_s: int = 300
86
+ #: PRD D4: verification is CPU-only, so CUDA is hidden by default.
87
+ hide_gpu: bool = True
88
+ #: Temp dir for generated scripts, outside the repo so it is never committed.
89
+ engine_dir: Path | None = None
90
+
91
+ def __post_init__(self) -> None:
92
+ self.repo_root = Path(self.repo_root)
93
+ if self.engine_dir is None:
94
+ self.engine_dir = self.repo_root / ".coagent" / "tmp"
95
+
96
+
97
+ @dataclass(slots=True)
98
+ class ExecOutcome:
99
+ """What a finished (or killed) process produced."""
100
+
101
+ returncode: int
102
+ output: str
103
+ truncated: bool = False
104
+ timed_out: bool = False
105
+ duration_s: float = 0.0
106
+
107
+ @property
108
+ def ok(self) -> bool:
109
+ return self.returncode == 0 and not self.timed_out
110
+
111
+
112
+ def scrubbed_env(cfg: ExecConfig, extra: dict[str, str] | None = None) -> dict[str, str]:
113
+ """The environment a child process sees: no secrets, bounded GPU visibility.
114
+
115
+ Secrets are removed rather than blanked, so a child that checks for the
116
+ variable's presence sees the truth: it is not available (FR-TOL-5).
117
+ """
118
+ env: dict[str, str] = {}
119
+ for name, value in os.environ.items():
120
+ if name in _SECRET_ALLOWLIST:
121
+ env[name] = value
122
+ continue
123
+ lowered = name.lower()
124
+ if any(marker in lowered for marker in _SECRET_MARKERS):
125
+ continue
126
+ env[name] = value
127
+
128
+ # Keep the repo importable without an install, and stop Python writing
129
+ # .pyc files into a tree the agent is about to diff.
130
+ existing = env.get("PYTHONPATH", "")
131
+ root = str(cfg.repo_root)
132
+ env["PYTHONPATH"] = f"{root}{os.pathsep}{existing}" if existing else root
133
+ env.setdefault("PYTHONDONTWRITEBYTECODE", "1")
134
+ env.setdefault("PYTHONUNBUFFERED", "1")
135
+ env.setdefault("TERM", "dumb")
136
+ env.setdefault("NO_COLOR", "1")
137
+ # A stray TERM/COLUMNS makes tools emit progress bars and ANSI codes.
138
+ env.pop("COLUMNS", None)
139
+ env.pop("LINES", None)
140
+
141
+ if cfg.hide_gpu:
142
+ env["CUDA_VISIBLE_DEVICES"] = ""
143
+ else:
144
+ # Only expose the first device: the T4 is a single-GPU box.
145
+ env.setdefault("CUDA_VISIBLE_DEVICES", "0")
146
+
147
+ if extra:
148
+ env.update(extra)
149
+ return env
150
+
151
+
152
+ def run_subprocess(
153
+ argv: list[str],
154
+ *,
155
+ cfg: ExecConfig,
156
+ cwd: Path | str | None = None,
157
+ env: dict[str, str] | None = None,
158
+ timeout_s: int | float | None = None,
159
+ stdin_text: str | None = None,
160
+ ) -> ExecOutcome:
161
+ """Run `argv` in its own process group with a scrubbed env and an output cap.
162
+
163
+ The process group matters: `pytest` spawns workers, and killing only the
164
+ parent leaves orphans holding the pipe open, which turns a 1s timeout into
165
+ a hang until the workers exit on their own.
166
+ """
167
+ cwd_path = Path(cwd) if cwd is not None else cfg.repo_root
168
+ effective_timeout = float(timeout_s if timeout_s is not None else cfg.timeout_s)
169
+ limit = cfg.max_output_bytes
170
+
171
+ started = time.monotonic()
172
+ popen_kwargs: dict[str, Any] = {
173
+ "cwd": str(cwd_path),
174
+ "env": env if env is not None else scrubbed_env(cfg),
175
+ "stdin": subprocess.PIPE if stdin_text is not None else subprocess.DEVNULL,
176
+ "stdout": subprocess.PIPE,
177
+ "stderr": subprocess.STDOUT,
178
+ "text": True,
179
+ "errors": "replace",
180
+ }
181
+ if os.name == "posix":
182
+ popen_kwargs["start_new_session"] = True
183
+ else: # pragma: no cover - exercised on Windows only
184
+ popen_kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
185
+
186
+ try:
187
+ proc = subprocess.Popen(argv, **popen_kwargs) # noqa: S603 - argv is policy-gated
188
+ except FileNotFoundError as exc:
189
+ return ExecOutcome(
190
+ returncode=127,
191
+ output=f"{argv[0]}: command not found ({exc.strerror or 'no such file'})",
192
+ duration_s=time.monotonic() - started,
193
+ )
194
+ except OSError as exc:
195
+ return ExecOutcome(
196
+ returncode=126,
197
+ output=f"failed to start {argv[0]}: {exc}",
198
+ duration_s=time.monotonic() - started,
199
+ )
200
+
201
+ timed_out = False
202
+ try:
203
+ raw, _ = proc.communicate(input=stdin_text, timeout=effective_timeout)
204
+ except subprocess.TimeoutExpired:
205
+ timed_out = True
206
+ _kill_group(proc)
207
+ try:
208
+ raw, _ = proc.communicate(timeout=5)
209
+ except subprocess.TimeoutExpired: # pragma: no cover - unkillable child
210
+ raw = ""
211
+ raw = (raw or "") + f"\n[timeout after {effective_timeout:.0f}s; process group killed]"
212
+
213
+ duration = time.monotonic() - started
214
+ text, truncated = _cap_output(raw or "", limit)
215
+ return ExecOutcome(
216
+ returncode=proc.returncode if proc.returncode is not None else -1,
217
+ output=text,
218
+ truncated=truncated,
219
+ timed_out=timed_out,
220
+ duration_s=duration,
221
+ )
222
+
223
+
224
+ def _kill_group(proc: subprocess.Popen[str]) -> None:
225
+ """Kill the process and everything it spawned."""
226
+ if proc.poll() is not None:
227
+ return
228
+ try:
229
+ if os.name == "posix":
230
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
231
+ else: # pragma: no cover - Windows
232
+ proc.kill()
233
+ except (ProcessLookupError, PermissionError, OSError):
234
+ with contextlib.suppress(OSError):
235
+ proc.kill()
236
+
237
+
238
+ def _cap_output(text: str, limit: int) -> tuple[str, bool]:
239
+ """Keep the head and the tail, elide the middle.
240
+
241
+ The head says what ran; the tail says what went wrong. The middle of a build
242
+ log is progress noise, so it is the cheapest thing to drop.
243
+ """
244
+ if limit <= 0 or len(text) <= limit:
245
+ return text, False
246
+ head_len = limit // 3
247
+ tail_len = limit - head_len
248
+ head = text[:head_len]
249
+ tail = text[-tail_len:]
250
+ omitted = len(text) - head_len - tail_len
251
+ return head + _OMITTED.format(n=omitted) + tail, True
252
+
253
+
254
+ class _ExecTool(BaseTool):
255
+ """Shared gate and result shaping for the three exec tools."""
256
+
257
+ #: Running anything is EXEC, never READ: SURVEY must not be able to spawn a
258
+ #: process, so the class is declared once here rather than defaulted.
259
+ safety = SafetyClass.EXEC
260
+
261
+ def __init__(self, cfg: ExecConfig, policy: Policy) -> None:
262
+ super().__init__(cfg)
263
+ self.cfg = cfg
264
+ self.policy = policy
265
+
266
+ def _gate(self, cmd: str) -> None:
267
+ """Deny or ask before anything is spawned (FR-TOL-4)."""
268
+ decision = self.policy.check_shell(cmd)
269
+ if decision.verdict is Verdict.DENY:
270
+ raise ToolError(f"denied: {decision.reason}")
271
+ if decision.verdict is Verdict.ASK:
272
+ # In non-interactive mode ASK resolves to deny, so the message is the
273
+ # same shape either way: say what would unblock it.
274
+ raise ToolError(f"needs approval: {decision.reason} ({cmd.split()[0]})")
275
+
276
+ def _result(self, outcome: ExecOutcome, label: str) -> ToolResult:
277
+ content = outcome.output
278
+ if not content.strip():
279
+ content = f"{label}: no output"
280
+ if outcome.timed_out:
281
+ return ToolResult.failure(
282
+ f"timeout: {label} exceeded {self.cfg.timeout_s}s and was killed\n{content}".strip(),
283
+ timed_out=True,
284
+ returncode=outcome.returncode,
285
+ duration_s=outcome.duration_s,
286
+ truncated=outcome.truncated,
287
+ )
288
+ if outcome.returncode != 0:
289
+ return ToolResult.failure(
290
+ f"exit {outcome.returncode}: {label}\n{content}".strip(),
291
+ returncode=outcome.returncode,
292
+ timed_out=False,
293
+ duration_s=outcome.duration_s,
294
+ truncated=outcome.truncated,
295
+ )
296
+ return ToolResult.success(
297
+ content,
298
+ returncode=0,
299
+ timed_out=False,
300
+ duration_s=outcome.duration_s,
301
+ truncated=outcome.truncated,
302
+ )
303
+
304
+
305
+ class RunShellTool(_ExecTool):
306
+ """Run one command in the repo.
307
+
308
+ Both an argv list and a shell string are accepted. argv is preferred because
309
+ it needs no quoting rules; the string form is what the model usually emits,
310
+ so it is normalized back to argv rather than handed to a shell.
311
+ """
312
+
313
+ name = "run_shell"
314
+ description = (
315
+ "Run a shell command in the repository. Prefer argv (a list of words) over a "
316
+ "command string: it avoids quoting bugs. Use for linters, formatters, git "
317
+ "inspection and build steps. Not for installing packages without approval."
318
+ )
319
+ args = (
320
+ ArgSpec(
321
+ "argv",
322
+ "array",
323
+ "Command as a list of words, e.g. ['pytest', '-q']. Required unless command is given.",
324
+ required=False,
325
+ ),
326
+ ArgSpec(
327
+ "command",
328
+ "string",
329
+ "Command as one string, e.g. 'pytest -q'. Required unless argv is given.",
330
+ required=False,
331
+ ),
332
+ ArgSpec(
333
+ "timeout_s",
334
+ "integer",
335
+ "Seconds before the process group is killed. Defaults to the session timeout.",
336
+ required=False,
337
+ ),
338
+ )
339
+
340
+ def run(
341
+ self,
342
+ argv: list[str] | None = None,
343
+ command: str | None = None,
344
+ timeout_s: int | None = None,
345
+ ) -> ToolResult:
346
+ cmd_argv = _resolve_argv(argv, command)
347
+ self._gate(" ".join(cmd_argv))
348
+ outcome = run_subprocess(
349
+ cmd_argv,
350
+ cfg=self.cfg,
351
+ timeout_s=timeout_s if timeout_s else None,
352
+ )
353
+ return self._result(outcome, " ".join(cmd_argv))
354
+
355
+
356
+ class RunTestsTool(_ExecTool):
357
+ """Run the verify command and return failing test names, not a wall of text.
358
+
359
+ The digest caps at `feedback_max_tokens` (approximated as 4 chars/token) so
360
+ the model gets the verdict, the failing node ids, and the last evidence for
361
+ each — without the passing-test noise (DESIGN §7.5).
362
+ """
363
+
364
+ name = "run_tests"
365
+ description = (
366
+ "Run the test suite (or one test file) and get the failures back as a short "
367
+ "digest: pass/fail verdict, failing test names, and the error for each. Use "
368
+ "this instead of run_shell when you want pytest output you can act on."
369
+ )
370
+ args = (
371
+ ArgSpec(
372
+ "command",
373
+ "string",
374
+ "Test command, e.g. 'pytest -q' or 'pytest tests/test_x.py::test_y'.",
375
+ required=False,
376
+ default="pytest -q",
377
+ ),
378
+ ArgSpec(
379
+ "argv",
380
+ "array",
381
+ "Command as a list of words. Prefer this when the command is already tokenized, "
382
+ "e.g. from [verify] commands in config.",
383
+ required=False,
384
+ ),
385
+ ArgSpec(
386
+ "max_tokens",
387
+ "integer",
388
+ "Digest budget in tokens (approximate). Defaults to the verify config.",
389
+ required=False,
390
+ ),
391
+ )
392
+
393
+ def __init__(
394
+ self,
395
+ cfg: ExecConfig,
396
+ policy: Policy,
397
+ *,
398
+ verify_commands: list[str] | None = None,
399
+ feedback_max_tokens: int = 1500,
400
+ ) -> None:
401
+ super().__init__(cfg, policy)
402
+ self.verify_commands = list(verify_commands or ["pytest -q"])
403
+ self.feedback_max_tokens = feedback_max_tokens
404
+
405
+ def run(
406
+ self,
407
+ command: str = "pytest -q",
408
+ argv: list[str] | None = None,
409
+ max_tokens: int | None = None,
410
+ ) -> ToolResult:
411
+ # argv wins when given: a tokenized command (as config carries it) must
412
+ # not be re-split, or a quoted interpreter path loses its meaning.
413
+ cmd_argv = _resolve_argv(argv, command)
414
+ label = " ".join(cmd_argv)
415
+ self._gate(label)
416
+
417
+ budget = int(max_tokens) if max_tokens else self.feedback_max_tokens
418
+ cap_chars = max(200, budget * 4)
419
+
420
+ outcome = run_subprocess(cmd_argv, cfg=self.cfg)
421
+ parsed = parse_pytest_output(outcome.output)
422
+ digest = summarise_pytest(outcome.output, cap=cap_chars)
423
+
424
+ meta: dict[str, Any] = {
425
+ "returncode": outcome.returncode,
426
+ "timed_out": outcome.timed_out,
427
+ "duration_s": outcome.duration_s,
428
+ "truncated": outcome.truncated,
429
+ "failures": parsed.failed_names,
430
+ "failed": parsed.failures,
431
+ "passed": parsed.passed,
432
+ }
433
+ if outcome.returncode == 0 and not outcome.timed_out and not parsed.failures:
434
+ # Keep the output when there is any: a passing lint or a script that
435
+ # prints a count is information, and "tests passed" would discard it.
436
+ return ToolResult.success(digest or "tests passed", **meta)
437
+ if outcome.timed_out:
438
+ return ToolResult.failure(
439
+ f"timeout: tests exceeded {self.cfg.timeout_s}s and were killed\n{digest}".strip(),
440
+ **meta,
441
+ )
442
+ return ToolResult.failure(digest or f"exit {outcome.returncode}", **meta)
443
+
444
+
445
+ class RunPythonTool(_ExecTool):
446
+ """Run a Python snippet in a fresh interpreter inside the repo.
447
+
448
+ A fresh process, not the agent's own: `sys.exit` in generated code would
449
+ otherwise end the session, and module-level state would leak between turns.
450
+ """
451
+
452
+ name = "run_python"
453
+ description = (
454
+ "Run a short Python snippet in a fresh interpreter with the repo importable. "
455
+ "Use for quick checks, printing values, or exercising a function. The process "
456
+ "is killed on timeout; nothing it prints is lost."
457
+ )
458
+ safety = SafetyClass.EXEC
459
+ args = (
460
+ ArgSpec(
461
+ "code",
462
+ "string",
463
+ "Python source to execute.",
464
+ required=True,
465
+ ),
466
+ ArgSpec(
467
+ "timeout_s",
468
+ "integer",
469
+ "Seconds before the process group is killed. Defaults to the session timeout.",
470
+ required=False,
471
+ ),
472
+ )
473
+
474
+ def run(self, code: str, timeout_s: int | None = None) -> ToolResult:
475
+ if not code.strip():
476
+ raise ToolError("run_python needs non-empty code")
477
+ # Gate on the source too: generated code that shells out must be reviewed.
478
+ self._gate(code)
479
+ outcome = run_subprocess(
480
+ [sys.executable, "-c", code],
481
+ cfg=self.cfg,
482
+ timeout_s=timeout_s if timeout_s else None,
483
+ )
484
+ return self._result(outcome, "python snippet")
485
+
486
+
487
+ def _resolve_argv(argv: list[str] | None, command: str | None) -> list[str]:
488
+ """Normalize argv/command to one argv list, or explain what was missing."""
489
+ if argv:
490
+ words = [str(a) for a in argv if str(a) != ""]
491
+ if words:
492
+ return words
493
+ if command and command.strip():
494
+ return _split_command(command)
495
+ raise ToolError("provide either argv or command")
496
+
497
+
498
+ def _split_command(command: str) -> list[str]:
499
+ """Split a command string with shell-like quoting, but no shell features.
500
+
501
+ Deliberately no pipes, redirects or `&&`: those need a shell, and handing a
502
+ shell a string the model wrote is the one thing this layer exists to avoid.
503
+
504
+ `posix=True` is the default because there is no shell here, so the only thing
505
+ posix mode changes is quote handling: non-posix keeps the quotes and turns
506
+ `python -c "print(7)"` into a program whose source is literally `"print(7)"`.
507
+ On Windows, however, posix mode eats backslashes in `C:\\Python\\python.exe`,
508
+ so the first token is re-checked against the raw string and the non-posix
509
+ split wins when posix mangled an existing path.
510
+ """
511
+ try:
512
+ import shlex
513
+
514
+ words = shlex.split(command, posix=True)
515
+ except ValueError as exc:
516
+ raise ToolError(f"could not parse command {command!r}: {exc}") from None
517
+
518
+ if words and os.name == "nt":
519
+ repaired = _repair_windows_exe(words[0], command)
520
+ if repaired is not None:
521
+ words[0] = repaired
522
+ return [
523
+ w[1:-1] if len(w) > 1 and w[0] == w[-1] == '"' else w
524
+ for w in words
525
+ ]
526
+
527
+
528
+ def _repair_windows_exe(token: str, command: str) -> str | None:
529
+ """Recover an unquoted `C:\\Python\\python.exe` that posix splitting mangled.
530
+
531
+ posix mode treats the backslashes as escapes, so a bare Windows path loses
532
+ them. When the raw first word exists on disk and the split token does not,
533
+ the raw word is the truth. This runs only on Windows and only for a token
534
+ that looks like an executable, so it cannot silently rewrite an odd command.
535
+ """
536
+ raw_first = command.strip().split(" ", 1)[0].strip('"')
537
+ if ".exe" not in raw_first.lower():
538
+ return None
539
+ if Path(token).exists() or not Path(raw_first).exists():
540
+ return None
541
+ return raw_first
542
+
543
+
544
+ #: Matches an ANSI escape sequence, so captured output stays readable in a diff.
545
+ _ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
546
+
547
+
548
+ def strip_ansi(text: str) -> str: # pragma: no cover - convenience helper
549
+ return _ANSI.sub("", text)
550
+
551
+
552
+ def write_scratch(cfg: ExecConfig, suffix: str = ".py") -> Path:
553
+ """A temp path under the scratch dir, never inside the tracked tree."""
554
+ scratch = Path(cfg.engine_dir or (cfg.repo_root / ".coagent" / "tmp"))
555
+ scratch.mkdir(parents=True, exist_ok=True)
556
+ fd, name = tempfile.mkstemp(suffix=suffix, dir=str(scratch))
557
+ os.close(fd)
558
+ return Path(name)
coagent/ui/__init__.py ADDED
@@ -0,0 +1,50 @@
1
+ """The notebook surface (DESIGN §8.1).
2
+
3
+ `render.py` turns the loop's event stream into a transcript as plain data;
4
+ `notebook.py` draws it in a Colab cell and collects approvals. The split exists so
5
+ that the interesting logic can be tested without a browser, and so the CLI can
6
+ reuse the same transcript without widgets.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .render import (
12
+ MODE_GLYPH,
13
+ Row,
14
+ Transcript,
15
+ render_status_line,
16
+ shorten_path,
17
+ summarise_verify,
18
+ tool_summary,
19
+ )
20
+
21
+ __all__ = [
22
+ "ChatCell",
23
+ "GradioApp",
24
+ "GradioUnavailable",
25
+ "MODE_GLYPH",
26
+ "Row",
27
+ "Transcript",
28
+ "gradio_available",
29
+ "render_status_line",
30
+ "shorten_path",
31
+ "summarise_verify",
32
+ "tool_summary",
33
+ ]
34
+
35
+
36
+ def __getattr__(name: str):
37
+ """Import the widget surfaces lazily so `render` stays usable headless.
38
+
39
+ Both surfaces pull in a different optional dependency (ipywidgets, gradio);
40
+ neither should be needed to import the transcript that renders them.
41
+ """
42
+ if name == "ChatCell":
43
+ from .notebook import ChatCell
44
+
45
+ return ChatCell
46
+ if name in {"GradioApp", "GradioUnavailable", "gradio_available"}:
47
+ from . import gradio_app
48
+
49
+ return getattr(gradio_app, name)
50
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")