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/ui/render.py ADDED
@@ -0,0 +1,422 @@
1
+ """Transcript rendering: the text the chat cell shows, built as data (§8.1).
2
+
3
+ Why this is separate from the widgets
4
+ -------------------------------------
5
+ `ipywidgets` cannot be exercised in a test, and the interesting decisions are not
6
+ widget decisions: which events become rows, how a tool call reads when collapsed,
7
+ how a diff is summarised, when the plan is worth re-rendering. All of that is a
8
+ pure function of the event stream, so it lives here and is tested directly. The
9
+ widget layer in `notebook.py` is then thin enough to read in one sitting.
10
+
11
+ The rendering targets §8.1's layout::
12
+
13
+ coAgent · Qwen2.5-Coder-7B · 12k ctx · session a1b2c3
14
+ ────────────────────────────────────────────────────────
15
+ You: add a chunked CSV loader and test dtype preservation
16
+
17
+ ▸ plan 4 steps, est. 6 min [approve] [edit]
18
+ ▸ step 1 read src/loader.py, src/frame.py 1.2s
19
+ ▸ step 2 edit src/loader.py +18 −3 0.4s
20
+ ▸ step 3 pytest -q → 1 failed 2.1s
21
+ ✓ committed a1b2c3d · artifacts none
22
+
23
+ Tool rows are *collapsed* `<details>` elements: one line when closed, args and
24
+ truncated output when opened. That is what keeps a 40-step run readable on a
25
+ phone-width Colab pane without hiding the evidence.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import html
31
+ import re
32
+ from dataclasses import dataclass, field
33
+ from typing import Any
34
+
35
+ __all__ = [
36
+ "Row",
37
+ "Transcript",
38
+ "MODE_GLYPH",
39
+ "render_status_line",
40
+ "tool_summary",
41
+ "shorten_path",
42
+ "summarise_verify",
43
+ ]
44
+
45
+ #: One glyph per event kind, so a run scans vertically.
46
+ MODE_GLYPH: dict[str, str] = {
47
+ "plan": "▸",
48
+ "plan_rejected": "✗",
49
+ "tool": "▸",
50
+ "verify": "▸",
51
+ "commit": "✓",
52
+ "sync": "⇡",
53
+ "artifact": "◈",
54
+ "error": "✗",
55
+ "info": "·",
56
+ }
57
+
58
+ #: An event kind's severity, for colouring and for "is this a stop?".
59
+ _SEVERITY = {
60
+ "commit": "good",
61
+ "sync": "good",
62
+ "artifact": "good",
63
+ "verify": "neutral",
64
+ "tool": "neutral",
65
+ "error": "bad",
66
+ "plan_rejected": "bad",
67
+ "denied": "bad",
68
+ "commit_failed": "bad",
69
+ "envelope_error": "warn",
70
+ "replan": "warn",
71
+ "truncated": "warn",
72
+ "repo_map_degraded": "warn",
73
+ "retrieval_degraded": "warn",
74
+ "artifact_manifest_degraded": "warn",
75
+ "final_checkpoint_failed": "warn",
76
+ "verify_failed_after_complete": "warn",
77
+ }
78
+
79
+ #: Long output kept in a collapsed block. The full text is in the session log;
80
+ #: this is the evidence a human needs, not the whole stream (§5.1).
81
+ MAX_ROW_OUTPUT = 1600
82
+
83
+
84
+ @dataclass
85
+ class Row:
86
+ """One line of the transcript, plus the details hidden behind it."""
87
+
88
+ kind: str
89
+ summary: str
90
+ body: str = ""
91
+ severity: str = "neutral"
92
+ #: Seconds, when the event knows how long it took.
93
+ duration_s: float | None = None
94
+ #: Extra metadata for a renderer that wants to be smarter than text.
95
+ meta: dict[str, Any] = field(default_factory=dict)
96
+
97
+ @property
98
+ def glyph(self) -> str:
99
+ return MODE_GLYPH.get(self.kind, "·")
100
+
101
+ @property
102
+ def collapsible(self) -> bool:
103
+ return bool(self.body.strip())
104
+
105
+ def headline(self) -> str:
106
+ """The one line shown when the row is collapsed."""
107
+ parts = [f"{self.glyph} {self.summary}"]
108
+ if self.duration_s is not None:
109
+ parts.append(f"{self.duration_s:.1f}s")
110
+ return " ".join(parts)
111
+
112
+ def render_markdown(self) -> str:
113
+ line = self.headline()
114
+ if not self.collapsible:
115
+ # No nested emphasis: the summary already carries its own markers, and
116
+ # wrapping it again renders as literal `**` in a notebook.
117
+ return line if "**" in line else f"`{line}`"
118
+ closed = "<code>" + html.escape(line) + "</code>"
119
+ return f"<details><summary>{closed}</summary>\n\n```\n{self.body}\n```\n\n</details>"
120
+
121
+
122
+ class Transcript:
123
+ """The chat cell's whole visible state, as data.
124
+
125
+ Events arrive from `coAgent.on_event`; each one either appends a row or
126
+ updates the plan/status. `render()` is called far more often than events
127
+ arrive (a widget update per streamed chunk), so it must stay cheap and stable
128
+ — no timestamps, no counters that drift between identical renders.
129
+ """
130
+
131
+ def __init__(self, *, max_rows: int = 200) -> None:
132
+ self.rows: list[Row] = []
133
+ self.max_rows = max_rows
134
+ self.task = ""
135
+ self.plan_title = ""
136
+ self.plan_steps: list[str] = []
137
+ self.plan_decided = False
138
+ self.artifacts: list[str] = []
139
+ self.final = ""
140
+ self.status = ""
141
+
142
+ # ------------------------------------------------------------- events
143
+
144
+ def handle(self, kind: str, data: dict[str, Any]) -> None:
145
+ """Fold one loop event into the transcript."""
146
+ if kind == "task_start":
147
+ self.task = str(data.get("task", ""))
148
+ self.status = "running"
149
+ return
150
+ if kind == "task_end":
151
+ self.status = "done" if data.get("done") else "stopped"
152
+ return
153
+ if kind == "plan":
154
+ self.plan_title = str(data.get("title", ""))
155
+ self.plan_steps = [str(s) for s in data.get("steps", [])]
156
+ steps = ", ".join(f"`{s}`" for s in self.plan_steps[:4])
157
+ self._add(
158
+ Row(
159
+ "plan",
160
+ f"**plan** {len(self.plan_steps)} steps — {self.plan_title}",
161
+ body="\n".join(f"[ ] {s}" for s in self.plan_steps) or steps,
162
+ )
163
+ )
164
+ return
165
+ if kind == "plan_rejected":
166
+ self.plan_decided = True
167
+ self._add(Row("plan_rejected", "plan rejected — nothing was written", severity="bad"))
168
+ return
169
+ if kind == "tool":
170
+ self._add(self._tool_row(data))
171
+ return
172
+ if kind == "verify":
173
+ ok = bool(data.get("ok"))
174
+ summary = summarise_verify(str(data.get("summary", "")))
175
+ self._add(
176
+ Row(
177
+ "verify",
178
+ f"**verify** {'passed' if ok else 'failed'} — {summary}",
179
+ body=str(data.get("summary", ""))[:MAX_ROW_OUTPUT],
180
+ severity="neutral" if ok else "bad",
181
+ )
182
+ )
183
+ return
184
+ if kind == "commit":
185
+ self._add(
186
+ Row(
187
+ "commit",
188
+ f"**committed** `{str(data.get('sha', ''))[:8]}` · step {data.get('step')}",
189
+ severity="good",
190
+ )
191
+ )
192
+ return
193
+ if kind == "sync":
194
+ ok = bool(data.get("ok"))
195
+ self._add(
196
+ Row(
197
+ "sync",
198
+ f"{'**checkpoint** ' if ok else '**checkpoint warning** '}"
199
+ f"{data.get('message', '')}",
200
+ severity="good" if ok else "warn",
201
+ )
202
+ )
203
+ return
204
+ if kind in {
205
+ "denied",
206
+ "commit_failed",
207
+ "plan_rejected",
208
+ "envelope_error",
209
+ "replan",
210
+ "truncated",
211
+ "repo_map_degraded",
212
+ "retrieval_degraded",
213
+ "artifact_manifest_degraded",
214
+ "final_checkpoint_failed",
215
+ "verify_failed_after_complete",
216
+ }:
217
+ self._add(self._diagnostic_row(kind, data))
218
+ return
219
+ if kind in {"survey", "resume"}:
220
+ notes = str(data.get("notes", "")).strip()
221
+ self._add(Row("info", f"**{kind}**", body=notes[:MAX_ROW_OUTPUT]))
222
+ return
223
+ # Unknown events are kept, not dropped: a new loop event should show up
224
+ # in the transcript the day it is added, not the day this file learns of it.
225
+ self._add(Row("info", f"**{kind}**", body=_format_data(data)))
226
+
227
+ def _tool_row(self, data: dict[str, Any]) -> Row:
228
+ tool = str(data.get("tool", "?"))
229
+ ok = bool(data.get("ok"))
230
+ body = str(data.get("content", ""))
231
+ summary = tool_summary(tool, body, ok=ok, path=str(data.get("path", "")))
232
+ return Row(
233
+ "tool" if ok else "error",
234
+ summary,
235
+ body=body[:MAX_ROW_OUTPUT],
236
+ severity="neutral" if ok else "bad",
237
+ meta={"tool": tool, "ok": ok},
238
+ )
239
+
240
+ def _diagnostic_row(self, kind: str, data: dict[str, Any]) -> Row:
241
+ label = kind.replace("_", " ")
242
+ detail = str(data.get("error") or data.get("corrective") or data.get("command") or "")
243
+ severity = _SEVERITY.get(kind, "warn")
244
+ summary = f"**{label}**" + (f" — {detail[:120]}" if detail else "")
245
+ return Row(kind, summary, body=_format_data(data), severity=severity)
246
+
247
+ def _add(self, row: Row) -> None:
248
+ self.rows.append(row)
249
+ if len(self.rows) > self.max_rows:
250
+ # Old rows are dropped from the *view*, never from the session log.
251
+ self.rows = self.rows[-self.max_rows :]
252
+
253
+ # ------------------------------------------------------------ artifacts
254
+
255
+ def note_artifacts(self, paths: list[str]) -> None:
256
+ for path in paths:
257
+ if path not in self.artifacts:
258
+ self.artifacts.append(path)
259
+ if paths:
260
+ self._add(
261
+ Row("artifact", "**artifacts** " + ", ".join(f"`{p}`" for p in paths), severity="good")
262
+ )
263
+
264
+ # -------------------------------------------------------------- render
265
+
266
+ def render(self, *, status_line: str | None = None, max_rows: int | None = None) -> str:
267
+ """The full markdown body for the chat cell."""
268
+ head = status_line if status_line is not None else self.status
269
+ blocks: list[str] = []
270
+ if head:
271
+ blocks.append(f"```\n{head}\n```")
272
+ if self.task:
273
+ blocks.append(f"**You:** {self.task}")
274
+ rows = self.rows[-max_rows:] if max_rows else self.rows
275
+ blocks.extend(row.render_markdown() for row in rows)
276
+ if self.final:
277
+ blocks.append(self.final)
278
+ return "\n\n".join(blocks) if blocks else "coAgent is idle."
279
+
280
+ def render_plan(self) -> str:
281
+ """The plan block alone, for a widget that updates in place."""
282
+ if not self.plan_steps:
283
+ return ""
284
+ lines = [f"**{self.plan_title}**"] if self.plan_title else []
285
+ lines.extend(f"- [ ] {step}" for step in self.plan_steps)
286
+ return "\n".join(lines)
287
+
288
+
289
+ # ------------------------------------------------------------------ helpers
290
+
291
+
292
+ def render_status_line(
293
+ *,
294
+ model_id: str,
295
+ ctx_tokens: int,
296
+ session_id: str,
297
+ budget: str = "",
298
+ gpu: str = "",
299
+ ) -> str:
300
+ """FR-UX-3: model, context, GPU, session, and budget, always visible.
301
+
302
+ Deliberately one line: it sits at the top of every update, so a second line
303
+ would cost vertical space in a Colab pane on every repaint.
304
+ """
305
+ parts = [f"coAgent · {model_id}", f"{ctx_tokens // 1000}k ctx"]
306
+ if gpu:
307
+ parts.append(gpu)
308
+ parts.append(f"session {session_id}")
309
+ if budget:
310
+ parts.append(budget)
311
+ return " · ".join(parts)
312
+
313
+
314
+ _DIFF_STAT = re.compile(r"^\+([0-9]+) [−-]([0-9]+)$")
315
+
316
+
317
+ def tool_summary(tool: str, content: str, *, ok: bool, path: str = "") -> str:
318
+ """One line describing a tool call, the way §8.1 shows it.
319
+
320
+ The point of collapsing is that the headline alone is informative: which file
321
+ was edited and by how much, which command ran and whether it passed.
322
+ """
323
+ if not ok:
324
+ first = _first_meaningful_line(content)
325
+ return f"**{tool}** failed — {first}" if first else f"**{tool}** failed"
326
+
327
+ if tool in {"edit_file", "edit_notebook", "write_file"}:
328
+ added = content.count("\n+")
329
+ removed = content.count("\n-")
330
+ # `--- a/...` and `+++ b/...` are headers, not content.
331
+ added = max(0, added - content.count("\n+++"))
332
+ removed = max(0, removed - content.count("\n---"))
333
+ label = _short_target(content) or path or tool
334
+ return f"**{tool}** {label} +{added} −{removed}" if added or removed else f"**{tool}** {label}"
335
+
336
+ if tool in {"read_file", "read_notebook", "list_dir"}:
337
+ # The path is the useful fact; the line count tells you how much came back.
338
+ lines = sum(1 for line in content.splitlines() if line.strip())
339
+ if path:
340
+ return f"**{tool}** {shorten_path(path)} · {lines} line(s)"
341
+ return f"**{tool}** {lines} line(s)"
342
+
343
+ if tool in {"run_shell", "run_python", "exec_notebook"}:
344
+ status = _first_match(content, r"^\[(.*)\]$") or ""
345
+ verdict = "ok" if ok else "failed"
346
+ detail = shorten_path(status) if status else verdict
347
+ return f"**{tool}** {detail}" if detail else f"**{tool}** {verdict}"
348
+
349
+ if tool in {"save_artifact", "display_artifact"}:
350
+ line = _first_meaningful_line(content)
351
+ return f"**{tool}** {line}" if line else f"**{tool}**"
352
+
353
+ if tool in {"grep"}:
354
+ count = _first_match(content, r"^no matches")
355
+ return f"**{tool}** {count}" if count else f"**{tool}** {_count_hits(content)} match(es)"
356
+
357
+ first = _first_meaningful_line(content)
358
+ return f"**{tool}** {first[:80]}" if first else f"**{tool}**"
359
+
360
+
361
+ def summarise_verify(summary: str) -> str:
362
+ """`pytest -q → 12 passed`, or the first failure line.
363
+
364
+ A verify summary is a blob of `$ cmd` + output. The transcript wants the
365
+ verdict, not the blob; the blob is in the collapsed body.
366
+ """
367
+ for line in summary.splitlines():
368
+ lowered = line.lower()
369
+ if any(word in lowered for word in ("failed", "error", "traceback")):
370
+ return line.strip()[:100]
371
+ for line in summary.splitlines():
372
+ if line.strip().startswith("$"):
373
+ return line.strip()[:100]
374
+ for line in reversed(summary.splitlines()):
375
+ if line.strip():
376
+ return line.strip()[:100]
377
+ return "no output"
378
+
379
+
380
+ def shorten_path(text: str, limit: int = 60) -> str:
381
+ """Middle-ellipsis a path so both the root and the filename survive."""
382
+ text = text.strip()
383
+ if len(text) <= limit:
384
+ return text
385
+ keep = max(10, (limit - 1) // 2)
386
+ return f"{text[:keep]}…{text[-keep:]}"
387
+
388
+
389
+ def _first_meaningful_line(text: str) -> str:
390
+ for line in text.splitlines():
391
+ stripped = line.strip()
392
+ if stripped and not stripped.startswith(("[", "$", "```")):
393
+ return stripped[:120]
394
+ return ""
395
+
396
+
397
+ def _first_match(text: str, pattern: str) -> str:
398
+ match = re.search(pattern, text, re.MULTILINE)
399
+ return match.group(0).strip() if match else ""
400
+
401
+
402
+ def _short_target(content: str) -> str:
403
+ match = re.search(r"^--- [ab]/(.+)$", content, re.MULTILINE)
404
+ return shorten_path(match.group(1)) if match else ""
405
+
406
+
407
+ def _count_hits(content: str) -> int:
408
+ if "matches=0" in content:
409
+ return 0
410
+ return sum(1 for line in content.splitlines() if re.match(r"^.+:\d+: ", line))
411
+
412
+
413
+ def _format_data(data: dict[str, Any]) -> str:
414
+ if not data:
415
+ return ""
416
+ lines = []
417
+ for key, value in data.items():
418
+ text = str(value)
419
+ if len(text) > 400:
420
+ text = text[:397] + "…"
421
+ lines.append(f"{key}: {text}")
422
+ return "\n".join(lines)