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
coagent/ui/gradio_app.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Optional Gradio surface for coAgent (DESIGN §8.3).
|
|
2
|
+
|
|
3
|
+
Scope, deliberately small: this is a *nicety*. The notebook UI (§8.1) must be
|
|
4
|
+
good enough that the product works without Gradio, so nothing here is required
|
|
5
|
+
by the loop and every import of `gradio` is guarded.
|
|
6
|
+
|
|
7
|
+
Served inside Colab with::
|
|
8
|
+
|
|
9
|
+
from google.colab.output import serve_kernel_port_as_window
|
|
10
|
+
app = GradioApp()
|
|
11
|
+
app.launch() # starts on cfg.ui.gradio_port
|
|
12
|
+
serve_kernel_port_as_window(app.port) # opens it in the Colab tab
|
|
13
|
+
|
|
14
|
+
`share=True` and ngrok are out of scope by design: flaky, rate-limited, and a
|
|
15
|
+
security surface nobody asked for. A bare `server_name="127.0.0.1"` is the whole
|
|
16
|
+
of the exposure model.
|
|
17
|
+
|
|
18
|
+
The interesting logic lives in `ui/render.py` (`Transcript`, `Row`); this module
|
|
19
|
+
is the thin widget layer over it, which is why it is the only file that imports
|
|
20
|
+
Gradio and the only file without unit tests beyond the guarded-import path.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import contextlib
|
|
26
|
+
import threading
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from .render import Transcript, render_status_line, summarise_verify
|
|
31
|
+
|
|
32
|
+
__all__ = ["GradioApp", "GradioUnavailable", "gradio_available"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GradioUnavailable(RuntimeError):
|
|
36
|
+
"""Gradio is not installed. The message says how to get it."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def gradio_available() -> bool:
|
|
40
|
+
"""True when `gradio` can be imported. Cheap enough to call per launch."""
|
|
41
|
+
try:
|
|
42
|
+
import gradio # noqa: F401
|
|
43
|
+
except Exception: # noqa: BLE001 - any import failure means "not available"
|
|
44
|
+
return False
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class _PendingApproval:
|
|
50
|
+
"""One approval request waiting on a human, plus the handles to resolve it."""
|
|
51
|
+
|
|
52
|
+
request: Any
|
|
53
|
+
decision: threading.Event = field(default_factory=threading.Event)
|
|
54
|
+
approved: bool = False
|
|
55
|
+
decided: bool = False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _require_gradio():
|
|
59
|
+
try:
|
|
60
|
+
import gradio as gr
|
|
61
|
+
except Exception as exc: # noqa: BLE001
|
|
62
|
+
raise GradioUnavailable(
|
|
63
|
+
"the Gradio UI needs the optional dependency:\n"
|
|
64
|
+
" pip install 'coagent[ui]' # or: pip install gradio\n"
|
|
65
|
+
"The notebook surface (coagent.ui.ChatCell) works without it."
|
|
66
|
+
) from exc
|
|
67
|
+
return gr
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class GradioApp:
|
|
71
|
+
"""A chat pane, a transcript, and an approval queue, on one port.
|
|
72
|
+
|
|
73
|
+
Usage::
|
|
74
|
+
|
|
75
|
+
app = GradioApp(agent=agent) # or: app = GradioApp(); app.attach(agent)
|
|
76
|
+
app.launch()
|
|
77
|
+
app.chat("add a chunked CSV loader") # from a notebook cell, optionally
|
|
78
|
+
|
|
79
|
+
The agent is optional at construction because the usual pattern in a Colab
|
|
80
|
+
cell is to build the UI first and wire the agent in once the model is loaded.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
*,
|
|
86
|
+
agent: Any = None,
|
|
87
|
+
port: int | None = None,
|
|
88
|
+
title: str = "coAgent",
|
|
89
|
+
max_rows: int = 200,
|
|
90
|
+
approval_timeout_s: float = 120.0,
|
|
91
|
+
) -> None:
|
|
92
|
+
self.gr = _require_gradio()
|
|
93
|
+
self.transcript = Transcript(max_rows=max_rows)
|
|
94
|
+
self.agent = agent
|
|
95
|
+
self.title = title
|
|
96
|
+
self.approval_timeout_s = approval_timeout_s
|
|
97
|
+
self._pending: _PendingApproval | None = None
|
|
98
|
+
self._lock = threading.Lock()
|
|
99
|
+
self._running = False
|
|
100
|
+
|
|
101
|
+
# The port comes from config when not passed: ui.gradio_port in config.toml.
|
|
102
|
+
self.port = int(port) if port else self._port_from_config(agent)
|
|
103
|
+
|
|
104
|
+
self._ui: Any = None
|
|
105
|
+
self.block = None
|
|
106
|
+
|
|
107
|
+
# ------------------------------------------------------------- config
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def _port_from_config(agent: Any) -> int:
|
|
111
|
+
cfg = getattr(agent, "cfg", None)
|
|
112
|
+
ui = getattr(cfg, "ui", None) if cfg is not None else None
|
|
113
|
+
return int(getattr(ui, "gradio_port", 7860) or 7860)
|
|
114
|
+
|
|
115
|
+
# -------------------------------------------------------------- agent
|
|
116
|
+
|
|
117
|
+
def attach(self, agent: Any) -> None:
|
|
118
|
+
"""Bind the agent and take over its event and approval hooks.
|
|
119
|
+
|
|
120
|
+
The hooks are replaced rather than chained because two surfaces drawing
|
|
121
|
+
the same run is a rare intent and always the wrong one: the transcript
|
|
122
|
+
would render every event twice.
|
|
123
|
+
"""
|
|
124
|
+
self.agent = agent
|
|
125
|
+
agent.on_event = self.on_event
|
|
126
|
+
agent.renderer = self.render_artifact
|
|
127
|
+
agent.approver = self.approver
|
|
128
|
+
if self.port == 7860:
|
|
129
|
+
self.port = self._port_from_config(agent)
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------- events
|
|
132
|
+
|
|
133
|
+
def on_event(self, kind: str, data: dict[str, Any]) -> None:
|
|
134
|
+
"""`coAgent(on_event=app.on_event)`. Never raises into the loop."""
|
|
135
|
+
try:
|
|
136
|
+
self.transcript.handle(kind, data)
|
|
137
|
+
if kind == "task_end":
|
|
138
|
+
self._running = False
|
|
139
|
+
self._clear_pending()
|
|
140
|
+
self._refresh()
|
|
141
|
+
except Exception as exc: # noqa: BLE001 - a broken UI must not end the run
|
|
142
|
+
print(f"[ui] could not update the Gradio transcript: {exc}")
|
|
143
|
+
|
|
144
|
+
def render_artifact(self, kind: str, payload: str, mime: str) -> None:
|
|
145
|
+
"""`coAgent(renderer=app.render_artifact)`.
|
|
146
|
+
|
|
147
|
+
Gradio can show an image or a file but has no inline "artifact" concept,
|
|
148
|
+
so the transcript records the path and the notebook cell stays the place
|
|
149
|
+
where artifacts are actually displayed. This keeps the two surfaces
|
|
150
|
+
honest about their different capabilities instead of faking parity.
|
|
151
|
+
"""
|
|
152
|
+
self.transcript.note_artifacts([payload] if payload else [])
|
|
153
|
+
self._refresh()
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------- approvals
|
|
156
|
+
|
|
157
|
+
def approver(self, request: Any) -> bool:
|
|
158
|
+
"""Block the loop's thread until a button is pressed, or time out to deny.
|
|
159
|
+
|
|
160
|
+
Timeout denies, matching the policy's non-interactive default: the safe
|
|
161
|
+
answer to "nobody is looking" is no (DESIGN §9.1).
|
|
162
|
+
"""
|
|
163
|
+
pending = _PendingApproval(request=request)
|
|
164
|
+
with self._lock:
|
|
165
|
+
self._pending = pending
|
|
166
|
+
self._refresh()
|
|
167
|
+
got = pending.decision.wait(timeout=self.approval_timeout_s)
|
|
168
|
+
with self._lock:
|
|
169
|
+
if self._pending is pending:
|
|
170
|
+
self._pending = None
|
|
171
|
+
self._refresh()
|
|
172
|
+
return bool(got and pending.approved)
|
|
173
|
+
|
|
174
|
+
def _resolve(self, approved: bool) -> None:
|
|
175
|
+
with self._lock:
|
|
176
|
+
pending = self._pending
|
|
177
|
+
if pending is None:
|
|
178
|
+
return
|
|
179
|
+
pending.approved = approved
|
|
180
|
+
pending.decided = True
|
|
181
|
+
self._pending = None
|
|
182
|
+
pending.decision.set()
|
|
183
|
+
|
|
184
|
+
def _clear_pending(self) -> None:
|
|
185
|
+
with self._lock:
|
|
186
|
+
pending = self._pending
|
|
187
|
+
self._pending = None
|
|
188
|
+
if pending is not None:
|
|
189
|
+
# Unblock the loop thread rather than leaving it waiting on a run
|
|
190
|
+
# that has already ended.
|
|
191
|
+
pending.approved = False
|
|
192
|
+
pending.decision.set()
|
|
193
|
+
|
|
194
|
+
# ---------------------------------------------------------------- chat
|
|
195
|
+
|
|
196
|
+
def chat(self, task: str) -> Any:
|
|
197
|
+
"""Run a task on the attached agent, off the UI thread.
|
|
198
|
+
|
|
199
|
+
The prompt is cleared as soon as it is submitted so a second click cannot
|
|
200
|
+
submit the same text twice while the first run is still going.
|
|
201
|
+
"""
|
|
202
|
+
if self.agent is None:
|
|
203
|
+
raise RuntimeError("GradioApp has no agent: pass agent=... or call attach(agent).")
|
|
204
|
+
self._running = True
|
|
205
|
+
try:
|
|
206
|
+
return self.agent.chat(task)
|
|
207
|
+
finally:
|
|
208
|
+
self._running = False
|
|
209
|
+
self._refresh()
|
|
210
|
+
|
|
211
|
+
def submit(self, task: str, _history: Any = None) -> tuple[str, str, str, str]:
|
|
212
|
+
"""The callback behind the Send button.
|
|
213
|
+
|
|
214
|
+
Returns ``(transcript_markdown, status, prompt_value, approval_html)`` so
|
|
215
|
+
Gradio can update four components from one event. Errors are reported in
|
|
216
|
+
the transcript and not raised: a traceback in the UI tells the user
|
|
217
|
+
nothing they can act on.
|
|
218
|
+
"""
|
|
219
|
+
task = (task or "").strip()
|
|
220
|
+
if not task:
|
|
221
|
+
return self._view()
|
|
222
|
+
if self.agent is None:
|
|
223
|
+
self.transcript.handle("info", {"notes": "no agent attached yet"})
|
|
224
|
+
return self._view()
|
|
225
|
+
|
|
226
|
+
# Run synchronously on the Gradio worker: the loop emits approval
|
|
227
|
+
# requests mid-run and the buttons must stay live, which they do because
|
|
228
|
+
# `approver` blocks the loop's own thread, not the server's.
|
|
229
|
+
try:
|
|
230
|
+
self.chat(task)
|
|
231
|
+
except Exception as exc: # noqa: BLE001 - surface it, do not crash the server
|
|
232
|
+
self.transcript.handle("info", {"notes": f"run failed: {exc}"})
|
|
233
|
+
return self._view()
|
|
234
|
+
|
|
235
|
+
def _view(self) -> tuple[str, str, str, str]:
|
|
236
|
+
status = self._status_line()
|
|
237
|
+
return (
|
|
238
|
+
self.transcript.render(status_line=status),
|
|
239
|
+
status,
|
|
240
|
+
"",
|
|
241
|
+
self._approval_html(),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def _status_line(self) -> str:
|
|
245
|
+
agent = self.agent
|
|
246
|
+
if agent is None:
|
|
247
|
+
return "coAgent · no agent attached"
|
|
248
|
+
budget = ""
|
|
249
|
+
state = getattr(agent, "state", None)
|
|
250
|
+
if state is not None:
|
|
251
|
+
summary = getattr(state, "plan_summary", "") or ""
|
|
252
|
+
if summary:
|
|
253
|
+
budget = summary
|
|
254
|
+
return render_status_line(
|
|
255
|
+
model_id=getattr(agent, "model_id", "?"),
|
|
256
|
+
ctx_tokens=int(getattr(agent, "ctx_tokens", 0) or 0),
|
|
257
|
+
session_id=getattr(agent, "session_id", "?"),
|
|
258
|
+
budget=budget,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def _approval_html(self) -> str:
|
|
262
|
+
"""The pending request, or empty. HTML because it must not render a plan."""
|
|
263
|
+
with self._lock:
|
|
264
|
+
pending = self._pending
|
|
265
|
+
if pending is None:
|
|
266
|
+
return ""
|
|
267
|
+
req = pending.request
|
|
268
|
+
command = str(getattr(req, "command", ""))
|
|
269
|
+
reason = str(getattr(req, "reason", ""))
|
|
270
|
+
kind = str(getattr(req, "kind", "action"))
|
|
271
|
+
# Escape before interpolating: `command` is model output, and this is HTML.
|
|
272
|
+
import html as _html
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
f"<div style='border-left:3px solid #d97706;padding:8px 12px'>"
|
|
276
|
+
f"<b>Approval needed</b> — <code>{_html.escape(kind)}</code><br>"
|
|
277
|
+
f"<code>{_html.escape(command)}</code><br>"
|
|
278
|
+
f"<small>{_html.escape(reason)}</small></div>"
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def verify_line(self, summary: str) -> str:
|
|
282
|
+
"""One-line verify verdict, shared with the notebook surface."""
|
|
283
|
+
return summarise_verify(summary)
|
|
284
|
+
|
|
285
|
+
def _refresh(self) -> None:
|
|
286
|
+
"""Push the current view to the widgets, if the UI is built."""
|
|
287
|
+
if self._ui is None:
|
|
288
|
+
return
|
|
289
|
+
try:
|
|
290
|
+
body, status, prompt, approval = self._view()
|
|
291
|
+
self._ui["transcript"].value = body
|
|
292
|
+
self._ui["status"].value = status
|
|
293
|
+
self._ui["prompt"].value = prompt
|
|
294
|
+
self._ui["approval"].value = approval
|
|
295
|
+
self._ui["buttons_row"].visible = bool(approval)
|
|
296
|
+
except Exception: # noqa: BLE001 - widget may not exist yet
|
|
297
|
+
pass
|
|
298
|
+
|
|
299
|
+
# --------------------------------------------------------------- build
|
|
300
|
+
|
|
301
|
+
def build(self) -> Any:
|
|
302
|
+
"""Construct the Blocks UI. Idempotent: repeated calls return the same app."""
|
|
303
|
+
if self.block is not None:
|
|
304
|
+
return self.block
|
|
305
|
+
gr = self.gr
|
|
306
|
+
|
|
307
|
+
with gr.Blocks(title=self.title) as block:
|
|
308
|
+
gr.Markdown(f"# {self.title}")
|
|
309
|
+
self._ui = {}
|
|
310
|
+
self._ui["status"] = gr.Markdown(self._status_line())
|
|
311
|
+
self._ui["transcript"] = gr.Markdown("coAgent is idle.")
|
|
312
|
+
self._ui["approval"] = gr.HTML("")
|
|
313
|
+
with gr.Row(visible=False) as buttons_row:
|
|
314
|
+
approve = gr.Button("Approve", variant="primary", size="sm")
|
|
315
|
+
deny = gr.Button("Deny", variant="stop", size="sm")
|
|
316
|
+
self._ui["buttons_row"] = buttons_row
|
|
317
|
+
with gr.Row():
|
|
318
|
+
prompt = gr.Textbox(
|
|
319
|
+
placeholder="Describe the task, e.g. 'add a chunked CSV loader with tests'",
|
|
320
|
+
show_label=False,
|
|
321
|
+
scale=8,
|
|
322
|
+
lines=1,
|
|
323
|
+
)
|
|
324
|
+
send = gr.Button("Send", variant="primary", scale=1, size="sm")
|
|
325
|
+
self._ui["prompt"] = prompt
|
|
326
|
+
|
|
327
|
+
outputs = [self._ui["transcript"], self._ui["status"], prompt, self._ui["approval"]]
|
|
328
|
+
send.click(self.submit, inputs=[prompt], outputs=outputs)
|
|
329
|
+
prompt.submit(self.submit, inputs=[prompt], outputs=outputs)
|
|
330
|
+
approve.click(lambda: (self._resolve(True), self._view())[1], outputs=outputs)
|
|
331
|
+
deny.click(lambda: (self._resolve(False), self._view())[1], outputs=outputs)
|
|
332
|
+
|
|
333
|
+
self.block = block
|
|
334
|
+
return block
|
|
335
|
+
|
|
336
|
+
def launch(self, *, open_browser: bool = False, **kwargs: Any) -> Any:
|
|
337
|
+
"""Start the server on 127.0.0.1 only.
|
|
338
|
+
|
|
339
|
+
Loopback deliberately: in Colab the tab reaches it through the kernel's
|
|
340
|
+
port proxy, so there is no reason to bind a public interface.
|
|
341
|
+
"""
|
|
342
|
+
block = self.build()
|
|
343
|
+
kwargs.setdefault("server_name", "127.0.0.1")
|
|
344
|
+
kwargs.setdefault("server_port", self.port)
|
|
345
|
+
kwargs.setdefault("share", False) # §8.3: share=True is out of scope
|
|
346
|
+
kwargs.setdefault("quiet", True)
|
|
347
|
+
return block.launch(open_browser=open_browser, **kwargs)
|
|
348
|
+
|
|
349
|
+
def close(self) -> None:
|
|
350
|
+
self._clear_pending()
|
|
351
|
+
if self.block is not None:
|
|
352
|
+
with contextlib.suppress(Exception):
|
|
353
|
+
self.block.close()
|
coagent/ui/notebook.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""The notebook chat cell — the guaranteed surface (DESIGN §8.1, PRD §7.8).
|
|
2
|
+
|
|
3
|
+
What this file is allowed to assume
|
|
4
|
+
-----------------------------------
|
|
5
|
+
`ipywidgets` and `IPython.display` may be absent (CLI, tests, a bare script), so
|
|
6
|
+
every import of them is inside a function. When they are missing, `ChatCell` still
|
|
7
|
+
constructs and still records the transcript; it just does not draw it. That is why
|
|
8
|
+
the interesting logic lives in `render.py` and this module is mostly plumbing.
|
|
9
|
+
|
|
10
|
+
The one genuinely hard part
|
|
11
|
+
---------------------------
|
|
12
|
+
Approvals are a *synchronous* callable in the loop (`Approver` returns a bool), but
|
|
13
|
+
a notebook cannot block its own cell waiting for a click without freezing the
|
|
14
|
+
front end. The mechanism here is a `threading.Event` that a button callback sets:
|
|
15
|
+
|
|
16
|
+
* the loop thread (which is the cell's thread) blocks on `event.wait()`;
|
|
17
|
+
* Colab delivers widget messages on a separate thread, so button callbacks still
|
|
18
|
+
run and can set the event.
|
|
19
|
+
|
|
20
|
+
A timeout is mandatory. If the browser is gone, a disconnected runtime, or a
|
|
21
|
+
message that never arrives, `wait()` returns False and the action is **denied** —
|
|
22
|
+
the safe default, consistent with non-interactive mode (DESIGN §9.1).
|
|
23
|
+
|
|
24
|
+
FR-UX-7 (reconnect-safe)
|
|
25
|
+
------------------------
|
|
26
|
+
Widget state can be lost when a Colab tab reconnects. So the cell also accepts
|
|
27
|
+
plain text commands (`approve`, `deny`, `stop`, `resume`) through `command()`,
|
|
28
|
+
which sets the same pending decision. A stuck session is recoverable from the
|
|
29
|
+
keyboard.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import contextlib
|
|
35
|
+
import html
|
|
36
|
+
import threading
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
from .render import Transcript, render_status_line
|
|
40
|
+
|
|
41
|
+
__all__ = ["ChatCell", "ApprovalTimeout", "DEFAULT_APPROVAL_TIMEOUT_S"]
|
|
42
|
+
|
|
43
|
+
#: Ten minutes: long enough for a human to read a diff, short enough that an
|
|
44
|
+
#: abandoned tab does not hold a session lock until the heartbeat expires.
|
|
45
|
+
DEFAULT_APPROVAL_TIMEOUT_S = 600
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ApprovalTimeout(RuntimeError):
|
|
49
|
+
"""No decision arrived in time. Treated as a denial by the caller."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ChatCell:
|
|
53
|
+
"""Renders a coAgent run into a Colab output cell.
|
|
54
|
+
|
|
55
|
+
Usage from a cell::
|
|
56
|
+
|
|
57
|
+
cell = ChatCell()
|
|
58
|
+
agent = coAgent(repo=".", on_event=cell.on_event, approver=cell.approver,
|
|
59
|
+
renderer=cell.render_artifact)
|
|
60
|
+
cell.attach(agent)
|
|
61
|
+
cell.chat("add a chunked CSV loader")
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
*,
|
|
67
|
+
title: str = "",
|
|
68
|
+
approval_timeout_s: float = DEFAULT_APPROVAL_TIMEOUT_S,
|
|
69
|
+
max_rows: int = 200,
|
|
70
|
+
stream: bool = True,
|
|
71
|
+
) -> None:
|
|
72
|
+
self.transcript = Transcript(max_rows=max_rows)
|
|
73
|
+
self.approval_timeout_s = approval_timeout_s
|
|
74
|
+
self.stream = stream
|
|
75
|
+
self.agent: Any = None
|
|
76
|
+
self.title = title
|
|
77
|
+
#: Set by a button or by `command()`; the loop thread waits on it.
|
|
78
|
+
self._decision: threading.Event = threading.Event()
|
|
79
|
+
self._decision_value = False
|
|
80
|
+
self._pending_request: Any = None
|
|
81
|
+
self._lock = threading.Lock()
|
|
82
|
+
|
|
83
|
+
self._handle: Any = None
|
|
84
|
+
self._details_handle: Any = None
|
|
85
|
+
self._decide_event: threading.Event | None = None
|
|
86
|
+
self._buttons: dict[str, Any] = {}
|
|
87
|
+
self._widgets_ok = False
|
|
88
|
+
self._setup_surface()
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------- surface
|
|
91
|
+
|
|
92
|
+
def _setup_surface(self) -> None:
|
|
93
|
+
"""Create the output handles. Failures here are not fatal (headless)."""
|
|
94
|
+
try:
|
|
95
|
+
from IPython.display import display
|
|
96
|
+
|
|
97
|
+
self._handle = display({"text/markdown": "coAgent is idle."}, raw=True, display_id=True)
|
|
98
|
+
self._details_handle = display({"text/html": ""}, raw=True, display_id=True)
|
|
99
|
+
self._widgets_ok = True
|
|
100
|
+
except Exception: # noqa: BLE001 - no IPython: stay silent, keep working
|
|
101
|
+
self._handle = None
|
|
102
|
+
self._details_handle = None
|
|
103
|
+
self._paint()
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def live(self) -> bool:
|
|
107
|
+
"""True when there is something to draw on."""
|
|
108
|
+
return self._handle is not None
|
|
109
|
+
|
|
110
|
+
# -------------------------------------------------------------- events
|
|
111
|
+
|
|
112
|
+
def on_event(self, kind: str, data: dict[str, Any]) -> None:
|
|
113
|
+
"""`coAgent(on_event=cell.on_event)`. Never raises into the loop."""
|
|
114
|
+
try:
|
|
115
|
+
self.transcript.handle(kind, data)
|
|
116
|
+
if kind == "task_end":
|
|
117
|
+
self._render_buttons(show=False)
|
|
118
|
+
self._paint()
|
|
119
|
+
except Exception as exc: # noqa: BLE001 - the UI must never break a run
|
|
120
|
+
# A broken renderer must not take the session with it; the log is the
|
|
121
|
+
# durable record and the loop keeps going.
|
|
122
|
+
self._safe_print(f"[ui] render failed: {exc}")
|
|
123
|
+
|
|
124
|
+
def _paint(self) -> None:
|
|
125
|
+
if self._handle is None:
|
|
126
|
+
return
|
|
127
|
+
try:
|
|
128
|
+
status = self._status_text()
|
|
129
|
+
body = self.transcript.render(status_line="")
|
|
130
|
+
if status:
|
|
131
|
+
body = f"```\n{status}\n```\n\n{body}"
|
|
132
|
+
# `display_id` + update is what §8.1 asks for: `clear_output` flickers
|
|
133
|
+
# and scrolls the pane, an in-place update does not.
|
|
134
|
+
self._handle.update({"text/markdown": body}, raw=True)
|
|
135
|
+
self._paint_buttons()
|
|
136
|
+
except Exception: # noqa: BLE001
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
def _status_text(self) -> str:
|
|
140
|
+
"""FR-UX-3: model, context, GPU, session, and budget on every repaint."""
|
|
141
|
+
agent = self.agent
|
|
142
|
+
if agent is None:
|
|
143
|
+
return self.transcript.status
|
|
144
|
+
try:
|
|
145
|
+
return render_status_line(
|
|
146
|
+
model_id=getattr(agent, "model_id", "?"),
|
|
147
|
+
ctx_tokens=agent.cfg.model.max_context,
|
|
148
|
+
session_id=agent.session_id,
|
|
149
|
+
budget=agent.budget.describe(),
|
|
150
|
+
gpu=_gpu_label(),
|
|
151
|
+
)
|
|
152
|
+
except Exception: # noqa: BLE001
|
|
153
|
+
return self.transcript.status
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------- approvals
|
|
156
|
+
|
|
157
|
+
def approver(self, request: Any) -> bool:
|
|
158
|
+
"""The synchronous `Approver` the loop calls.
|
|
159
|
+
|
|
160
|
+
Blocks on a button click or a text command. On timeout it denies: a
|
|
161
|
+
missing human is not consent (DESIGN §9.1, non-interactive default).
|
|
162
|
+
"""
|
|
163
|
+
with self._lock:
|
|
164
|
+
self._pending_request = request
|
|
165
|
+
self._decision.clear()
|
|
166
|
+
self._decision_value = False
|
|
167
|
+
|
|
168
|
+
self._render_buttons(show=True)
|
|
169
|
+
self._paint()
|
|
170
|
+
arrived = self._decision.wait(timeout=self.approval_timeout_s)
|
|
171
|
+
|
|
172
|
+
with self._lock:
|
|
173
|
+
decided = self._decision_value
|
|
174
|
+
self._pending_request = None
|
|
175
|
+
self._render_buttons(show=False)
|
|
176
|
+
self._paint()
|
|
177
|
+
|
|
178
|
+
if not arrived:
|
|
179
|
+
self.transcript.handle(
|
|
180
|
+
"info",
|
|
181
|
+
{
|
|
182
|
+
"notes": (
|
|
183
|
+
f"No decision for the {request.kind} request within "
|
|
184
|
+
f"{self.approval_timeout_s:.0f}s, so it was denied. "
|
|
185
|
+
"Send `approve` to retry the action, or `stop` to end the session."
|
|
186
|
+
)
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
self._paint()
|
|
190
|
+
return False
|
|
191
|
+
return decided
|
|
192
|
+
|
|
193
|
+
def command(self, text: str) -> bool | None:
|
|
194
|
+
"""Accept a text decision when widgets are gone (FR-UX-7).
|
|
195
|
+
|
|
196
|
+
Returns True/False when the command resolved a pending request, or None
|
|
197
|
+
when the text was not a command.
|
|
198
|
+
"""
|
|
199
|
+
word = text.strip().lower().split(maxsplit=1)[0] if text.strip() else ""
|
|
200
|
+
if word in {"approve", "yes", "y", "a"}:
|
|
201
|
+
return self._resolve(True)
|
|
202
|
+
if word in {"deny", "no", "n", "reject", "d"}:
|
|
203
|
+
return self._resolve(False)
|
|
204
|
+
if word in {"stop", "cancel"}:
|
|
205
|
+
if self.agent is not None:
|
|
206
|
+
self.agent.cancel()
|
|
207
|
+
return self._resolve(False)
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
def _resolve(self, value: bool) -> bool | None:
|
|
211
|
+
with self._lock:
|
|
212
|
+
if self._pending_request is None:
|
|
213
|
+
return None
|
|
214
|
+
self._decision_value = value
|
|
215
|
+
self._decision.set()
|
|
216
|
+
return value
|
|
217
|
+
|
|
218
|
+
# ------------------------------------------------------------ buttons
|
|
219
|
+
|
|
220
|
+
def _render_buttons(self, *, show: bool) -> None:
|
|
221
|
+
"""The plan/approval controls (FR-UX-2).
|
|
222
|
+
|
|
223
|
+
Widgets are created once and shown/hidden, because creating fresh widgets
|
|
224
|
+
on every approval is what makes a Colab output cell grow without bound.
|
|
225
|
+
"""
|
|
226
|
+
if not self._widgets_ok:
|
|
227
|
+
return
|
|
228
|
+
if show and not self._buttons:
|
|
229
|
+
self._create_buttons()
|
|
230
|
+
box = self._buttons.get("box")
|
|
231
|
+
if box is None:
|
|
232
|
+
return
|
|
233
|
+
try:
|
|
234
|
+
from IPython.display import display
|
|
235
|
+
|
|
236
|
+
if show and getattr(box, "_coagent_shown", False) is False:
|
|
237
|
+
display(box)
|
|
238
|
+
box._coagent_shown = True # noqa: SLF001 - display bookkeeping
|
|
239
|
+
box.layout.display = "" if show else "none"
|
|
240
|
+
except Exception: # noqa: BLE001
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
def _create_buttons(self) -> None:
|
|
244
|
+
try:
|
|
245
|
+
import ipywidgets as widgets
|
|
246
|
+
except Exception: # noqa: BLE001 - no widgets: text commands still work
|
|
247
|
+
self._widgets_ok = False
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
approve = widgets.Button(description="Approve", button_style="success")
|
|
251
|
+
deny = widgets.Button(description="Deny", button_style="danger")
|
|
252
|
+
stop = widgets.Button(description="Stop")
|
|
253
|
+
|
|
254
|
+
approve.on_click(lambda _b: self._resolve(True))
|
|
255
|
+
deny.on_click(lambda _b: self._resolve(False))
|
|
256
|
+
stop.on_click(self._on_stop)
|
|
257
|
+
|
|
258
|
+
self._buttons = {
|
|
259
|
+
"approve": approve,
|
|
260
|
+
"deny": deny,
|
|
261
|
+
"stop": stop,
|
|
262
|
+
"box": widgets.HBox([approve, deny, stop]),
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
def _on_stop(self, _button: Any) -> None:
|
|
266
|
+
if self.agent is not None:
|
|
267
|
+
with contextlib.suppress(Exception):
|
|
268
|
+
self.agent.cancel()
|
|
269
|
+
self._resolve(False)
|
|
270
|
+
|
|
271
|
+
# ---------------------------------------------------------- artifacts
|
|
272
|
+
|
|
273
|
+
def render_artifact(self, kind: str, payload: str, mime: str) -> None:
|
|
274
|
+
"""`coAgent(renderer=cell.render_artifact)` — FR-UX-4.
|
|
275
|
+
|
|
276
|
+
Renders below the transcript so a figure does not push the log off screen.
|
|
277
|
+
`payload` is base64 for images and raw text otherwise, which is the
|
|
278
|
+
contract `display_artifact` documents.
|
|
279
|
+
"""
|
|
280
|
+
if self._details_handle is None:
|
|
281
|
+
return
|
|
282
|
+
try:
|
|
283
|
+
if kind == "image":
|
|
284
|
+
import base64
|
|
285
|
+
|
|
286
|
+
from IPython.display import Image, display
|
|
287
|
+
|
|
288
|
+
display(Image(data=base64.b64decode(payload)))
|
|
289
|
+
return
|
|
290
|
+
if kind == "html":
|
|
291
|
+
from IPython.display import HTML, display
|
|
292
|
+
|
|
293
|
+
display(HTML(payload))
|
|
294
|
+
return
|
|
295
|
+
escaped = html.escape(payload[:4000])
|
|
296
|
+
self._details_handle.update(
|
|
297
|
+
{"text/markdown": f"```\n{escaped}\n```"}, raw=True
|
|
298
|
+
)
|
|
299
|
+
except Exception as exc: # noqa: BLE001
|
|
300
|
+
self._safe_print(f"[ui] could not render artifact: {exc}")
|
|
301
|
+
|
|
302
|
+
# --------------------------------------------------------------- chat
|
|
303
|
+
|
|
304
|
+
def attach(self, agent: Any) -> None:
|
|
305
|
+
"""Bind the agent so the status line and Stop button have a target."""
|
|
306
|
+
self.agent = agent
|
|
307
|
+
|
|
308
|
+
def chat(self, task: str, agent: Any = None) -> Any:
|
|
309
|
+
"""Run a task with this cell as the surface."""
|
|
310
|
+
target = agent or self.agent
|
|
311
|
+
if target is None:
|
|
312
|
+
raise RuntimeError(
|
|
313
|
+
"ChatCell.chat needs an agent: pass one, or call attach(agent) first."
|
|
314
|
+
)
|
|
315
|
+
self.attach(target)
|
|
316
|
+
return target.chat(task)
|
|
317
|
+
|
|
318
|
+
def _safe_print(self, text: str) -> None:
|
|
319
|
+
try:
|
|
320
|
+
from IPython.display import display
|
|
321
|
+
|
|
322
|
+
display({"text/plain": text}, raw=True)
|
|
323
|
+
except Exception: # noqa: BLE001
|
|
324
|
+
print(text)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _gpu_label() -> str:
|
|
328
|
+
"""`T4 16GB` when a GPU is visible, empty otherwise.
|
|
329
|
+
|
|
330
|
+
Deliberately tolerant: this runs on every repaint, and a CUDA query that
|
|
331
|
+
throws must not blank the status line.
|
|
332
|
+
"""
|
|
333
|
+
try:
|
|
334
|
+
import torch
|
|
335
|
+
|
|
336
|
+
if not torch.cuda.is_available():
|
|
337
|
+
return ""
|
|
338
|
+
props = torch.cuda.get_device_properties(0)
|
|
339
|
+
gib = props.total_memory / 1024**3
|
|
340
|
+
return f"{props.name.split()[0]} {gib:.0f}GB"
|
|
341
|
+
except Exception: # noqa: BLE001
|
|
342
|
+
return ""
|