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/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)
|