bareagent-cli 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.
- bareagent/__init__.py +10 -0
- bareagent/concurrency/__init__.py +6 -0
- bareagent/concurrency/background.py +97 -0
- bareagent/concurrency/notification.py +61 -0
- bareagent/concurrency/scheduler.py +136 -0
- bareagent/config.toml +299 -0
- bareagent/core/__init__.py +1 -0
- bareagent/core/config_paths.py +49 -0
- bareagent/core/context.py +127 -0
- bareagent/core/fileutil.py +103 -0
- bareagent/core/goal.py +214 -0
- bareagent/core/handlers/__init__.py +1 -0
- bareagent/core/handlers/bash.py +79 -0
- bareagent/core/handlers/file_edit.py +47 -0
- bareagent/core/handlers/file_read.py +270 -0
- bareagent/core/handlers/file_write.py +34 -0
- bareagent/core/handlers/glob_search.py +30 -0
- bareagent/core/handlers/goal.py +60 -0
- bareagent/core/handlers/grep_search.py +52 -0
- bareagent/core/handlers/memory.py +71 -0
- bareagent/core/handlers/plan.py +106 -0
- bareagent/core/handlers/search_utils.py +77 -0
- bareagent/core/handlers/skill.py +87 -0
- bareagent/core/handlers/subagent_send.py +70 -0
- bareagent/core/handlers/web_fetch.py +126 -0
- bareagent/core/handlers/web_search.py +165 -0
- bareagent/core/handlers/workflow.py +190 -0
- bareagent/core/loop.py +535 -0
- bareagent/core/retry.py +131 -0
- bareagent/core/sandbox.py +27 -0
- bareagent/core/schema.py +21 -0
- bareagent/core/tools.py +779 -0
- bareagent/core/workflow.py +517 -0
- bareagent/core/workflow_registry.py +219 -0
- bareagent/debug/__init__.py +0 -0
- bareagent/debug/interaction_log.py +263 -0
- bareagent/debug/viewer.html +1750 -0
- bareagent/debug/web_viewer.py +157 -0
- bareagent/hooks/__init__.py +32 -0
- bareagent/hooks/config.py +118 -0
- bareagent/hooks/engine.py +197 -0
- bareagent/hooks/errors.py +14 -0
- bareagent/hooks/events.py +22 -0
- bareagent/lsp/__init__.py +63 -0
- bareagent/lsp/config.py +134 -0
- bareagent/lsp/coord.py +118 -0
- bareagent/lsp/diagnostics.py +240 -0
- bareagent/lsp/errors.py +24 -0
- bareagent/lsp/manager.py +866 -0
- bareagent/lsp/tools.py +629 -0
- bareagent/lsp/workspace_edit.py +305 -0
- bareagent/main.py +4205 -0
- bareagent/mcp/__init__.py +69 -0
- bareagent/mcp/_sse.py +69 -0
- bareagent/mcp/client.py +341 -0
- bareagent/mcp/config.py +169 -0
- bareagent/mcp/errors.py +32 -0
- bareagent/mcp/manager.py +318 -0
- bareagent/mcp/protocol.py +187 -0
- bareagent/mcp/registry.py +557 -0
- bareagent/mcp/transport/__init__.py +15 -0
- bareagent/mcp/transport/base.py +149 -0
- bareagent/mcp/transport/http_legacy.py +192 -0
- bareagent/mcp/transport/http_streamable.py +217 -0
- bareagent/mcp/transport/stdio.py +202 -0
- bareagent/memory/__init__.py +1 -0
- bareagent/memory/compact.py +203 -0
- bareagent/memory/conversation_io.py +226 -0
- bareagent/memory/embedding.py +194 -0
- bareagent/memory/persistent.py +515 -0
- bareagent/memory/token_counter.py +67 -0
- bareagent/memory/token_tracker.py +262 -0
- bareagent/memory/transcript.py +100 -0
- bareagent/permission/__init__.py +1 -0
- bareagent/permission/guard.py +329 -0
- bareagent/permission/rules.py +19 -0
- bareagent/planning/__init__.py +19 -0
- bareagent/planning/agent_types.py +169 -0
- bareagent/planning/skill_gen.py +141 -0
- bareagent/planning/skill_store.py +173 -0
- bareagent/planning/skills.py +146 -0
- bareagent/planning/subagent.py +355 -0
- bareagent/planning/subagent_registry.py +77 -0
- bareagent/planning/tasks.py +348 -0
- bareagent/planning/todo.py +153 -0
- bareagent/planning/worktree.py +122 -0
- bareagent/provider/__init__.py +1 -0
- bareagent/provider/anthropic.py +348 -0
- bareagent/provider/base.py +136 -0
- bareagent/provider/factory.py +130 -0
- bareagent/provider/openai.py +881 -0
- bareagent/provider/presets.py +72 -0
- bareagent/provider/setup.py +356 -0
- bareagent/skills/.gitkeep +1 -0
- bareagent/skills/code-review/SKILL.md +68 -0
- bareagent/skills/git/SKILL.md +68 -0
- bareagent/skills/test/SKILL.md +70 -0
- bareagent/team/__init__.py +17 -0
- bareagent/team/autonomous.py +193 -0
- bareagent/team/mailbox.py +239 -0
- bareagent/team/manager.py +155 -0
- bareagent/team/protocols.py +129 -0
- bareagent/tracing/__init__.py +12 -0
- bareagent/tracing/_api.py +92 -0
- bareagent/tracing/_proxy.py +60 -0
- bareagent/tracing/composite.py +115 -0
- bareagent/tracing/json_file.py +115 -0
- bareagent/tracing/langfuse.py +139 -0
- bareagent/tracing/otel.py +107 -0
- bareagent/tracing/setup.py +85 -0
- bareagent/ui/__init__.py +24 -0
- bareagent/ui/console.py +167 -0
- bareagent/ui/prompt.py +78 -0
- bareagent/ui/protocol.py +24 -0
- bareagent/ui/stream.py +66 -0
- bareagent/ui/theme.py +240 -0
- bareagent_cli-0.1.0.dist-info/METADATA +331 -0
- bareagent_cli-0.1.0.dist-info/RECORD +121 -0
- bareagent_cli-0.1.0.dist-info/WHEEL +4 -0
- bareagent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- bareagent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from bareagent.core.sandbox import safe_path
|
|
9
|
+
|
|
10
|
+
# Image extension -> Anthropic-supported mime type. The whitelist mirrors
|
|
11
|
+
# ``mcp/registry.py:_SUPPORTED_IMAGE_MIME_TYPES`` so local image reads produce
|
|
12
|
+
# the same internal block shape MCP multimodal results do.
|
|
13
|
+
_IMAGE_EXT_TO_MIME: dict[str, str] = {
|
|
14
|
+
".png": "image/png",
|
|
15
|
+
".jpg": "image/jpeg",
|
|
16
|
+
".jpeg": "image/jpeg",
|
|
17
|
+
".gif": "image/gif",
|
|
18
|
+
".webp": "image/webp",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# Hard cap on image payloads. Aligned with the MCP default
|
|
22
|
+
# ``max_result_binary_bytes`` (5 MiB). Over-limit images return an Error string
|
|
23
|
+
# (D2: ask the user to downscale; we do not auto-resize — that needs Pillow).
|
|
24
|
+
_MAX_IMAGE_BYTES = 5_242_880 # 5 MiB
|
|
25
|
+
|
|
26
|
+
# Per-output and whole-document length caps for textual renderings (notebook
|
|
27
|
+
# cell outputs, PDF text). Keeps a pathological file from flooding the context.
|
|
28
|
+
_MAX_CELL_OUTPUT_CHARS = 2000
|
|
29
|
+
_MAX_DOCUMENT_CHARS = 200_000
|
|
30
|
+
|
|
31
|
+
_TRUNCATED_MARKER = "... [truncated]"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run_read(
|
|
35
|
+
file_path: str,
|
|
36
|
+
offset: int = 0,
|
|
37
|
+
limit: int | None = None,
|
|
38
|
+
*,
|
|
39
|
+
pages: str | None = None,
|
|
40
|
+
workspace: Path,
|
|
41
|
+
) -> str | list[dict[str, Any]]:
|
|
42
|
+
"""Read a workspace file, dispatching by extension.
|
|
43
|
+
|
|
44
|
+
- Images (.png/.jpg/.jpeg/.gif/.webp) -> ``[text, image]`` content blocks.
|
|
45
|
+
- PDF (.pdf) -> extracted text (optional ``pages`` range); needs the
|
|
46
|
+
``[pdf]`` extra (pypdf).
|
|
47
|
+
- Notebook (.ipynb) -> text rendering of markdown/code cells + outputs.
|
|
48
|
+
- Everything else -> the legacy UTF-8 text path with line numbers
|
|
49
|
+
(``offset`` / ``limit`` slice).
|
|
50
|
+
"""
|
|
51
|
+
if offset < 0:
|
|
52
|
+
raise ValueError("offset must be >= 0")
|
|
53
|
+
if limit is not None and limit < 0:
|
|
54
|
+
raise ValueError("limit must be >= 0")
|
|
55
|
+
|
|
56
|
+
resolved = safe_path(file_path, workspace)
|
|
57
|
+
suffix = resolved.suffix.lower()
|
|
58
|
+
|
|
59
|
+
if suffix in _IMAGE_EXT_TO_MIME:
|
|
60
|
+
return _read_image(resolved, _IMAGE_EXT_TO_MIME[suffix])
|
|
61
|
+
if suffix == ".pdf":
|
|
62
|
+
return _read_pdf(resolved, pages)
|
|
63
|
+
if suffix == ".ipynb":
|
|
64
|
+
return _read_notebook(resolved)
|
|
65
|
+
|
|
66
|
+
return _read_text(resolved, offset, limit)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _read_text(resolved: Path, offset: int, limit: int | None) -> str:
|
|
70
|
+
"""Read a UTF-8 text file and prefix each line with its line number."""
|
|
71
|
+
if offset > 0 or limit is not None:
|
|
72
|
+
# Stream lines to avoid loading the entire file when only a slice is needed.
|
|
73
|
+
selected: list[str] = []
|
|
74
|
+
end = None if limit is None else offset + limit
|
|
75
|
+
with resolved.open(encoding="utf-8") as fh:
|
|
76
|
+
for idx, line in enumerate(fh):
|
|
77
|
+
if idx < offset:
|
|
78
|
+
continue
|
|
79
|
+
if end is not None and idx >= end:
|
|
80
|
+
break
|
|
81
|
+
selected.append(line.rstrip("\n\r"))
|
|
82
|
+
else:
|
|
83
|
+
selected = resolved.read_text(encoding="utf-8").splitlines()
|
|
84
|
+
|
|
85
|
+
return "\n".join(
|
|
86
|
+
f"{line_number}: {line}" for line_number, line in enumerate(selected, start=offset + 1)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _read_image(resolved: Path, mime: str) -> str | list[dict[str, Any]]:
|
|
91
|
+
"""Read an image as base64 and return ``[text, image]`` content blocks."""
|
|
92
|
+
try:
|
|
93
|
+
raw = resolved.read_bytes()
|
|
94
|
+
except OSError as exc:
|
|
95
|
+
return f"Error: cannot read image {resolved.name!r}: {exc}"
|
|
96
|
+
size = len(raw)
|
|
97
|
+
if size > _MAX_IMAGE_BYTES:
|
|
98
|
+
return (
|
|
99
|
+
f"Error: image {resolved.name!r} is {size} bytes, exceeding the "
|
|
100
|
+
f"{_MAX_IMAGE_BYTES} byte limit. Downscale or compress it before reading."
|
|
101
|
+
)
|
|
102
|
+
data = base64.b64encode(raw).decode("ascii")
|
|
103
|
+
description = f"Image {resolved.name} ({mime}, {size} bytes)"
|
|
104
|
+
return [
|
|
105
|
+
{"type": "text", "text": description},
|
|
106
|
+
{
|
|
107
|
+
"type": "image",
|
|
108
|
+
"source": {
|
|
109
|
+
"type": "base64",
|
|
110
|
+
"media_type": mime,
|
|
111
|
+
"data": data,
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _read_notebook(resolved: Path) -> str:
|
|
118
|
+
"""Render a Jupyter notebook's markdown/code cells (+ outputs) as text."""
|
|
119
|
+
try:
|
|
120
|
+
raw = resolved.read_text(encoding="utf-8")
|
|
121
|
+
except OSError as exc:
|
|
122
|
+
return f"Error: cannot read notebook {resolved.name!r}: {exc}"
|
|
123
|
+
try:
|
|
124
|
+
nb = json.loads(raw)
|
|
125
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
126
|
+
return f"Error: notebook {resolved.name!r} is not valid JSON: {exc}"
|
|
127
|
+
|
|
128
|
+
cells = nb.get("cells") if isinstance(nb, dict) else None
|
|
129
|
+
if not isinstance(cells, list):
|
|
130
|
+
return f"Error: notebook {resolved.name!r} has no 'cells' array"
|
|
131
|
+
|
|
132
|
+
parts: list[str] = []
|
|
133
|
+
for index, cell in enumerate(cells, start=1):
|
|
134
|
+
if not isinstance(cell, dict):
|
|
135
|
+
continue
|
|
136
|
+
cell_type = cell.get("cell_type")
|
|
137
|
+
source = _join_source(cell.get("source"))
|
|
138
|
+
if cell_type == "markdown":
|
|
139
|
+
parts.append(f"## Markdown cell {index}\n{source}")
|
|
140
|
+
elif cell_type == "code":
|
|
141
|
+
block = f"## Code cell {index}\n{source}"
|
|
142
|
+
outputs = _render_outputs(cell.get("outputs"))
|
|
143
|
+
if outputs:
|
|
144
|
+
block += f"\n### Output\n{outputs}"
|
|
145
|
+
parts.append(block)
|
|
146
|
+
# Other cell types (e.g. raw) are skipped.
|
|
147
|
+
|
|
148
|
+
rendered = "\n\n".join(parts)
|
|
149
|
+
return _cap_document(rendered)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _join_source(source: Any) -> str:
|
|
153
|
+
"""Notebook ``source`` is a list of lines or a single string."""
|
|
154
|
+
if isinstance(source, list):
|
|
155
|
+
return "".join(str(item) for item in source)
|
|
156
|
+
if isinstance(source, str):
|
|
157
|
+
return source
|
|
158
|
+
return ""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _render_outputs(outputs: Any) -> str:
|
|
162
|
+
"""Extract human-readable text from a code cell's ``outputs`` array."""
|
|
163
|
+
if not isinstance(outputs, list):
|
|
164
|
+
return ""
|
|
165
|
+
rendered: list[str] = []
|
|
166
|
+
for output in outputs:
|
|
167
|
+
if not isinstance(output, dict):
|
|
168
|
+
continue
|
|
169
|
+
output_type = output.get("output_type")
|
|
170
|
+
text = ""
|
|
171
|
+
if output_type == "stream":
|
|
172
|
+
text = _join_source(output.get("text"))
|
|
173
|
+
elif output_type in ("execute_result", "display_data"):
|
|
174
|
+
data = output.get("data")
|
|
175
|
+
if isinstance(data, dict):
|
|
176
|
+
text = _join_source(data.get("text/plain"))
|
|
177
|
+
elif output_type == "error":
|
|
178
|
+
traceback = output.get("traceback")
|
|
179
|
+
if isinstance(traceback, list):
|
|
180
|
+
text = "\n".join(str(line) for line in traceback)
|
|
181
|
+
if text:
|
|
182
|
+
rendered.append(_truncate(text, _MAX_CELL_OUTPUT_CHARS))
|
|
183
|
+
return "\n".join(rendered)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _read_pdf(resolved: Path, pages: str | None) -> str:
|
|
187
|
+
"""Extract text from a PDF. Needs the ``[pdf]`` extra (pypdf)."""
|
|
188
|
+
try:
|
|
189
|
+
from pypdf import PdfReader # type: ignore[import-untyped]
|
|
190
|
+
except ImportError:
|
|
191
|
+
return (
|
|
192
|
+
'Error: PDF support is not installed. Run uv pip install -e ".[pdf]" '
|
|
193
|
+
"(or pip install pypdf)."
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
reader = PdfReader(str(resolved))
|
|
198
|
+
except Exception as exc: # noqa: BLE001 - pypdf raises a variety of types
|
|
199
|
+
return f"Error: cannot open PDF {resolved.name!r}: {type(exc).__name__}: {exc}"
|
|
200
|
+
|
|
201
|
+
total = len(reader.pages)
|
|
202
|
+
if total == 0:
|
|
203
|
+
return f"Error: PDF {resolved.name!r} has no pages"
|
|
204
|
+
|
|
205
|
+
selection = _parse_page_range(pages, total)
|
|
206
|
+
if isinstance(selection, str):
|
|
207
|
+
return selection # error message
|
|
208
|
+
|
|
209
|
+
parts: list[str] = []
|
|
210
|
+
for page_no in selection:
|
|
211
|
+
try:
|
|
212
|
+
text = reader.pages[page_no].extract_text() or ""
|
|
213
|
+
except Exception as exc: # noqa: BLE001 - extraction can fail per page
|
|
214
|
+
text = f"[Error extracting text: {type(exc).__name__}: {exc}]"
|
|
215
|
+
parts.append(f"--- Page {page_no + 1} ---\n{text}")
|
|
216
|
+
|
|
217
|
+
return _cap_document("\n\n".join(parts))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _parse_page_range(pages: str | None, total: int) -> list[int] | str:
|
|
221
|
+
"""Parse a 1-based user page spec into a list of 0-based indices.
|
|
222
|
+
|
|
223
|
+
Accepts ``None`` (all pages), ``"3"`` (single page) and ``"1-5"`` (range).
|
|
224
|
+
A range's ``end`` past the last page is clamped, but a ``start`` past the
|
|
225
|
+
last page is an explicit error (mirroring the single-page branch) rather
|
|
226
|
+
than silently returning a different page. Returns an Error string on bad
|
|
227
|
+
input.
|
|
228
|
+
"""
|
|
229
|
+
if pages is None:
|
|
230
|
+
return list(range(total))
|
|
231
|
+
spec = pages.strip()
|
|
232
|
+
if not spec:
|
|
233
|
+
return list(range(total))
|
|
234
|
+
|
|
235
|
+
if "-" in spec:
|
|
236
|
+
start_str, _, end_str = spec.partition("-")
|
|
237
|
+
try:
|
|
238
|
+
start = int(start_str)
|
|
239
|
+
end = int(end_str)
|
|
240
|
+
except ValueError:
|
|
241
|
+
return f"Error: invalid page range {pages!r} (expected e.g. '1-5' or '3')"
|
|
242
|
+
if start < 1 or end < 1 or start > end:
|
|
243
|
+
return f"Error: invalid page range {pages!r}"
|
|
244
|
+
if start > total:
|
|
245
|
+
return f"Error: page range start {start} out of range (PDF has {total} pages)"
|
|
246
|
+
start_idx = start - 1
|
|
247
|
+
end_idx = min(end, total)
|
|
248
|
+
return list(range(start_idx, end_idx))
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
single = int(spec)
|
|
252
|
+
except ValueError:
|
|
253
|
+
return f"Error: invalid page range {pages!r} (expected e.g. '1-5' or '3')"
|
|
254
|
+
if single < 1:
|
|
255
|
+
return f"Error: invalid page number {pages!r}"
|
|
256
|
+
if single > total:
|
|
257
|
+
return f"Error: page {single} out of range (PDF has {total} pages)"
|
|
258
|
+
return [single - 1]
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _truncate(text: str, max_chars: int) -> str:
|
|
262
|
+
if len(text) <= max_chars:
|
|
263
|
+
return text
|
|
264
|
+
return text[:max_chars] + _TRUNCATED_MARKER
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _cap_document(text: str) -> str:
|
|
268
|
+
if len(text) <= _MAX_DOCUMENT_CHARS:
|
|
269
|
+
return text
|
|
270
|
+
return text[:_MAX_DOCUMENT_CHARS] + f"\n{_TRUNCATED_MARKER}"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from bareagent.core.sandbox import safe_path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run_write(
|
|
11
|
+
file_path: str,
|
|
12
|
+
content: str,
|
|
13
|
+
*,
|
|
14
|
+
workspace: Path,
|
|
15
|
+
diagnostics_hook: Callable[[str, Any], str | None] | None = None,
|
|
16
|
+
) -> str:
|
|
17
|
+
"""Write content to a workspace file, creating parent directories when needed.
|
|
18
|
+
|
|
19
|
+
``diagnostics_hook`` (Hybrid auto-diagnostics) follows the same contract
|
|
20
|
+
as :func:`run_edit`: the handler snapshots before, performs the write,
|
|
21
|
+
then asks the hook for an appendix. Returning ``None`` from either call
|
|
22
|
+
leaves the result untouched.
|
|
23
|
+
"""
|
|
24
|
+
resolved = safe_path(file_path, workspace)
|
|
25
|
+
resolved.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
before = diagnostics_hook(str(resolved), None) if diagnostics_hook else None
|
|
27
|
+
resolved.write_text(content, encoding="utf-8")
|
|
28
|
+
relative = resolved.relative_to(workspace.resolve(strict=False))
|
|
29
|
+
result = f"Wrote {len(content)} characters to {relative.as_posix()}"
|
|
30
|
+
if diagnostics_hook is not None:
|
|
31
|
+
appendix = diagnostics_hook(str(resolved), before)
|
|
32
|
+
if appendix:
|
|
33
|
+
result = f"{result}{appendix}"
|
|
34
|
+
return result
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from bareagent.core.handlers.search_utils import (
|
|
6
|
+
iter_search_files,
|
|
7
|
+
matches_glob_pattern,
|
|
8
|
+
)
|
|
9
|
+
from bareagent.core.sandbox import safe_path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run_glob(pattern: str, path: str = ".", *, workspace: Path) -> list[str]:
|
|
13
|
+
"""Return workspace-relative paths that match a glob pattern."""
|
|
14
|
+
workspace_path = workspace.resolve(strict=False)
|
|
15
|
+
search_root = safe_path(path, workspace_path)
|
|
16
|
+
|
|
17
|
+
if search_root.is_file():
|
|
18
|
+
candidates = [search_root]
|
|
19
|
+
else:
|
|
20
|
+
candidates = list(iter_search_files(search_root))
|
|
21
|
+
|
|
22
|
+
matches: list[str] = []
|
|
23
|
+
for candidate in candidates:
|
|
24
|
+
resolved = candidate.resolve(strict=False)
|
|
25
|
+
if not resolved.is_relative_to(workspace_path):
|
|
26
|
+
continue
|
|
27
|
+
if not matches_glob_pattern(resolved, search_root, pattern):
|
|
28
|
+
continue
|
|
29
|
+
matches.append(resolved.relative_to(workspace_path).as_posix())
|
|
30
|
+
return sorted(dict.fromkeys(matches))
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Handler + schema for the ``goal_verdict`` tool (goal-completion evaluator).
|
|
2
|
+
|
|
3
|
+
Like ``skill_create``, ``goal_verdict`` is NOT registered in the global tool set.
|
|
4
|
+
It is exposed only inside the isolated evaluator ``agent_loop`` call that runs
|
|
5
|
+
after each turn of a ``/goal`` loop (see ``main.py`` and ``src/core/goal.py``).
|
|
6
|
+
Keeping it out of the global set means the main loop never offers it and
|
|
7
|
+
sub-agents never receive it (isolation, same stance as ``skill_create`` /
|
|
8
|
+
``hook_engine``).
|
|
9
|
+
|
|
10
|
+
The handler is a thin shim: it parses the model-supplied fields into a
|
|
11
|
+
:class:`bareagent.core.goal.Verdict` (delegating coercion to ``goal.parse_verdict``)
|
|
12
|
+
and records it into a caller-provided ``sink`` list as a side effect, mirroring
|
|
13
|
+
how ``skill_create`` persists via its store. The caller reads ``sink`` after the
|
|
14
|
+
isolated evaluator loop returns. Returning a plain confirmation string keeps the
|
|
15
|
+
evaluator loop from crashing (see ``error-handling.md``).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from bareagent.core.goal import Verdict, parse_verdict
|
|
21
|
+
from bareagent.core.schema import tool_schema
|
|
22
|
+
|
|
23
|
+
GOAL_VERDICT_TOOL_SCHEMA = tool_schema(
|
|
24
|
+
"goal_verdict",
|
|
25
|
+
(
|
|
26
|
+
"Report whether the goal completion condition is now fully satisfied, "
|
|
27
|
+
"judging only from the conversation. Call exactly once."
|
|
28
|
+
),
|
|
29
|
+
{
|
|
30
|
+
"met": {
|
|
31
|
+
"type": "boolean",
|
|
32
|
+
"description": "True only if the transcript verifiably satisfies the condition.",
|
|
33
|
+
},
|
|
34
|
+
"reason": {
|
|
35
|
+
"type": "string",
|
|
36
|
+
"description": (
|
|
37
|
+
"Concrete justification. If not met, name what is still missing "
|
|
38
|
+
"and what the agent should do next."
|
|
39
|
+
),
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
["met", "reason"],
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run_goal_verdict(
|
|
47
|
+
*,
|
|
48
|
+
sink: list[Verdict],
|
|
49
|
+
met: object = None,
|
|
50
|
+
reason: object = None,
|
|
51
|
+
) -> str:
|
|
52
|
+
"""Record the evaluator's verdict into ``sink`` and confirm to the model.
|
|
53
|
+
|
|
54
|
+
``sink`` is a one-element accumulator the caller reads after the isolated
|
|
55
|
+
evaluator loop returns. Coercion/validation lives in ``goal.parse_verdict``,
|
|
56
|
+
so this stays a thin shim that never raises.
|
|
57
|
+
"""
|
|
58
|
+
verdict = parse_verdict({"met": met, "reason": reason})
|
|
59
|
+
sink.append(verdict)
|
|
60
|
+
return "Verdict recorded."
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from bareagent.core.handlers.search_utils import iter_search_files
|
|
7
|
+
from bareagent.core.sandbox import safe_path
|
|
8
|
+
|
|
9
|
+
MAX_MATCHES = 1000
|
|
10
|
+
MAX_FILE_SIZE = 1_048_576 # 1 MB
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_grep(
|
|
14
|
+
pattern: str,
|
|
15
|
+
path: str = ".",
|
|
16
|
+
include: str = "",
|
|
17
|
+
*,
|
|
18
|
+
workspace: Path,
|
|
19
|
+
) -> list[str]:
|
|
20
|
+
"""Search for a regex pattern in workspace files."""
|
|
21
|
+
workspace_path = workspace.resolve(strict=False)
|
|
22
|
+
search_root = safe_path(path, workspace_path)
|
|
23
|
+
try:
|
|
24
|
+
regex = re.compile(pattern)
|
|
25
|
+
except re.error as exc:
|
|
26
|
+
return [f"Invalid regex pattern: {exc}"]
|
|
27
|
+
|
|
28
|
+
matches: list[str] = []
|
|
29
|
+
for file_path in iter_search_files(search_root):
|
|
30
|
+
resolved = file_path.resolve(strict=False)
|
|
31
|
+
if not resolved.is_relative_to(workspace_path):
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
relative = resolved.relative_to(workspace_path)
|
|
35
|
+
if include and not relative.match(include):
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
if resolved.stat().st_size > MAX_FILE_SIZE:
|
|
40
|
+
continue
|
|
41
|
+
lines = resolved.read_text(encoding="utf-8").splitlines()
|
|
42
|
+
except (OSError, UnicodeDecodeError):
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
for line_number, line in enumerate(lines, start=1):
|
|
46
|
+
if regex.search(line):
|
|
47
|
+
matches.append(f"{relative.as_posix()}:{line_number}:{line}")
|
|
48
|
+
if len(matches) >= MAX_MATCHES:
|
|
49
|
+
break
|
|
50
|
+
if len(matches) >= MAX_MATCHES:
|
|
51
|
+
break
|
|
52
|
+
return matches
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Handler for the single ``memory`` tool.
|
|
2
|
+
|
|
3
|
+
Thin dispatcher over :class:`bareagent.memory.persistent.MemoryManager`. It validates
|
|
4
|
+
per-command required arguments and converts the manager's stdlib exceptions
|
|
5
|
+
into ``Error:`` strings so the LLM can read and react to failures instead of
|
|
6
|
+
crashing the agent loop (see ``.trellis/spec/backend/error-handling.md``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from bareagent.memory.persistent import MemoryManager
|
|
15
|
+
|
|
16
|
+
# Predictable, LLM-facing failures. Anything outside this set is a real bug and
|
|
17
|
+
# is allowed to propagate to the loop's blanket safety net.
|
|
18
|
+
_HANDLED_ERRORS = (
|
|
19
|
+
FileNotFoundError,
|
|
20
|
+
PermissionError,
|
|
21
|
+
ValueError,
|
|
22
|
+
IsADirectoryError,
|
|
23
|
+
NotADirectoryError,
|
|
24
|
+
OSError,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_memory(
|
|
29
|
+
*,
|
|
30
|
+
manager: MemoryManager,
|
|
31
|
+
command: str,
|
|
32
|
+
path: str | None = None,
|
|
33
|
+
file_text: str | None = None,
|
|
34
|
+
old_str: str | None = None,
|
|
35
|
+
new_str: str | None = None,
|
|
36
|
+
insert_line: int | None = None,
|
|
37
|
+
insert_text: str | None = None,
|
|
38
|
+
old_path: str | None = None,
|
|
39
|
+
new_path: str | None = None,
|
|
40
|
+
view_range: list[int] | None = None,
|
|
41
|
+
) -> str:
|
|
42
|
+
cmd = (command or "").strip()
|
|
43
|
+
try:
|
|
44
|
+
if cmd == "view":
|
|
45
|
+
return manager.view(path or ".", view_range=view_range)
|
|
46
|
+
if cmd == "create":
|
|
47
|
+
if path is None or file_text is None:
|
|
48
|
+
return "Error: create requires 'path' and 'file_text'."
|
|
49
|
+
return manager.create(path, file_text)
|
|
50
|
+
if cmd == "str_replace":
|
|
51
|
+
if path is None or old_str is None or new_str is None:
|
|
52
|
+
return "Error: str_replace requires 'path', 'old_str', and 'new_str'."
|
|
53
|
+
return manager.str_replace(path, old_str, new_str)
|
|
54
|
+
if cmd == "insert":
|
|
55
|
+
if path is None or insert_line is None or insert_text is None:
|
|
56
|
+
return "Error: insert requires 'path', 'insert_line', and 'insert_text'."
|
|
57
|
+
return manager.insert(path, int(insert_line), insert_text)
|
|
58
|
+
if cmd == "delete":
|
|
59
|
+
if path is None:
|
|
60
|
+
return "Error: delete requires 'path'."
|
|
61
|
+
return manager.delete(path)
|
|
62
|
+
if cmd == "rename":
|
|
63
|
+
if old_path is None or new_path is None:
|
|
64
|
+
return "Error: rename requires 'old_path' and 'new_path'."
|
|
65
|
+
return manager.rename(old_path, new_path)
|
|
66
|
+
except _HANDLED_ERRORS as exc:
|
|
67
|
+
return f"Error: {exc}"
|
|
68
|
+
return (
|
|
69
|
+
f"Error: unknown memory command {command!r}. "
|
|
70
|
+
"Valid commands: view, create, str_replace, insert, delete, rename."
|
|
71
|
+
)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Handler + schema for the ``exit_plan_mode`` tool (plan-mode workflow).
|
|
2
|
+
|
|
3
|
+
Like ``skill_create``, ``exit_plan_mode`` is NOT registered in the global tool
|
|
4
|
+
set (``core/tools.py``). It is injected only into the *main* REPL loop's tool
|
|
5
|
+
list in ``main.py`` after the base handlers are built. Keeping it out of
|
|
6
|
+
``get_tools()`` / the base handler dict means sub-agents never receive it:
|
|
7
|
+
``run_subagent`` filters tools by the base schema list (which lacks it) and
|
|
8
|
+
``filter_handlers`` then drops the orphaned handler. A sub-agent must never be
|
|
9
|
+
able to flip the parent's permission mode.
|
|
10
|
+
|
|
11
|
+
The handler is pure: it validates the plan, delegates the user interaction and
|
|
12
|
+
the permission-mode flip to an injected ``approve_fn`` (wired in ``main.py``
|
|
13
|
+
where the UI console and ``PermissionGuard`` live), and maps the resulting
|
|
14
|
+
decision to an ``Error:``-or-instruction string the LLM reads to decide what to
|
|
15
|
+
do next. This keeps ``core/handlers`` free of any dependency on ``permission`` /
|
|
16
|
+
``ui`` and makes the mapping unit-testable with a fake ``approve_fn``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
from bareagent.core.schema import tool_schema
|
|
25
|
+
|
|
26
|
+
EXIT_PLAN_MODE_TOOL_SCHEMA = tool_schema(
|
|
27
|
+
"exit_plan_mode",
|
|
28
|
+
(
|
|
29
|
+
"Present your completed implementation plan for user approval and leave "
|
|
30
|
+
"plan mode. Call this only after researching the task with read-only "
|
|
31
|
+
"tools. On approval the permission mode switches and you continue with "
|
|
32
|
+
"the implementation; on rejection you stay in plan mode and revise."
|
|
33
|
+
),
|
|
34
|
+
{
|
|
35
|
+
"plan": {
|
|
36
|
+
"type": "string",
|
|
37
|
+
"description": (
|
|
38
|
+
"The implementation plan as markdown: what you will change, in "
|
|
39
|
+
"what order, and any risks. Concise but complete."
|
|
40
|
+
),
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
["plan"],
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class PlanDecision:
|
|
49
|
+
"""Outcome of the plan-approval interaction.
|
|
50
|
+
|
|
51
|
+
``outcome`` is one of:
|
|
52
|
+
|
|
53
|
+
- ``"approve-default"`` -- user approved; mode flipped to DEFAULT.
|
|
54
|
+
- ``"approve-auto"`` -- user approved with auto-accept; mode flipped to AUTO.
|
|
55
|
+
- ``"reject"`` -- user rejected; ``reason`` carries optional feedback.
|
|
56
|
+
- ``"noop"`` -- called while not in plan mode (defensive; nothing happened).
|
|
57
|
+
- ``"unavailable"`` -- no interactive approval possible (non-tty); stayed in plan.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
outcome: str
|
|
61
|
+
reason: str = ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run_exit_plan_mode(
|
|
65
|
+
*,
|
|
66
|
+
plan: str | None = None,
|
|
67
|
+
approve_fn: Callable[[str], PlanDecision],
|
|
68
|
+
) -> str:
|
|
69
|
+
if not plan or not str(plan).strip():
|
|
70
|
+
return "Error: exit_plan_mode requires a non-empty 'plan'."
|
|
71
|
+
|
|
72
|
+
decision = approve_fn(str(plan))
|
|
73
|
+
|
|
74
|
+
if decision.outcome == "approve-default":
|
|
75
|
+
return (
|
|
76
|
+
"Plan approved. Permission mode is now DEFAULT (write operations are "
|
|
77
|
+
"still confirmed individually). Proceed with the implementation."
|
|
78
|
+
)
|
|
79
|
+
if decision.outcome == "approve-auto":
|
|
80
|
+
return (
|
|
81
|
+
"Plan approved with auto-accept. Permission mode is now AUTO (safe "
|
|
82
|
+
"commands run without prompts; dangerous ones are still blocked). "
|
|
83
|
+
"Proceed with the implementation."
|
|
84
|
+
)
|
|
85
|
+
if decision.outcome == "unavailable":
|
|
86
|
+
return (
|
|
87
|
+
"Plan approval is unavailable in a non-interactive environment. Staying in plan mode."
|
|
88
|
+
)
|
|
89
|
+
if decision.outcome == "noop":
|
|
90
|
+
return (
|
|
91
|
+
"Error: exit_plan_mode is only valid in plan mode, and you are not "
|
|
92
|
+
"currently in plan mode."
|
|
93
|
+
)
|
|
94
|
+
# Reject is the safety-net default for any unexpected outcome: staying in
|
|
95
|
+
# plan mode is the conservative choice (never auto-grant write access).
|
|
96
|
+
reason = decision.reason.strip()
|
|
97
|
+
if reason:
|
|
98
|
+
return (
|
|
99
|
+
f"The user rejected the plan. Reason: {reason}\n"
|
|
100
|
+
"You are still in plan mode. Revise the plan to address this feedback "
|
|
101
|
+
"and call exit_plan_mode again."
|
|
102
|
+
)
|
|
103
|
+
return (
|
|
104
|
+
"The user rejected the plan. You are still in plan mode. Revise the plan "
|
|
105
|
+
"and call exit_plan_mode again."
|
|
106
|
+
)
|