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/agent/loop.py ADDED
@@ -0,0 +1,1102 @@
1
+ """The agent loop (DESIGN §7).
2
+
3
+ One explicit state machine: INTAKE → SURVEY → PLAN → ACT → VERIFY → CHECKPOINT →
4
+ (ACT | REPORT). No framework, no hidden prompt layer — with a 7B model, every
5
+ layer you cannot read is a debugging cost you cannot pay.
6
+
7
+ Deliberate properties, each traceable to a requirement:
8
+
9
+ * **PLAN cannot act.** It gets an empty tool registry (FR-AGT-2 / DESIGN §7.1).
10
+ * **One write or exec per turn.** A multi-write message is rejected with a
11
+ corrective prompt (FR-AGT-3).
12
+ * **Read before write** is enforced by the tool, not by the prompt (FR-AGT-4).
13
+ * **Verify after write.** A write that cannot pass a syntax check never commits
14
+ (FR-AGT-7).
15
+ * **Definition of done** is a code predicate: checks pass AND every plan item is
16
+ ticked AND no new stubs (FR-AGT-8).
17
+ * **Never a silent stop.** Budget exhaustion checkpoints and reports (FR-AGT-5).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import contextlib
23
+ import json
24
+ import shlex
25
+ import time
26
+ from collections.abc import Callable
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from ..config import Config, load_config
32
+ from ..context.artifacts import ArtifactStore
33
+ from ..gitx.commit import CommitMeta
34
+ from ..gitx.durable import SyncResult
35
+ from ..gitx.git import GitError
36
+ from ..gitx.log import LogRecord, SessionLog
37
+ from ..gitx.session import Session, open_session
38
+ from ..llm.base import BudgetError, GenerationConfig, LLMBackend, Message, TokenBudget
39
+ from ..llm.grammar import EnvelopeError, looks_like_final_answer, parse_envelope
40
+ from ..llm.registry import get_model_by_name
41
+ from ..safety.policy import RiskClass, SafetyClass, Verdict
42
+ from ..safety.redact import Redactor
43
+ from ..tools import Phase, build_policy, build_registry
44
+ from ..tools.base import ToolError, ToolRegistry, ToolResult, ToolSchemaError
45
+ from ..tools.fs import FileSession
46
+ from ..tools.pyt import summarise_pytest
47
+ from . import prompts
48
+ from .budget import Budget, BudgetExhausted, BudgetLimits, LoopDetector, LoopVerdict
49
+ from .state import PlanFile, SessionState, slugify
50
+
51
+ __all__ = ["AgentResult", "coAgent", "ApprovalRequest", "Approver"]
52
+
53
+
54
+ Approver = Callable[["ApprovalRequest"], bool]
55
+
56
+
57
+ @dataclass(slots=True)
58
+ class ApprovalRequest:
59
+ """A risky action awaiting a human decision (DESIGN §9.1)."""
60
+
61
+ kind: str # a RiskClass value
62
+ command: str
63
+ reason: str
64
+
65
+ def render(self) -> str:
66
+ return f"[{self.kind}] {self.command}\n why: {self.reason}"
67
+
68
+
69
+ @dataclass
70
+ class AgentResult:
71
+ task: str
72
+ done: bool
73
+ branch: str
74
+ session_id: str
75
+ steps: int = 0
76
+ commits: int = 0
77
+ files_changed: list[str] = field(default_factory=list)
78
+ artifacts: list[str] = field(default_factory=list)
79
+ blocked_reason: str = ""
80
+ report: str = ""
81
+ sync: SyncResult | None = None
82
+ stopped_by: str = ""
83
+
84
+ def render(self) -> str:
85
+ verdict = "done" if self.done else "not done"
86
+ lines = [
87
+ f"coAgent · {verdict} · session {self.session_id}",
88
+ f"branch: {self.branch}",
89
+ f"steps: {self.steps} · commits: {self.commits}",
90
+ ]
91
+ if self.files_changed:
92
+ lines.append(f"files: {', '.join(self.files_changed[:10])}")
93
+ if self.artifacts:
94
+ lines.append(f"artifacts: {', '.join(self.artifacts)}")
95
+ if self.blocked_reason:
96
+ lines.append(f"blocked: {self.blocked_reason}")
97
+ if self.sync is not None:
98
+ lines.append(f"durable: {self.sync.message}")
99
+ if self.report:
100
+ lines.append("")
101
+ lines.append(self.report.strip())
102
+ return "\n".join(lines)
103
+
104
+
105
+ _WRITE_TOOLS = {"edit_file", "write_file", "edit_notebook"}
106
+ _EXEC_TOOLS = {"run_shell", "run_python", "run_tests"}
107
+ #: Tools whose success means a file on disk changed and a verify+commit is due.
108
+ #: `save_artifact` belongs here because the manifest and any committed artifact
109
+ #: must ride in the same commit that produced them (FR-GIT-10); it is not in
110
+ #: _WRITE_TOOLS because it does not edit code or tick a plan item.
111
+ _COMMIT_TOOLS = _WRITE_TOOLS | {"exec_notebook", "save_artifact"}
112
+ #: Tools that leave a path read (and, for exec_notebook, rewritten).
113
+ _READ_TOOLS = {"read_file", "read_notebook"}
114
+
115
+ # Files that must never be committed on their own (state, not code).
116
+ _PLAN_PREFIX = ".coagent/plans/"
117
+
118
+
119
+ def _is_url(text: str) -> bool:
120
+ """True for the clone URLs a user would paste (DESIGN §11).
121
+
122
+ Deliberately narrow: a local path that merely ends in `.git` is still a path,
123
+ so the scheme (or an ssh host form) has to decide it.
124
+ """
125
+ return text.startswith(("https://", "http://", "ssh://", "git@"))
126
+
127
+
128
+ class HardwareError(RuntimeError):
129
+ """The runtime cannot host the model (PRD D1, FR-BOOT-2).
130
+
131
+ Raised at construction, before any weights are downloaded, so the message the
132
+ user reads is the reason and not a CUDA stack trace.
133
+ """
134
+
135
+ def __init__(self, reason: str) -> None:
136
+ self.reason = reason
137
+ super().__init__(
138
+ f"{reason}\n\n"
139
+ "To run anyway on unsupported hardware (the 3B fallback at 8k context may fit):\n"
140
+ ' coAgent(repo=..., allow_unsupported=True)\n'
141
+ "or set `[model] key = \"qwen-coder-3b\"` in .coagent/config.toml."
142
+ )
143
+
144
+
145
+ class coAgent:
146
+ """The agent. Construct once per session, then `chat()` or `run()`."""
147
+
148
+ def __init__(
149
+ self,
150
+ repo: str | Path = ".",
151
+ *,
152
+ model: str | None = None,
153
+ backend: LLMBackend | None = None,
154
+ config: Config | None = None,
155
+ interactive: bool = True,
156
+ auto: bool = False,
157
+ session_id: str | None = None,
158
+ token: str | None = None,
159
+ approver: Approver | None = None,
160
+ on_event: Callable[[str, dict[str, Any]], None] | None = None,
161
+ renderer: Callable[[str, str, str], None] | None = None,
162
+ force: bool = False,
163
+ allow_unsupported: bool = False,
164
+ ) -> None:
165
+ self.cfg = config or load_config(start=repo if not _is_url(str(repo)) else ".")
166
+ # FR-BOOT-1: `coAgent(repo="https://github.com/you/project.git")` is the
167
+ # documented one-liner, so a URL must clone rather than fail as a path.
168
+ self.repo_path = self._resolve_repo(repo, token=token)
169
+ self.interactive = interactive
170
+ self.auto = auto
171
+ self.token = token
172
+ self.approver = approver
173
+ self.on_event = on_event or (lambda _kind, _data: None)
174
+ # Optional notebook renderer for `display_artifact`. `None` is headless:
175
+ # the artifact is still saved and referenced, just not drawn (FR-UX-4).
176
+ self.renderer = renderer
177
+ self.redactor = Redactor([token] if token else [])
178
+
179
+ if backend is None:
180
+ # FR-BOOT-2: refuse before loading weights, not after four gigabytes
181
+ # of them and a CUDA error. A caller who injects a backend has
182
+ # already made their own hardware decision, so this is skipped.
183
+ self.preflight = self._preflight(allow_unsupported=allow_unsupported)
184
+ if not self.preflight.ok:
185
+ raise HardwareError(self.preflight.reason)
186
+ self._emit("preflight", {"summary": self.preflight.describe()})
187
+ from ..llm import make_backend
188
+
189
+ spec = get_model_by_name(self.cfg.model.name)
190
+ key = spec.key if spec else "qwen-coder-7b"
191
+ backend = make_backend(
192
+ "transformers", model_key=key, max_context=self.cfg.model.max_context
193
+ )
194
+ self.backend = backend
195
+ self.model_id = model or getattr(backend, "model_id", "unknown")
196
+
197
+ self.session: Session = open_session(
198
+ self.repo_path,
199
+ session_id=session_id,
200
+ force=force,
201
+ author_name=self.cfg.git.author_name,
202
+ author_email=self.cfg.git.author_email,
203
+ token=token,
204
+ )
205
+ self.policy = build_policy(
206
+ self.repo_path, self.cfg, interactive=interactive and not auto
207
+ )
208
+ # One FileSession and one ArtifactStore for the whole session. Phases get
209
+ # fresh registries but share these, because read-before-write and the
210
+ # artifact manifest are session state, not phase state.
211
+ self.tool_session = FileSession(repo_root=self.repo_path, policy=self.policy)
212
+ self.artifacts = ArtifactStore(
213
+ self.repo_path,
214
+ self.session.session_id,
215
+ commit_under_mb=self.cfg.artifacts.commit_under_mb,
216
+ drive_dir=self.cfg.artifacts.drive_dir,
217
+ )
218
+ self.tool_session.artifact_store = self.artifacts
219
+ self.tool_session.render = self.renderer
220
+ self.registry: ToolRegistry = build_registry(
221
+ self.repo_path,
222
+ self.cfg,
223
+ phase=Phase.ACT,
224
+ policy=self.policy,
225
+ session=self.tool_session,
226
+ )
227
+ self.log = SessionLog(
228
+ self.repo_path, self.session.session_id, redactor=self.redactor
229
+ )
230
+ self.budget = Budget(limits=BudgetLimits.from_config(self.cfg))
231
+ self.loop_detector = LoopDetector(
232
+ replan_after=self.cfg.agent.replan_after_repeats,
233
+ stop_after=self.cfg.agent.stop_after_repeats,
234
+ )
235
+ self.state: SessionState | None = None
236
+ self._cancelled = False
237
+ #: Set by `coagent.coAgent` when a notebook surface is attached, so
238
+ #: `agent.cell.command("approve")` works from another cell (FR-UX-7).
239
+ self.cell: Any = None
240
+ self._pending_sync_tip: str | None = None
241
+ # Per-instance, not class-level: two agents in one process must not
242
+ # share a "last failure" or the loop would cross-contaminate.
243
+ self._last_notes = ""
244
+ self._last_failure = ""
245
+ self._last_was_write = False
246
+
247
+ # --------------------------------------------------------------- preflight
248
+
249
+ def _resolve_repo(self, repo: str | Path, *, token: str | None) -> Path:
250
+ """Turn a path, or a git URL, into a local clone (DESIGN §3.1).
251
+
252
+ The clone is shallow because the runtime is disposable and the branch is
253
+ what matters; history is on the remote.
254
+ """
255
+ text = str(repo)
256
+ if not _is_url(text):
257
+ return Path(repo).resolve()
258
+ from ..gitx.session import clone_repo
259
+
260
+ name = text.rstrip("/").rsplit("/", 1)[-1]
261
+ name = name[:-4] if name.endswith(".git") else name
262
+ # `/content` on Colab, the cwd elsewhere: a workspace next to the runtime,
263
+ # not inside whatever directory happened to be current.
264
+ base = Path("/content") if Path("/content").is_dir() else Path.cwd()
265
+ dest = base / "coagent-workspace" / (name or "project")
266
+ if not (dest / ".git").exists():
267
+ clone_repo(text, dest, depth=1, token=token)
268
+ self._emit("cloned", {"url": text, "path": str(dest)})
269
+ return dest
270
+
271
+ def _preflight(self, *, allow_unsupported: bool) -> Any:
272
+ """The T4 envelope check (PRD D1, FR-BOOT-2).
273
+
274
+ `allow_unsupported` is documented rather than hidden: the 3B fallback may
275
+ run where the 7B will not, and refusing outright would block that.
276
+ """
277
+ from ..llm import preflight_gpu
278
+
279
+ result = preflight_gpu()
280
+ if not result.ok and allow_unsupported:
281
+ result.ok = True
282
+ result.reason = ""
283
+ result.override_allowed = True
284
+ return result
285
+
286
+ # ------------------------------------------------------------- public API
287
+
288
+ @property
289
+ def branch(self) -> str:
290
+ return self.session.branch
291
+
292
+ @property
293
+ def session_id(self) -> str:
294
+ return self.session.session_id
295
+
296
+ def status_line(self) -> str:
297
+ ctx = self.cfg.model.max_context
298
+ return (
299
+ f"coAgent · {self.model_id} · {ctx // 1000}k ctx · "
300
+ f"session {self.session.session_id} · {self.budget.describe()}"
301
+ )
302
+
303
+ def cancel(self) -> None:
304
+ """Stop the next generation or command without losing the checkpoint."""
305
+ self._cancelled = True
306
+
307
+ def chat(self, task: str | None = None) -> AgentResult:
308
+ """Run one task. With no task, ask the approver/user for one."""
309
+ if task is None:
310
+ task = self._prompt_text("What should coAgent do?")
311
+ return self.run(task)
312
+
313
+ def run(self, task: str) -> AgentResult:
314
+ """survey → plan → (approve) → act/verify loop → checkpoint → report."""
315
+ self.state = SessionState(
316
+ task=task, plan=PlanFile(self.repo_path, slugify(task))
317
+ )
318
+ self._emit("task_start", {"task": task, "branch": self.branch})
319
+ result = AgentResult(task=task, done=False, branch=self.branch, session_id=self.session_id)
320
+
321
+ try:
322
+ self._phase_survey()
323
+ plan_ok = self._phase_plan()
324
+ if not plan_ok:
325
+ result.blocked_reason = "the plan was rejected; nothing was written"
326
+ result.stopped_by = "plan_rejected"
327
+ result.report = "The plan was rejected, so no files were changed."
328
+ return result
329
+
330
+ self._act_loop(result)
331
+ except BudgetExhausted as exc:
332
+ result.stopped_by = exc.kind
333
+ result.blocked_reason = exc.detail
334
+ except KeyboardInterrupt:
335
+ result.stopped_by = "interrupt"
336
+ result.blocked_reason = "interrupted by the user"
337
+ finally:
338
+ self._final_checkpoint(result)
339
+ # The manifest is written even if every artifact went to Drive or the
340
+ # run stopped early: the report is the only place the user learns
341
+ # what was produced (FR-AGT-8 / US-3).
342
+ self._persist_manifest()
343
+ result.steps = self.budget.steps
344
+ result.commits = self.budget.commits
345
+ result.files_changed = sorted(self.state.files_written) if self.state else []
346
+ result.artifacts = list(self.state.artifacts) if self.state else []
347
+ try:
348
+ result.report = self._phase_report(result)
349
+ except (BudgetError, BudgetExhausted) as exc: # report must never fail the run
350
+ result.report = f"(report skipped: {exc})"
351
+ self._emit("task_end", {"done": result.done, "stopped_by": result.stopped_by})
352
+
353
+ return result
354
+
355
+ def resume(self) -> str:
356
+ """Print the briefing for this session (FR-GIT-5)."""
357
+ from ..gitx.resume import build_briefing
358
+
359
+ self._emit("resume", {})
360
+ return build_briefing(self.session.repo, self.session_id, self.branch).render()
361
+
362
+ def push(self) -> SyncResult:
363
+ from ..gitx.durable import sync_push
364
+
365
+ return sync_push(self.session.repo, self.cfg.durability.remote, self.branch, policy=self.policy)
366
+
367
+ def show_log(self, *, last: int = 10) -> str:
368
+ """The last N steps with their diffs, as markdown (§8.2).
369
+
370
+ Reads the JSONL session log rather than the in-memory observations, so it
371
+ still works after a resume and shows what the durable record actually
372
+ says. Diffs come from git, not from the log, because that is the version
373
+ that was committed.
374
+ """
375
+ from ..gitx.resume import read_last_log_records
376
+
377
+ records = read_last_log_records(
378
+ self.repo_path / ".coagent" / "sessions" / f"{self.session_id}.jsonl",
379
+ count=max(1, int(last)),
380
+ )
381
+ if not records:
382
+ return f"no log records for session {self.session_id} yet."
383
+
384
+ lines = [f"**coAgent log** · session {self.session_id} · {len(records)} step(s)"]
385
+ for record in records:
386
+ mark = "ok" if record.get("ok", True) else "FAIL"
387
+ step = record.get("step", "?")
388
+ tool = record.get("tool") or record.get("phase", "?")
389
+ note = str(record.get("note") or "")
390
+ lines.append(f"\n`step {step}` **{tool}** — {mark}" + (f" · {note}" if note else ""))
391
+ error = str(record.get("error") or "")
392
+ if error:
393
+ lines.append(f"\n```\n{error[:600]}\n```")
394
+
395
+ diff = self._recent_diff()
396
+ if diff:
397
+ lines.append("\n**last change**\n")
398
+ lines.append(f"```diff\n{diff[:2000]}\n```")
399
+ return "\n".join(lines)
400
+
401
+ def _recent_diff(self, *, limit: int = 1500) -> str:
402
+ """The diff of the last commit on the session branch, if there is one."""
403
+ try:
404
+ out = self.session.repo.run(
405
+ "show", "--format=", "--no-color", "-n1", self.branch
406
+ )
407
+ return out.stdout.strip()[:limit]
408
+ except Exception: # noqa: BLE001 - a missing diff is not an error worth raising
409
+ return ""
410
+
411
+ def close(self) -> None:
412
+ self.session.close()
413
+
414
+ # ---------------------------------------------------------------- phases
415
+
416
+ def _phase_survey(self) -> str:
417
+ """Read-only exploration. Returns notes for the PLAN prompt."""
418
+ assert self.state is not None
419
+ registry = build_registry(
420
+ self.repo_path,
421
+ self.cfg,
422
+ phase=Phase.SURVEY,
423
+ policy=self.policy,
424
+ session=self.tool_session,
425
+ )
426
+ repo_map = self._repo_map()
427
+ prompt = prompts.survey_prompt(
428
+ task=self.state.task,
429
+ repo_map=repo_map,
430
+ tool_list=", ".join(registry.names()),
431
+ budget_note=f"Budget: {self.budget.describe()}.",
432
+ )
433
+ notes = self._generate_text(
434
+ [Message("system", prompts.SYSTEM_CORE), Message("user", prompt)],
435
+ temperature=self.cfg.model.temperature_edit,
436
+ phase="survey",
437
+ )
438
+ self._emit("survey", {"notes": notes})
439
+ return notes
440
+
441
+ def _phase_plan(self) -> bool:
442
+ """Write the plan file. PLAN has no tools, by construction (FR-AGT-2)."""
443
+ assert self.state is not None
444
+ prompt = prompts.plan_prompt(
445
+ task=self.state.task,
446
+ survey_notes=(self._last_notes or "(no survey notes)"),
447
+ files_touched=", ".join(sorted(self.state.files_read)[:15]) or "(none yet)",
448
+ require_tests=self.cfg.verify.require_tests,
449
+ )
450
+ text = self._generate_text(
451
+ [Message("system", prompts.SYSTEM_CORE), Message("user", prompt)],
452
+ temperature=self.cfg.model.temperature_plan,
453
+ phase="plan",
454
+ max_new_tokens=min(2048, self.cfg.model.max_new_tokens * 2),
455
+ )
456
+ steps = self._parse_plan(text)
457
+ if not steps:
458
+ # A plan with no checklist cannot be tracked, so synthesise one from
459
+ # the survey rather than proceeding without memory.
460
+ steps = self._fallback_plan()
461
+ title, steps = self._split_title(text, steps)
462
+ path = self.state.plan.write(title, steps)
463
+ self._emit("plan", {"path": str(path), "title": title, "steps": [s.text for s in steps]})
464
+
465
+ if self.auto:
466
+ return True
467
+ decision = self._approve(
468
+ ApprovalRequest(
469
+ kind="plan",
470
+ command=str(path),
471
+ reason="\n".join(step.checkbox() for step in steps),
472
+ )
473
+ )
474
+ if not decision:
475
+ self._emit("plan_rejected", {})
476
+ return False
477
+ return True
478
+
479
+ def _act_loop(self, result: AgentResult) -> None:
480
+ """ACT → VERIFY → CHECKPOINT until done or out of budget."""
481
+ assert self.state is not None
482
+ while True:
483
+ if self._cancelled:
484
+ result.stopped_by = "cancelled"
485
+ result.blocked_reason = "stopped by the user"
486
+ return
487
+ self.budget.check()
488
+
489
+ if self.state.plan.complete:
490
+ if self._verify_and_commit(result):
491
+ result.done = True
492
+ result.stopped_by = "complete"
493
+ return
494
+ # Checks failed with a complete plan: reopen the failure as work.
495
+ self._emit("verify_failed_after_complete", {})
496
+
497
+ action = self._next_action()
498
+ if action is None:
499
+ # The model answered in prose rather than calling a tool.
500
+ result.stopped_by = "no_action"
501
+ result.blocked_reason = "the model stopped proposing actions"
502
+ return
503
+ tool, args = action
504
+
505
+ verdict = self.loop_detector.observe(tool, args)
506
+ if verdict is LoopVerdict.STOP:
507
+ result.stopped_by = "loop"
508
+ result.blocked_reason = self.loop_detector.corrective(LoopVerdict.STOP)
509
+ return
510
+ if verdict is LoopVerdict.REPLAN:
511
+ self._emit("replan", {"corrective": self.loop_detector.corrective(verdict)})
512
+ self._force_replan()
513
+ continue
514
+
515
+ self.budget.record_step()
516
+ executed = self._execute(tool, args)
517
+ self.state.add_observation(tool, executed.ok, executed.content, step=self.budget.steps)
518
+ if not executed.ok:
519
+ try:
520
+ self.budget.record_failure()
521
+ except BudgetExhausted:
522
+ result.stopped_by = "consecutive_failures"
523
+ result.blocked_reason = (
524
+ f"{self.budget.consecutive_failures} consecutive failures; "
525
+ "the last error is in the session log"
526
+ )
527
+ return
528
+ else:
529
+ self.budget.record_success()
530
+
531
+ if tool in _COMMIT_TOOLS and executed.ok:
532
+ self._verify_and_commit(result)
533
+
534
+ def _phase_report(self, result: AgentResult) -> str:
535
+ assert self.state is not None
536
+ if not result.done and not result.blocked_reason:
537
+ result.blocked_reason = "the task did not reach a verified state"
538
+ prompt = prompts.report_prompt(
539
+ task=self.state.task,
540
+ plan=self.state.plan.render(),
541
+ results=self.state.last_verification or "(no verification ran)",
542
+ branch=self.branch,
543
+ artifacts=result.artifacts,
544
+ )
545
+ try:
546
+ text = self._generate_text(
547
+ [Message("system", prompts.SYSTEM_CORE), Message("user", prompt)],
548
+ temperature=0.5,
549
+ phase="report",
550
+ max_new_tokens=400,
551
+ )
552
+ except (BudgetError, BudgetExhausted):
553
+ text = ""
554
+ # REPORT has no tools. A model that answers with an envelope is not
555
+ # producing a report, and pasting raw JSON to the user is worse than a
556
+ # generated summary.
557
+ if not text.strip() or self._looks_like_envelope(text):
558
+ text = self._static_report(result)
559
+ return text
560
+
561
+ # ------------------------------------------------------------- act helper
562
+
563
+ def _next_action(self) -> tuple[str, dict[str, Any]] | None:
564
+ """Ask the model for exactly one action."""
565
+ assert self.state is not None
566
+ schemas = json.dumps(self.registry.schemas(), indent=None, separators=(",", ":"))
567
+ prompt = prompts.act_prompt(
568
+ task=self.state.task,
569
+ plan=self.state.plan.render(),
570
+ observations=prompts.format_observations(self.state.observation_pairs()),
571
+ tool_schemas=schemas,
572
+ next_step=self.budget.steps + 1,
573
+ failures=self._last_failure,
574
+ retrieved=self._retrieve(),
575
+ )
576
+ messages = [Message("system", prompts.SYSTEM_CORE), Message("user", prompt)]
577
+
578
+ for attempt in (1, 2):
579
+ text = self._generate_text(
580
+ messages,
581
+ temperature=self.cfg.model.temperature_edit,
582
+ phase="act",
583
+ max_new_tokens=self.cfg.model.max_new_tokens,
584
+ )
585
+ if looks_like_final_answer(text) and "{" not in text:
586
+ return None
587
+ try:
588
+ env = parse_envelope(text, allowed_tools=set(self.registry.names()))
589
+ return env.tool, env.args
590
+ except EnvelopeError as exc:
591
+ self._emit("envelope_error", {"corrective": exc.corrective})
592
+ self._last_failure = exc.corrective
593
+ if attempt == 2:
594
+ self.budget.record_failure()
595
+ return None
596
+ messages.append(Message("assistant", text))
597
+ messages.append(Message("user", exc.corrective))
598
+ return None
599
+
600
+ def _execute(self, tool: str, args: dict[str, Any]) -> ToolResult:
601
+ """Enforce the one-write rule, approvals, then dispatch."""
602
+ try:
603
+ spec = self.registry.get(tool)
604
+ except ToolSchemaError as exc:
605
+ return ToolResult.failure(str(exc))
606
+
607
+ if (spec.safety in {SafetyClass.WRITE, SafetyClass.EXEC}) and self._last_was_write:
608
+ return ToolResult.failure(
609
+ "You made a write or exec call in the previous message. Send only one "
610
+ "write or exec action per message; batch reads instead."
611
+ )
612
+
613
+ # Approvals for risky classes, decided *before* anything runs.
614
+ denied = self._gate(tool, args)
615
+ if denied is not None:
616
+ return denied
617
+
618
+ try:
619
+ result = self.registry.call(tool, args)
620
+ except ToolSchemaError as exc:
621
+ self._last_failure = str(exc)
622
+ return ToolResult.failure(str(exc))
623
+ except ToolError as exc:
624
+ self._last_failure = str(exc)
625
+ return ToolResult.failure(str(exc))
626
+ except Exception as exc: # noqa: BLE001 - a tool crash must not kill the loop
627
+ self._last_failure = f"{type(exc).__name__}: {exc}"
628
+ return ToolResult.failure(f"tool crashed: {self._last_failure}")
629
+
630
+ self._last_was_write = spec.safety in {SafetyClass.WRITE, SafetyClass.EXEC}
631
+ if result.ok:
632
+ self._last_failure = ""
633
+ else:
634
+ self._last_failure = result.content[:800]
635
+ self.state and self.state.add_observation(tool, False, result.content)
636
+
637
+ if tool in _WRITE_TOOLS and result.ok:
638
+ path = str(args.get("path", ""))
639
+ if path:
640
+ self.state and self.state.note_written(path)
641
+ # A step that names this file is satisfied by editing it.
642
+ self.state and self.state.plan.tick_matching(path)
643
+ self.state and self.state.plan.write(
644
+ self.state.plan.title, self.state.plan.steps
645
+ )
646
+ elif tool == "save_artifact" and result.ok:
647
+ # The store already wrote the manifest; the loop records which paths
648
+ # this run owns so the `Coagent-Artifacts` trailer and the report can
649
+ # name them (FR-GIT-10).
650
+ self._record_artifacts()
651
+ elif tool == "exec_notebook" and result.ok:
652
+ # Execution rewrites the notebook in place with outputs, so it is a
653
+ # write as well as a check: the file must be staged and committed.
654
+ path = str(args.get("path", ""))
655
+ if path:
656
+ self.state and self.state.note_written(path)
657
+ self.state and self.state.plan.tick_matching(path)
658
+ elif tool in _READ_TOOLS and result.ok:
659
+ path = str(args.get("path", ""))
660
+ if path:
661
+ self.state and self.state.note_read(path)
662
+ # Ticking read steps only counts once something else is done too;
663
+ # otherwise a plan of pure reads would report complete.
664
+ if len(self.state.files_written) > 0:
665
+ self.state.plan.tick_matching(path)
666
+
667
+ self.log.append(
668
+ LogRecord(
669
+ step=self.budget.steps,
670
+ phase=Phase.ACT,
671
+ tool=tool,
672
+ ok=result.ok,
673
+ tokens_in=0,
674
+ tokens_out=0,
675
+ error="" if result.ok else result.content[:400],
676
+ note=str(args.get("path", ""))[:200],
677
+ )
678
+ )
679
+ self._emit(
680
+ "tool",
681
+ {
682
+ "tool": tool,
683
+ "ok": result.ok,
684
+ # `path` lets the transcript name the file in the collapsed row
685
+ # (FR-UX-1) without re-parsing the tool's own output.
686
+ "path": str(args.get("path", ""))[:200],
687
+ "content": self.redactor.redact(result.content)[:2000],
688
+ },
689
+ )
690
+ return result
691
+
692
+ def _gate(self, tool: str, args: dict[str, Any]) -> ToolResult | None:
693
+ """Return a failure result when an approval was refused."""
694
+ if tool in _EXEC_TOOLS:
695
+ command = args.get("command") or " ".join(args.get("argv") or [])
696
+ decision = self.policy.check_shell(str(command))
697
+ if decision.verdict is Verdict.DENY:
698
+ return ToolResult.failure(f"denied: {decision.reason}")
699
+ if decision.verdict is Verdict.ASK:
700
+ kind = self._risk_of(str(command))
701
+ if not self._approve(ApprovalRequest(kind, str(command), decision.reason)):
702
+ self._emit("denied", {"command": str(command)})
703
+ return ToolResult.failure(
704
+ f"the user denied this command: {decision.reason}. Choose another approach."
705
+ )
706
+ return None
707
+
708
+ @staticmethod
709
+ def _risk_of(command: str) -> str:
710
+ low = command.lower()
711
+ if "pip install" in low or "apt" in low or "conda install" in low:
712
+ return RiskClass.INSTALL.value
713
+ if "rm " in low or "mv " in low or "chmod" in low:
714
+ return RiskClass.DELETE.value
715
+ return RiskClass.NETWORK.value
716
+
717
+ # --------------------------------------------------------------- verify
718
+
719
+ def _verify_and_commit(self, result: AgentResult) -> bool:
720
+ """Run configured checks; commit only if they pass (FR-AGT-7)."""
721
+ assert self.state is not None
722
+ commands = self.cfg.verify.commands
723
+ if not commands:
724
+ self.state.last_verification = "no verify.commands configured"
725
+ return False
726
+
727
+ outputs: list[str] = []
728
+ all_ok = True
729
+ failure_digest = ""
730
+ for command in commands:
731
+ argv = shlex.split(command) if isinstance(command, str) else [str(a) for a in command]
732
+ label = command if isinstance(command, str) else " ".join(argv)
733
+ try:
734
+ tool_result = self.registry.call("run_tests", {"argv": argv, "command": label})
735
+ except (ToolError, ToolSchemaError):
736
+ # Older/minimal registries may not carry run_tests; the raw run
737
+ # is still correct, just noisier.
738
+ try:
739
+ tool_result = self.registry.call("run_shell", {"argv": argv})
740
+ except (ToolError, ToolSchemaError) as exc:
741
+ tool_result = ToolResult.failure(str(exc))
742
+ outputs.append(f"$ {label}\n{tool_result.content[-1200:]}")
743
+ if not tool_result.ok:
744
+ all_ok = False
745
+ # Keep the *parsed* failure, not the tail of the raw dump: the
746
+ # verdict and the failing test names sit at the end of a pytest
747
+ # run, and a flat cut throws away the traceback that explains it
748
+ # (§7.5, capped by verify.feedback_max_tokens).
749
+ failure_digest = summarise_pytest(
750
+ tool_result.content,
751
+ cap=int(self.cfg.verify.feedback_max_tokens * 4),
752
+ )
753
+ break
754
+
755
+ summary = "\n".join(outputs)
756
+ self.state.last_verification = summary
757
+ self._emit("verify", {"ok": all_ok, "summary": summary[-1500:]})
758
+
759
+ if not all_ok:
760
+ self._last_failure = failure_digest or summary[-1200:]
761
+ try:
762
+ self.budget.record_failure()
763
+ except BudgetExhausted:
764
+ raise
765
+ return False
766
+
767
+ self.budget.record_success()
768
+ # Passing checks complete the plan item that named them, which is what
769
+ # makes "done" a predicate rather than the model's opinion (FR-AGT-8).
770
+ self._tick_verification_steps()
771
+ self._commit_step(result, verify_summary=self._short_verify(commands))
772
+ return True
773
+
774
+ def _tick_verification_steps(self) -> None:
775
+ assert self.state is not None
776
+ plan = self.state.plan
777
+ for keyword in ("test", "check", "verify", "run", "lint"):
778
+ if plan.tick_matching(keyword):
779
+ break
780
+ plan.write(plan.title, plan.steps)
781
+
782
+ def _commit_step(self, result: AgentResult, *, verify_summary: str) -> None:
783
+ """Commit tracked changes with the full trailer set (FR-GIT-3)."""
784
+ assert self.state is not None
785
+ repo = self.session.repo
786
+ if not repo.is_dirty(ignore=(_PLAN_PREFIX, ".coagent/lock.json")):
787
+ # Nothing on disk changed, so there is no step to record.
788
+ return
789
+
790
+ # An artifact saved this step must be in the commit it is attributed to.
791
+ self._record_artifacts()
792
+
793
+ # Stage the work before committing. `git commit` does not stage for you:
794
+ # without this the commit fails with a bare exit 1 and empty stderr, and
795
+ # the step is silently lost.
796
+ self._stage_changes()
797
+ if not repo.staged_paths():
798
+ return
799
+
800
+ step = self.state.bump_step()
801
+ meta = CommitMeta(
802
+ session=self.session_id,
803
+ step=step,
804
+ task=self.state.task,
805
+ verify=verify_summary,
806
+ model=self.model_id,
807
+ artifacts=self.state.artifacts,
808
+ subject=f"coagent: {self.state.plan.title}"[:72],
809
+ body=self._commit_body(),
810
+ )
811
+ try:
812
+ sha = repo.commit_with_trailers(meta.default_subject(), meta.body, meta.trailers())
813
+ except (ValueError, GitError) as exc:
814
+ self._emit("commit_failed", {"error": str(exc)})
815
+ return
816
+
817
+ self.budget.record_commit()
818
+ self._emit("commit", {"sha": sha, "step": step})
819
+
820
+ # A commit is not a checkpoint; the sync is (DESIGN §3.2).
821
+ from ..gitx.durable import checkpoint
822
+
823
+ sync = checkpoint(
824
+ repo,
825
+ branch=self.branch,
826
+ session_id=self.session_id,
827
+ mode=self.cfg.durability.mode,
828
+ remote=self.cfg.durability.remote,
829
+ bundle_dir=self._bundle_dir(),
830
+ previous_tip=self._pending_sync_tip,
831
+ policy=self.policy,
832
+ )
833
+ self._pending_sync_tip = sha
834
+ result.sync = sync
835
+ self._emit("sync", {"ok": sync.ok, "message": sync.message})
836
+
837
+ def _stage_changes(self) -> None:
838
+ """Stage tracked and new work, but never coAgent's own runtime state."""
839
+ repo = self.session.repo
840
+ repo.add_all_tracked()
841
+ for path in repo.changed_paths():
842
+ if path.startswith(".coagent/"):
843
+ continue
844
+ try:
845
+ repo.add(path)
846
+ except GitError:
847
+ # A file deleted between listing and staging is not an error.
848
+ continue
849
+
850
+ def _commit_body(self) -> str:
851
+ assert self.state is not None
852
+ done = [s.text for s in self.state.plan.steps if s.done]
853
+ return "\n".join(f"- {d}" for d in done[:8]) if done else ""
854
+
855
+ def _short_verify(self, commands: list) -> str:
856
+ joined = "; ".join(c if isinstance(c, str) else " ".join(map(str, c)) for c in commands)
857
+ return f"{joined} (passed)"[:150]
858
+
859
+ def _final_checkpoint(self, result: AgentResult) -> None:
860
+ """Commit partial work so a stopped session is recoverable (FR-REL-1).
861
+
862
+ This commits even when checks failed, which is the point: the work is
863
+ saved rather than lost. The trailer records the real verification state
864
+ so a failing commit is never labelled as passing.
865
+ """
866
+ assert self.state is not None
867
+ try:
868
+ if not result.done and self.state.files_written:
869
+ verify = "failed" if self.state.last_verification and not result.done else "not run"
870
+ self._commit_step(result, verify_summary=f"{verify} (work in progress)")
871
+ except Exception as exc: # noqa: BLE001 - a checkpoint must not mask the real error
872
+ self._emit("final_checkpoint_failed", {"error": str(exc)})
873
+
874
+ # ---------------------------------------------------------------- helpers
875
+
876
+ def _record_artifacts(self) -> None:
877
+ """Copy the store's committed paths onto the session state.
878
+
879
+ `state.artifacts` feeds the `Coagent-Artifacts` trailer and the final
880
+ report. Only paths in *this* commit are named: a Drive pointer is not a
881
+ file in the tree, and a trailer that claims otherwise is a lie.
882
+ """
883
+ if self.state is None:
884
+ return
885
+ self.state.artifacts = self.artifacts.committed_paths()
886
+
887
+ def _persist_manifest(self) -> None:
888
+ """Touch the manifest so it exists even when nothing was committed.
889
+
890
+ A run whose artifacts all went to Drive still owes the user a manifest;
891
+ writing it here means the report and a later `git add` both find it.
892
+ """
893
+ try:
894
+ self.artifacts.flush()
895
+ except Exception as exc: # noqa: BLE001 - a manifest must not end a session
896
+ self._emit("artifact_manifest_degraded", {"error": str(exc)})
897
+ return
898
+ self._record_artifacts()
899
+
900
+ def _generate_text(
901
+ self,
902
+ messages: list[Message],
903
+ *,
904
+ temperature: float,
905
+ phase: str,
906
+ max_new_tokens: int | None = None,
907
+ ) -> str:
908
+ """Budget-checked generation with token accounting."""
909
+ limit = max_new_tokens or self.cfg.model.max_new_tokens
910
+ budget = TokenBudget(
911
+ max_context=self.cfg.model.max_context,
912
+ count=self.backend.count_tokens,
913
+ )
914
+ used = budget.check(messages, max_new_tokens=limit)
915
+ self.budget.record_tokens(used)
916
+
917
+ started = time.monotonic()
918
+ result = self.backend.generate(
919
+ messages,
920
+ GenerationConfig(
921
+ max_new_tokens=limit,
922
+ temperature=temperature,
923
+ top_p=self.cfg.model.top_p,
924
+ seed=self.cfg.model.seed,
925
+ ),
926
+ cancel=lambda: self._cancelled,
927
+ )
928
+ self.budget.record_gpu(time.monotonic() - started)
929
+ self.budget.record_tokens(result.usage.completion_tokens)
930
+
931
+ if phase == "survey":
932
+ self._last_notes = result.text
933
+ if result.truncated:
934
+ self._emit("truncated", {"phase": phase})
935
+ return result.text
936
+
937
+ def _parse_plan(self, text: str) -> list:
938
+ from .prompts import PlanStep
939
+
940
+ steps: list[PlanStep] = []
941
+ for line in text.splitlines():
942
+ stripped = line.strip()
943
+ if stripped.startswith(("- [ ]", "- [x]", "- [X]")):
944
+ steps.append(PlanStep(stripped[5:].strip(), done=stripped[3].lower() == "x"))
945
+ elif stripped.startswith(("- ", "* ")) and len(stripped) > 3:
946
+ steps.append(PlanStep(stripped[2:].strip()))
947
+ return steps
948
+
949
+ def _split_title(self, text: str, steps: list) -> tuple[str, list]:
950
+ """Use the plan's own H1; otherwise fall back to the task, not a step."""
951
+ for line in text.splitlines():
952
+ if line.strip().startswith("# "):
953
+ return line.strip()[2:].strip(), steps
954
+ assert self.state is not None
955
+ return self.state.task.strip()[:80] or "task", steps
956
+
957
+ def _fallback_plan(self) -> list:
958
+ from .prompts import PlanStep
959
+
960
+ assert self.state is not None
961
+ steps = [PlanStep(f"read {p}") for p in sorted(self.state.files_read)[:3]]
962
+ steps.append(PlanStep("make the change the task asks for"))
963
+ steps.append(PlanStep("run " + " and ".join(self.cfg.verify.commands)))
964
+ return steps
965
+
966
+ def _force_replan(self) -> None:
967
+ """Re-derive the plan from current state after a repeated action."""
968
+ assert self.state is not None
969
+ self.state.plan.read()
970
+ if not self.state.plan.steps:
971
+ self.state.plan.write(self.state.task, self._fallback_plan())
972
+
973
+ def _repo_map(self) -> str:
974
+ """Ranked, signature-only map of the repository (DESIGN §5.2).
975
+
976
+ The task text and the files this session has already touched are fed in
977
+ as ranking signals so the map leads with what matters. Budget comes from
978
+ the repo map's §5.1 slot; a repo with no Python degrades to a note
979
+ rather than an empty prompt.
980
+ """
981
+ mapper = self._mapper()
982
+ if mapper is None:
983
+ return "(repo map unavailable; explore with list_dir and grep)"
984
+ try:
985
+ return mapper.render()
986
+ except Exception as exc: # noqa: BLE001 - a map must never end a session
987
+ self._emit("repo_map_degraded", {"error": str(exc)})
988
+ return "(repo map unavailable; explore with list_dir and grep)"
989
+
990
+ def _mapper(self): # noqa: ANN202 - RepoMap, imported lazily
991
+ """Build the repo map once per session and reuse it.
992
+
993
+ Parsing is the only real cost in the context layer, and the ACT loop asks
994
+ for it every turn. The map is invalidated when a file is written, since
995
+ that is the only thing that can change a signature.
996
+ """
997
+ assert self.state is not None
998
+ from ..context.repo_map import RepoMap, RepoMapConfig
999
+
1000
+ written = frozenset(self.state.files_written)
1001
+ cached = getattr(self, "_map_cache", None)
1002
+ if cached is not None and cached[0] == written:
1003
+ return cached[1]
1004
+
1005
+ touched = set(self.state.files_written) | set(self.state.files_read)
1006
+ mapper = RepoMap(
1007
+ self.repo_path,
1008
+ task=self.state.task,
1009
+ recently_edited=touched,
1010
+ config=RepoMapConfig(
1011
+ max_tokens=self.cfg.context.repo_map_tokens,
1012
+ max_files=self.cfg.context.max_files,
1013
+ ),
1014
+ )
1015
+ self._map_cache = (written, mapper)
1016
+ return mapper
1017
+
1018
+ def _retrieve(self) -> str:
1019
+ """BM25 excerpts for the current plan step (DESIGN §5.3).
1020
+
1021
+ The query is the task plus the first unchecked plan item, because that is
1022
+ what the model is about to act on — retrieving against the whole task on
1023
+ every step would return the same five chunks for 40 turns. Files already
1024
+ read are excluded so the excerpt slot only ever shows new code.
1025
+ """
1026
+ assert self.state is not None
1027
+ mapper = self._mapper()
1028
+ if mapper is None:
1029
+ return ""
1030
+ try:
1031
+ from ..context.retrieval import RetrievalConfig, Retriever
1032
+
1033
+ query = self.state.task
1034
+ pending = next((s for s in self.state.plan.steps if not s.done), None)
1035
+ if pending is not None:
1036
+ query = f"{query} {pending.text}"
1037
+
1038
+ retriever = Retriever(
1039
+ mapper,
1040
+ config=RetrievalConfig(max_tokens=self.cfg.context.retrieved_tokens),
1041
+ )
1042
+ return retriever.render(query, in_context=set(self.state.files_read))
1043
+ except Exception as exc: # noqa: BLE001 - retrieval is an optimisation
1044
+ self._emit("retrieval_degraded", {"error": str(exc)})
1045
+ return ""
1046
+
1047
+ def _approve(self, request: ApprovalRequest) -> bool:
1048
+ if self.auto:
1049
+ self._emit("auto_approved", {"kind": request.kind})
1050
+ return True
1051
+ if self.approver is not None:
1052
+ return bool(self.approver(request))
1053
+ if not self.interactive:
1054
+ return False
1055
+ return self._prompt_yes_no(request.render())
1056
+
1057
+ def _prompt_text(self, question: str) -> str:
1058
+ try:
1059
+ return input(f"{question}\n> ").strip()
1060
+ except EOFError:
1061
+ return ""
1062
+
1063
+ def _prompt_yes_no(self, question: str) -> bool:
1064
+ try:
1065
+ answer = input(f"{question}\napprove? [y/N] ").strip().lower()
1066
+ except EOFError:
1067
+ return False
1068
+ return answer in {"y", "yes"}
1069
+
1070
+ def _bundle_dir(self) -> Path | None:
1071
+ if self.cfg.durability.mode != "bundle":
1072
+ return self.repo_path / ".coagent" / "bundles"
1073
+ return self.repo_path / ".coagent" / "bundles"
1074
+
1075
+ @staticmethod
1076
+ def _looks_like_envelope(text: str) -> bool:
1077
+ """True when the reply is a tool call rather than prose.
1078
+
1079
+ Uses the real parser so a fenced block is recognised as an envelope
1080
+ instead of being pasted into the report.
1081
+ """
1082
+ try:
1083
+ parse_envelope(text)
1084
+ except EnvelopeError:
1085
+ return False
1086
+ return True
1087
+
1088
+ def _static_report(self, result: AgentResult) -> str:
1089
+ assert self.state is not None
1090
+ verdict = "Done" if result.done else "Not done"
1091
+ blocked = result.blocked_reason or "nothing"
1092
+ return (
1093
+ f"1. Landed: {len(result.files_changed)} file(s) changed on {self.branch}.\n"
1094
+ f"2. Blocked: {blocked}\n"
1095
+ f"3. Verdict: {verdict}. Plan progress {self.state.plan.progress}.\n"
1096
+ "4. Next: review the branch diff, then run `coagent push`."
1097
+ )
1098
+
1099
+ def _emit(self, kind: str, data: dict[str, Any]) -> None:
1100
+ # A UI callback must never break the loop.
1101
+ with contextlib.suppress(Exception):
1102
+ self.on_event(kind, data)