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
@@ -0,0 +1,307 @@
1
+ """Artifact tools: save to the repo/Drive and render in the transcript (FR-TOL-7).
2
+
3
+ Two tools, two jobs, one store:
4
+
5
+ * `save_artifact` — takes bytes the agent produced (a plot written by a cell, a
6
+ CSV from a script, a text report) and puts it through the §3.5 size policy. The
7
+ model never chooses the branch: it names a file, the store decides whether the
8
+ bytes are committed or pointed at by hash.
9
+ * `display_artifact` — renders an artifact inline in the notebook cell, so the
10
+ user sees the figure rather than a file path (FR-UX-4, US-3). The rendering is
11
+ done by an optional callback: on Colab it is `IPython.display`, on the CLI it is
12
+ a printed path. The loop must work with neither available, so a missing UI is a
13
+ normal result, not an error.
14
+
15
+ Why `save_artifact` refuses to invent content
16
+ ---------------------------------------------
17
+ The tool takes a *path* to something already on disk, or explicit text. It
18
+ deliberately cannot fabricate bytes from a description: an agent that can invent
19
+ a plot it never drew is worse than one that says "the cell produced nothing".
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import base64
25
+ import binascii
26
+ from collections.abc import Callable
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ from ..context.artifacts import ArtifactError, ArtifactRecord, ArtifactStore
31
+ from ..safety.policy import Verdict
32
+ from .base import ArgSpec, SafetyClass, ToolError, ToolResult
33
+ from .fs import _PolicyTool
34
+
35
+ __all__ = [
36
+ "SaveArtifactTool",
37
+ "DisplayArtifactTool",
38
+ "MAX_SAVE_BYTES",
39
+ "RenderCallback",
40
+ ]
41
+
42
+ #: Hard ceiling for one artifact. Matches the store's copy limit; anything larger
43
+ #: is a dataset that belongs where it already is.
44
+ MAX_SAVE_BYTES = 2 * 1024 * 1024 * 1024
45
+
46
+ RenderCallback = Callable[[str, str, str], None]
47
+
48
+
49
+ class _ArtifactTool(_PolicyTool):
50
+ """Shared plumbing: the store comes from the session, like the policy does."""
51
+
52
+ store: ArtifactStore
53
+
54
+ def __init__(self, session: Any) -> None:
55
+ super().__init__(session)
56
+ self.session = session
57
+ self.store = session.artifact_store
58
+
59
+ def _require_store(self) -> ArtifactStore:
60
+ if self.store is None: # pragma: no cover - build_registry always sets it
61
+ raise ToolError(
62
+ "no artifact store is configured for this session; artifact tools are "
63
+ "unavailable. Everything else still works."
64
+ )
65
+ return self.store
66
+
67
+
68
+ class SaveArtifactTool(_ArtifactTool):
69
+ name = "save_artifact"
70
+ description = (
71
+ "Save a produced file (plot, CSV, report, notebook output) as an artifact. "
72
+ "Give a `source` path for a file that already exists, or `text` for content "
73
+ "you have inline. Files under the commit cap are committed under "
74
+ "artifacts/<run>/; larger files go to Drive and are recorded by sha256. "
75
+ "Use this — not write_file — for anything a tool generated."
76
+ )
77
+ safety = SafetyClass.WRITE
78
+ args = (
79
+ ArgSpec("name", "string", "Artifact filename, e.g. 'loss.png' or 'bench.csv'."),
80
+ ArgSpec("source", "string", "Repo-relative or /tmp path of an existing file.", required=False, default=""),
81
+ ArgSpec("text", "string", "Inline text content, used only when `source` is absent.", required=False, default=""),
82
+ ArgSpec("mime", "string", "MIME type, e.g. image/png. Helps pick the extension.", required=False, default=""),
83
+ ArgSpec("subdir", "string", "Subdirectory inside the run dir, e.g. 'plots'.", required=False, default=""),
84
+ )
85
+
86
+ def run(
87
+ self,
88
+ name: str,
89
+ source: str = "",
90
+ text: str = "",
91
+ mime: str = "",
92
+ subdir: str = "",
93
+ ) -> ToolResult:
94
+ store = self._require_store()
95
+ if not source and not text:
96
+ raise ToolError(
97
+ "save_artifact needs either `source` (a file that already exists) or "
98
+ "`text`. It will not invent content: run the cell or script that "
99
+ "produces the file first."
100
+ )
101
+ if source and text:
102
+ raise ToolError("give `source` or `text`, not both.")
103
+
104
+ infer_mime = mime or _mime_for(name)
105
+ try:
106
+ if source:
107
+ path = self._artifact_source(source)
108
+ record = store.save_file(path, name=name, mime=infer_mime, subdir=subdir)
109
+ else:
110
+ record = store.save_text(text, name, mime=infer_mime or "text/plain", subdir=subdir)
111
+ except ArtifactError as exc:
112
+ raise ToolError(str(exc)) from exc
113
+
114
+ self.session.mark_written(store.dir / record.name)
115
+ return ToolResult.success(
116
+ self._describe(record),
117
+ artifact=record.path,
118
+ sha256=record.sha256,
119
+ store=record.store,
120
+ bytes=record.bytes,
121
+ committed=record.in_repo,
122
+ )
123
+
124
+ def _artifact_source(self, source: str) -> Path:
125
+ """A produced file may live in the repo or the scratch dir, nowhere else."""
126
+ resolved = self.session.resolve(source)
127
+ decision = self.session.policy.check_read(resolved)
128
+ if decision.verdict is Verdict.DENY:
129
+ raise ToolError(
130
+ f"cannot read {source} to save it: {decision.reason}. Write the file "
131
+ "inside the repo or .coagent/tmp first."
132
+ )
133
+ if not resolved.is_file():
134
+ raise ToolError(
135
+ f"{source} does not exist, so there is nothing to save. Produce the file "
136
+ "first (run the cell or script), then save it."
137
+ )
138
+ try:
139
+ size = resolved.stat().st_size
140
+ except OSError as exc:
141
+ raise ToolError(f"cannot stat {source}: {exc}") from exc
142
+ if size > MAX_SAVE_BYTES:
143
+ raise ToolError(
144
+ f"{source} is {size / 1024**3:.1f} GB, over the 2 GB artifact limit. "
145
+ "Keep it where it is and record the path instead."
146
+ )
147
+ if size == 0:
148
+ raise ToolError(
149
+ f"{source} is empty. An empty artifact is almost always a failed job; "
150
+ "check the cell or script that was meant to write it."
151
+ )
152
+ return resolved
153
+
154
+ @staticmethod
155
+ def _describe(record: ArtifactRecord) -> str:
156
+ where = (
157
+ "committed"
158
+ if record.in_repo
159
+ else f"not committed ({record.store})"
160
+ )
161
+ body = (
162
+ f"saved {record.path} ({record.human_size}, sha256 {record.sha256[:12]}, {where})"
163
+ )
164
+ if record.note:
165
+ body += f"\n{record.note}"
166
+ if not record.in_repo and record.location:
167
+ body += f"\nstored at: {record.location}"
168
+ return body
169
+
170
+
171
+ class DisplayArtifactTool(_ArtifactTool):
172
+ name = "display_artifact"
173
+ description = (
174
+ "Render an artifact inline in the notebook/transcript so the user can see it "
175
+ "(an image, HTML, or text). Use this after save_artifact when the user asked "
176
+ "for something visual. Passing nothing renders the whole run manifest."
177
+ )
178
+ safety = SafetyClass.UI
179
+ args = (
180
+ ArgSpec("path", "string", "Artifact path or an in-repo image path to render.", required=False, default=""),
181
+ ArgSpec("mime", "string", "Override the MIME type when it cannot be inferred.", required=False, default=""),
182
+ )
183
+
184
+ def run(self, path: str = "", mime: str = "") -> ToolResult:
185
+ store = self._require_store()
186
+ if not path:
187
+ return ToolResult.success(
188
+ store.manifest.render(),
189
+ shown=0,
190
+ manifest=str(store.manifest_path),
191
+ )
192
+
193
+ target, resolved_mime = self._locate(path, mime)
194
+ try:
195
+ data = target.read_bytes()
196
+ except OSError as exc:
197
+ raise ToolError(f"cannot read {path} to display it: {exc}") from exc
198
+
199
+ kind = _display_kind(resolved_mime)
200
+ renderer: RenderCallback | None = getattr(self.session, "render", None)
201
+ payload = _payload(data, kind)
202
+
203
+ if renderer is None:
204
+ # Headless (CLI, tests): the artifact is still on disk and referenced,
205
+ # which is all FR-UX-4 can promise without a notebook.
206
+ return ToolResult.success(
207
+ f"{path} ({resolved_mime or 'unknown type'}) is ready but there is no notebook "
208
+ "surface to render it in; open it at the path above.",
209
+ shown=0,
210
+ path=str(target),
211
+ mime=resolved_mime,
212
+ kind=kind,
213
+ )
214
+
215
+ try:
216
+ renderer(kind, payload, resolved_mime)
217
+ except Exception as exc: # noqa: BLE001 - a UI failure must not end a step
218
+ return ToolResult.success(
219
+ f"{path} was saved but the notebook could not render it ({exc}). "
220
+ "The file is still available at that path.",
221
+ shown=0,
222
+ path=str(target),
223
+ mime=resolved_mime,
224
+ error=str(exc),
225
+ )
226
+
227
+ return ToolResult.success(
228
+ f"rendered {path} ({resolved_mime or kind}) in the transcript",
229
+ shown=1,
230
+ path=str(target),
231
+ mime=resolved_mime,
232
+ kind=kind,
233
+ )
234
+
235
+ def _locate(self, path: str, mime: str) -> tuple[Path, str]:
236
+ """Find the artifact's bytes: in the run dir, or anywhere readable in jail."""
237
+ store = self._require_store()
238
+ candidate = Path(path)
239
+ resolved = candidate if candidate.is_absolute() else self.session.repo_root / candidate
240
+ resolved = resolved.resolve()
241
+
242
+ # A manifest `path` is repo-relative; a Drive artifact's bytes live at
243
+ # `location`. Prefer real bytes wherever they are.
244
+ for record in store.manifest.artifacts:
245
+ if path in {record.path, record.name}:
246
+ if record.location:
247
+ location = Path(record.location)
248
+ if location.is_file():
249
+ return location, mime or record.mime
250
+ break
251
+
252
+ decision = self.session.policy.check_read(resolved)
253
+ if decision.verdict is Verdict.DENY:
254
+ raise ToolError(f"cannot display {path}: {decision.reason}")
255
+ if not resolved.is_file():
256
+ raise ToolError(
257
+ f"{path} is not a saved artifact and does not exist. Use save_artifact "
258
+ "first, or pass a path inside the run's artifact directory."
259
+ )
260
+ return resolved, mime or _mime_for(resolved.name)
261
+
262
+
263
+ def _display_kind(mime: str) -> str:
264
+ if mime.startswith("image/"):
265
+ return "image"
266
+ if mime == "text/html":
267
+ return "html"
268
+ return "text"
269
+
270
+
271
+ def _payload(data: bytes, kind: str) -> str:
272
+ """Images travel as base64 so the renderer can be written as a string only."""
273
+ if kind == "image":
274
+ return base64.b64encode(data).decode("ascii")
275
+ try:
276
+ return data.decode("utf-8")
277
+ except UnicodeDecodeError:
278
+ raise ToolError(
279
+ "this artifact is binary and its type is not an image; it cannot be rendered "
280
+ "inline. The manifest still records it."
281
+ ) from None
282
+
283
+
284
+ _MIME_BY_EXT = {
285
+ ".png": "image/png",
286
+ ".jpg": "image/jpeg",
287
+ ".jpeg": "image/jpeg",
288
+ ".svg": "image/svg+xml",
289
+ ".html": "text/html",
290
+ ".htm": "text/html",
291
+ ".csv": "text/csv",
292
+ ".json": "application/json",
293
+ ".txt": "text/plain",
294
+ ".md": "text/plain",
295
+ }
296
+
297
+
298
+ def _mime_for(name: str) -> str:
299
+ return _MIME_BY_EXT.get(Path(name).suffix.lower(), "")
300
+
301
+
302
+ def decode_base64_lenient(text: str) -> bytes | None:
303
+ """Decode a data payload a cell reported, tolerating whitespace/newlines."""
304
+ try:
305
+ return base64.b64decode(text, validate=False)
306
+ except (binascii.Error, ValueError):
307
+ return None
coagent/tools/base.py ADDED
@@ -0,0 +1,226 @@
1
+ """Tool registry and the contract every tool implements (PRD FR-TOL-1).
2
+
3
+ The model may only do what is registered here, with only the arguments the
4
+ schema declares. Unknown tool names and unknown argument keys are rejected with
5
+ a corrective message rather than a crash — a malformed tool call costs a turn,
6
+ and the cheapest recovery is to tell the model exactly what was wrong.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from abc import ABC, abstractmethod
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, ClassVar
15
+
16
+ from ..safety.policy import SafetyClass
17
+
18
+ __all__ = [
19
+ "ArgSpec",
20
+ "BaseTool",
21
+ "ToolError",
22
+ "ToolRegistry",
23
+ "ToolResult",
24
+ "ToolSchemaError",
25
+ ]
26
+
27
+
28
+ class ToolError(RuntimeError):
29
+ """A tool ran but failed. The message is fed back to the model verbatim."""
30
+
31
+
32
+ class ToolSchemaError(ToolError):
33
+ """The call was malformed. Rejected before the tool body runs (FR-TOL-1)."""
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class ArgSpec:
38
+ """One declared argument."""
39
+
40
+ name: str
41
+ type: str # "string" | "integer" | "number" | "boolean" | "array" | "object"
42
+ description: str
43
+ required: bool = True
44
+ default: Any = None
45
+ enum: tuple[str, ...] | None = None
46
+
47
+ def json_schema(self) -> dict[str, Any]:
48
+ schema: dict[str, Any] = {"type": self.type, "description": self.description}
49
+ if self.enum:
50
+ schema["enum"] = list(self.enum)
51
+ if self.default is not None:
52
+ schema["default"] = self.default
53
+ return schema
54
+
55
+
56
+ @dataclass(slots=True)
57
+ class ToolResult:
58
+ """What a tool returns to the loop."""
59
+
60
+ ok: bool
61
+ content: str
62
+ tool: str = ""
63
+ # Structured extras for the UI (diffs, paths, images) — never sent to the model.
64
+ meta: dict[str, Any] = field(default_factory=dict)
65
+ truncated: bool = False
66
+
67
+ @classmethod
68
+ def success(cls, content: str, **meta: Any) -> ToolResult:
69
+ return cls(ok=True, content=content, meta=meta)
70
+
71
+ @classmethod
72
+ def failure(cls, content: str, **meta: Any) -> ToolResult:
73
+ return cls(ok=False, content=content, meta=meta)
74
+
75
+
76
+ class BaseTool(ABC):
77
+ """One registered action."""
78
+
79
+ name: ClassVar[str] = ""
80
+ description: ClassVar[str] = ""
81
+ safety: ClassVar[SafetyClass] = SafetyClass.READ
82
+ args: ClassVar[tuple[ArgSpec, ...]] = ()
83
+
84
+ def __init__(self, ctx: Any = None) -> None:
85
+ self.ctx = ctx
86
+
87
+ # ------------------------------------------------------------- schema
88
+
89
+ @classmethod
90
+ def parameters_schema(cls) -> dict[str, Any]:
91
+ props = {a.name: a.json_schema() for a in cls.args}
92
+ required = [a.name for a in cls.args if a.required and a.default is None]
93
+ return {"type": "object", "properties": props, "required": required}
94
+
95
+ @classmethod
96
+ def tool_schema(cls) -> dict[str, Any]:
97
+ return {
98
+ "name": cls.name,
99
+ "description": cls.description,
100
+ "safety": cls.safety.value,
101
+ "parameters": cls.parameters_schema(),
102
+ }
103
+
104
+ @classmethod
105
+ def validate_args(cls, args: dict[str, Any]) -> dict[str, Any]:
106
+ """Reject unknown keys and missing required args with a corrective message."""
107
+ if not isinstance(args, dict):
108
+ raise ToolSchemaError(
109
+ f"arguments for {cls.name} must be an object, got {type(args).__name__}"
110
+ )
111
+ known = {a.name for a in cls.args}
112
+ unknown = set(args) - known
113
+ if unknown:
114
+ raise ToolSchemaError(
115
+ f"unknown argument(s) for {cls.name}: {', '.join(sorted(unknown))}. "
116
+ f"Valid arguments are: {', '.join(sorted(known)) or '(none)'}."
117
+ )
118
+ cleaned: dict[str, Any] = {}
119
+ for spec in cls.args:
120
+ if spec.name not in args:
121
+ if spec.required and spec.default is None:
122
+ raise ToolSchemaError(
123
+ f"missing required argument {spec.name!r} for {cls.name}."
124
+ )
125
+ cleaned[spec.name] = spec.default
126
+ continue
127
+ cleaned[spec.name] = _coerce(cls.name, spec, args[spec.name])
128
+ return cleaned
129
+
130
+ # --------------------------------------------------------------- body
131
+
132
+ @abstractmethod
133
+ def run(self, **kwargs: Any) -> ToolResult:
134
+ """Execute the tool. Raise `ToolError` for an expected failure."""
135
+
136
+
137
+ def _coerce(tool: str, spec: ArgSpec, value: Any) -> Any:
138
+ """Check the declared type, correcting the common JSON mistakes."""
139
+ want = spec.type
140
+ if want == "string":
141
+ if not isinstance(value, str):
142
+ raise ToolSchemaError(f"{tool}.{spec.name} must be a string, got {type(value).__name__}")
143
+ if spec.enum and value not in spec.enum:
144
+ raise ToolSchemaError(
145
+ f"{tool}.{spec.name} must be one of {list(spec.enum)}, got {value!r}"
146
+ )
147
+ return value
148
+ if want == "boolean":
149
+ if isinstance(value, bool):
150
+ return value
151
+ if isinstance(value, str) and value.lower() in {"true", "false"}:
152
+ return value.lower() == "true"
153
+ raise ToolSchemaError(f"{tool}.{spec.name} must be a boolean")
154
+ if want == "integer":
155
+ if isinstance(value, bool):
156
+ raise ToolSchemaError(f"{tool}.{spec.name} must be an integer")
157
+ if isinstance(value, int):
158
+ return value
159
+ if isinstance(value, str) and value.strip().lstrip("-").isdigit():
160
+ return int(value)
161
+ raise ToolSchemaError(f"{tool}.{spec.name} must be an integer")
162
+ if want == "number":
163
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
164
+ raise ToolSchemaError(f"{tool}.{spec.name} must be a number")
165
+ return float(value)
166
+ if want == "array":
167
+ if not isinstance(value, list):
168
+ raise ToolSchemaError(f"{tool}.{spec.name} must be an array")
169
+ return value
170
+ if want == "object":
171
+ if not isinstance(value, dict):
172
+ raise ToolSchemaError(f"{tool}.{spec.name} must be an object")
173
+ return value
174
+ return value
175
+
176
+
177
+ class ToolRegistry:
178
+ """The set of tools available in the current phase."""
179
+
180
+ def __init__(self, tools: list[BaseTool] | None = None) -> None:
181
+ self._tools: dict[str, BaseTool] = {}
182
+ for tool in tools or []:
183
+ self.register(tool)
184
+
185
+ def register(self, tool: BaseTool) -> None:
186
+ if not tool.name:
187
+ raise ValueError(f"{type(tool).__name__} has no name")
188
+ if tool.name in self._tools:
189
+ raise ValueError(f"duplicate tool name {tool.name!r}")
190
+ self._tools[tool.name] = tool
191
+
192
+ def get(self, name: str) -> BaseTool:
193
+ try:
194
+ return self._tools[name]
195
+ except KeyError:
196
+ raise ToolSchemaError(
197
+ f"unknown tool {name!r}. Available tools: {', '.join(sorted(self._tools)) or '(none)'}."
198
+ ) from None
199
+
200
+ def __contains__(self, name: object) -> bool:
201
+ return name in self._tools
202
+
203
+ def __len__(self) -> int:
204
+ return len(self._tools)
205
+
206
+ def names(self) -> list[str]:
207
+ return sorted(self._tools)
208
+
209
+ def schemas(self, *, safety: set[SafetyClass] | None = None) -> list[dict[str, Any]]:
210
+ """JSON tool schemas, optionally filtered to a phase's allowed classes."""
211
+ return [
212
+ t.tool_schema()
213
+ for t in self._tools.values()
214
+ if safety is None or t.safety in safety
215
+ ]
216
+
217
+ def schemas_json(self, *, safety: set[SafetyClass] | None = None) -> str:
218
+ return json.dumps(self.schemas(safety=safety), indent=None, separators=(",", ":"))
219
+
220
+ def call(self, name: str, args: dict[str, Any]) -> ToolResult:
221
+ """Validate then dispatch. Schema errors never reach the tool body."""
222
+ tool = self.get(name)
223
+ cleaned = tool.validate_args(args)
224
+ result = tool.run(**cleaned)
225
+ result.tool = name
226
+ return result