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,348 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
import threading
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from bareagent.core.fileutil import atomic_write_json, generate_random_id, utc_timestamp_iso
|
|
11
|
+
from bareagent.core.schema import tool_schema as _schema
|
|
12
|
+
|
|
13
|
+
TASK_STATUSES = {"pending", "in_progress", "done", "failed"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class Task:
|
|
18
|
+
id: str
|
|
19
|
+
title: str
|
|
20
|
+
description: str
|
|
21
|
+
status: str
|
|
22
|
+
depends_on: list[str]
|
|
23
|
+
created_at: str
|
|
24
|
+
updated_at: str
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> dict[str, Any]:
|
|
27
|
+
return asdict(self)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
TASK_TOOL_SCHEMAS: list[dict[str, Any]] = [
|
|
31
|
+
_schema(
|
|
32
|
+
"task_create",
|
|
33
|
+
"Create a persisted task with optional dependency task IDs.",
|
|
34
|
+
{
|
|
35
|
+
"title": {
|
|
36
|
+
"type": "string",
|
|
37
|
+
"description": "Short task title.",
|
|
38
|
+
},
|
|
39
|
+
"description": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"description": "Optional task description.",
|
|
42
|
+
"default": "",
|
|
43
|
+
},
|
|
44
|
+
"depends_on": {
|
|
45
|
+
"type": "array",
|
|
46
|
+
"description": "Optional dependency task IDs.",
|
|
47
|
+
"items": {"type": "string"},
|
|
48
|
+
"default": [],
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
["title"],
|
|
52
|
+
),
|
|
53
|
+
_schema(
|
|
54
|
+
"task_update",
|
|
55
|
+
"Update a persisted task status and/or title.",
|
|
56
|
+
{
|
|
57
|
+
"task_id": {
|
|
58
|
+
"type": "string",
|
|
59
|
+
"description": "Task ID to update.",
|
|
60
|
+
},
|
|
61
|
+
"status": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"enum": sorted(TASK_STATUSES),
|
|
64
|
+
"description": "Optional new task status.",
|
|
65
|
+
},
|
|
66
|
+
"title": {
|
|
67
|
+
"type": "string",
|
|
68
|
+
"description": "Optional new task title.",
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
["task_id"],
|
|
72
|
+
),
|
|
73
|
+
_schema(
|
|
74
|
+
"task_get",
|
|
75
|
+
"Get a single persisted task by ID.",
|
|
76
|
+
{
|
|
77
|
+
"task_id": {
|
|
78
|
+
"type": "string",
|
|
79
|
+
"description": "Task ID to look up.",
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
["task_id"],
|
|
83
|
+
),
|
|
84
|
+
_schema(
|
|
85
|
+
"task_list",
|
|
86
|
+
"List persisted tasks, optionally filtered by status.",
|
|
87
|
+
{
|
|
88
|
+
"status": {
|
|
89
|
+
"type": "string",
|
|
90
|
+
"enum": sorted(TASK_STATUSES),
|
|
91
|
+
"description": "Optional task status filter.",
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
[],
|
|
95
|
+
),
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class TaskManager:
|
|
100
|
+
"""Persist tasks on disk and enforce dependency validation."""
|
|
101
|
+
|
|
102
|
+
def __init__(self, task_file: str | Path = ".tasks.json") -> None:
|
|
103
|
+
self.task_file = Path(task_file)
|
|
104
|
+
self.tasks: dict[str, Task] = {}
|
|
105
|
+
self._lock = threading.RLock()
|
|
106
|
+
self._load()
|
|
107
|
+
|
|
108
|
+
def create(
|
|
109
|
+
self,
|
|
110
|
+
title: str,
|
|
111
|
+
description: str = "",
|
|
112
|
+
depends_on: list[str] | None = None,
|
|
113
|
+
) -> Task:
|
|
114
|
+
with self._lock:
|
|
115
|
+
normalized_title = title.strip()
|
|
116
|
+
if not normalized_title:
|
|
117
|
+
raise ValueError("title must not be empty")
|
|
118
|
+
|
|
119
|
+
normalized_depends_on = self._normalize_depends_on(depends_on)
|
|
120
|
+
self._ensure_dependencies_exist(normalized_depends_on)
|
|
121
|
+
now = self._timestamp()
|
|
122
|
+
task = Task(
|
|
123
|
+
id=self._generate_task_id(),
|
|
124
|
+
title=normalized_title,
|
|
125
|
+
description=description.strip(),
|
|
126
|
+
status="pending",
|
|
127
|
+
depends_on=normalized_depends_on,
|
|
128
|
+
created_at=now,
|
|
129
|
+
updated_at=now,
|
|
130
|
+
)
|
|
131
|
+
self.tasks[task.id] = task
|
|
132
|
+
try:
|
|
133
|
+
self._ensure_acyclic()
|
|
134
|
+
except Exception:
|
|
135
|
+
del self.tasks[task.id]
|
|
136
|
+
raise
|
|
137
|
+
self._save()
|
|
138
|
+
return self._copy_task(task)
|
|
139
|
+
|
|
140
|
+
def update(
|
|
141
|
+
self,
|
|
142
|
+
task_id: str,
|
|
143
|
+
status: str | None = None,
|
|
144
|
+
title: str | None = None,
|
|
145
|
+
expected_status: str | None = None,
|
|
146
|
+
) -> Task:
|
|
147
|
+
with self._lock:
|
|
148
|
+
task = self._get_unsafe(task_id)
|
|
149
|
+
if expected_status is not None:
|
|
150
|
+
normalized_expected_status = expected_status.strip()
|
|
151
|
+
self._validate_status(normalized_expected_status)
|
|
152
|
+
if task.status != normalized_expected_status:
|
|
153
|
+
raise ValueError(
|
|
154
|
+
f"Task {task.id} status is {task.status}, "
|
|
155
|
+
f"expected {normalized_expected_status}"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
changed = False
|
|
159
|
+
|
|
160
|
+
if status is not None:
|
|
161
|
+
normalized_status = status.strip()
|
|
162
|
+
self._validate_status(normalized_status)
|
|
163
|
+
if task.status != normalized_status:
|
|
164
|
+
task.status = normalized_status
|
|
165
|
+
changed = True
|
|
166
|
+
|
|
167
|
+
if title is not None:
|
|
168
|
+
normalized_title = title.strip()
|
|
169
|
+
if not normalized_title:
|
|
170
|
+
raise ValueError("title must not be empty")
|
|
171
|
+
if task.title != normalized_title:
|
|
172
|
+
task.title = normalized_title
|
|
173
|
+
changed = True
|
|
174
|
+
|
|
175
|
+
if changed:
|
|
176
|
+
task.updated_at = self._timestamp()
|
|
177
|
+
self._save()
|
|
178
|
+
|
|
179
|
+
return self._copy_task(task)
|
|
180
|
+
|
|
181
|
+
def get(self, task_id: str) -> Task:
|
|
182
|
+
with self._lock:
|
|
183
|
+
return self._copy_task(self._get_unsafe(task_id))
|
|
184
|
+
|
|
185
|
+
def _get_unsafe(self, task_id: str) -> Task:
|
|
186
|
+
"""Return the internal task reference. Caller must hold self._lock."""
|
|
187
|
+
normalized_id = task_id.strip()
|
|
188
|
+
task = self.tasks.get(normalized_id)
|
|
189
|
+
if task is None:
|
|
190
|
+
raise ValueError(f"Unknown task id: {task_id}")
|
|
191
|
+
return task
|
|
192
|
+
|
|
193
|
+
def list(self, status: str | None = None) -> list[Task]:
|
|
194
|
+
with self._lock:
|
|
195
|
+
if status is None:
|
|
196
|
+
return [self._copy_task(t) for t in self.tasks.values()]
|
|
197
|
+
|
|
198
|
+
normalized_status = status.strip()
|
|
199
|
+
self._validate_status(normalized_status)
|
|
200
|
+
return [
|
|
201
|
+
self._copy_task(t)
|
|
202
|
+
for t in self.tasks.values()
|
|
203
|
+
if t.status == normalized_status
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
def get_ready_tasks(self) -> list[Task]:
|
|
207
|
+
with self._lock:
|
|
208
|
+
ready_tasks: list[Task] = []
|
|
209
|
+
for task in self.tasks.values():
|
|
210
|
+
if task.status != "pending":
|
|
211
|
+
continue
|
|
212
|
+
if all(
|
|
213
|
+
self.tasks.get(dep_id) is not None
|
|
214
|
+
and self.tasks[dep_id].status == "done"
|
|
215
|
+
for dep_id in task.depends_on
|
|
216
|
+
):
|
|
217
|
+
ready_tasks.append(self._copy_task(task))
|
|
218
|
+
return ready_tasks
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _copy_task(task: Task) -> Task:
|
|
222
|
+
"""Return a shallow copy with an independent depends_on list."""
|
|
223
|
+
copied = copy.copy(task)
|
|
224
|
+
copied.depends_on = list(task.depends_on)
|
|
225
|
+
return copied
|
|
226
|
+
|
|
227
|
+
def _save(self) -> None:
|
|
228
|
+
payload = {
|
|
229
|
+
"tasks": {task_id: task.to_dict() for task_id, task in self.tasks.items()}
|
|
230
|
+
}
|
|
231
|
+
atomic_write_json(self.task_file, payload)
|
|
232
|
+
|
|
233
|
+
def _load(self) -> None:
|
|
234
|
+
try:
|
|
235
|
+
with self.task_file.open("r", encoding="utf-8") as file:
|
|
236
|
+
payload = json.load(file)
|
|
237
|
+
except FileNotFoundError:
|
|
238
|
+
self.tasks = {}
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
if not isinstance(payload, dict):
|
|
242
|
+
raise ValueError("Task file must contain a JSON object")
|
|
243
|
+
|
|
244
|
+
raw_tasks = payload.get("tasks", {})
|
|
245
|
+
if not isinstance(raw_tasks, dict):
|
|
246
|
+
raise ValueError("Task file 'tasks' field must be an object")
|
|
247
|
+
|
|
248
|
+
loaded_tasks: dict[str, Task] = {}
|
|
249
|
+
for task_id, raw_task in raw_tasks.items():
|
|
250
|
+
if not isinstance(raw_task, dict):
|
|
251
|
+
raise ValueError(f"Invalid task payload for {task_id}")
|
|
252
|
+
loaded_tasks[task_id] = Task(**raw_task)
|
|
253
|
+
|
|
254
|
+
self.tasks = loaded_tasks
|
|
255
|
+
self._validate_graph()
|
|
256
|
+
|
|
257
|
+
def _validate_graph(self) -> None:
|
|
258
|
+
for task in self.tasks.values():
|
|
259
|
+
self._validate_status(task.status)
|
|
260
|
+
self._ensure_dependencies_exist(task.depends_on)
|
|
261
|
+
self._ensure_acyclic()
|
|
262
|
+
|
|
263
|
+
def _normalize_depends_on(self, depends_on: list[str] | None) -> list[str]:
|
|
264
|
+
if depends_on is None:
|
|
265
|
+
return []
|
|
266
|
+
|
|
267
|
+
normalized: list[str] = []
|
|
268
|
+
seen: set[str] = set()
|
|
269
|
+
for dependency_id in depends_on:
|
|
270
|
+
normalized_id = str(dependency_id).strip()
|
|
271
|
+
if not normalized_id:
|
|
272
|
+
raise ValueError("depends_on entries must not be empty")
|
|
273
|
+
if normalized_id in seen:
|
|
274
|
+
continue
|
|
275
|
+
seen.add(normalized_id)
|
|
276
|
+
normalized.append(normalized_id)
|
|
277
|
+
return normalized
|
|
278
|
+
|
|
279
|
+
def _ensure_dependencies_exist(self, depends_on: list[str]) -> None:
|
|
280
|
+
for dependency_id in depends_on:
|
|
281
|
+
if dependency_id not in self.tasks:
|
|
282
|
+
raise ValueError(f"Unknown dependency task id: {dependency_id}")
|
|
283
|
+
|
|
284
|
+
def _ensure_acyclic(self) -> None:
|
|
285
|
+
visiting: set[str] = set()
|
|
286
|
+
visited: set[str] = set()
|
|
287
|
+
|
|
288
|
+
def _dfs(task_id: str) -> None:
|
|
289
|
+
if task_id in visiting:
|
|
290
|
+
raise ValueError(f"Cyclic task dependency detected at: {task_id}")
|
|
291
|
+
if task_id in visited:
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
visiting.add(task_id)
|
|
295
|
+
for dependency_id in self.tasks[task_id].depends_on:
|
|
296
|
+
_dfs(dependency_id)
|
|
297
|
+
visiting.remove(task_id)
|
|
298
|
+
visited.add(task_id)
|
|
299
|
+
|
|
300
|
+
for task_id in self.tasks:
|
|
301
|
+
_dfs(task_id)
|
|
302
|
+
|
|
303
|
+
def _generate_task_id(self) -> str:
|
|
304
|
+
while True:
|
|
305
|
+
task_id = generate_random_id(8)
|
|
306
|
+
if task_id not in self.tasks:
|
|
307
|
+
return task_id
|
|
308
|
+
|
|
309
|
+
def _timestamp(self) -> str:
|
|
310
|
+
return utc_timestamp_iso()
|
|
311
|
+
|
|
312
|
+
def _validate_status(self, status: str) -> None:
|
|
313
|
+
if status not in TASK_STATUSES:
|
|
314
|
+
valid = ", ".join(sorted(TASK_STATUSES))
|
|
315
|
+
raise ValueError(f"Invalid task status: {status}. Expected one of: {valid}")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def make_task_handlers(task_manager: TaskManager) -> dict[str, Any]:
|
|
319
|
+
def _task_create(
|
|
320
|
+
title: str,
|
|
321
|
+
description: str = "",
|
|
322
|
+
depends_on: list[str] | None = None,
|
|
323
|
+
) -> dict[str, Any]:
|
|
324
|
+
return task_manager.create(
|
|
325
|
+
title=title,
|
|
326
|
+
description=description,
|
|
327
|
+
depends_on=depends_on,
|
|
328
|
+
).to_dict()
|
|
329
|
+
|
|
330
|
+
def _task_update(
|
|
331
|
+
task_id: str,
|
|
332
|
+
status: str | None = None,
|
|
333
|
+
title: str | None = None,
|
|
334
|
+
) -> dict[str, Any]:
|
|
335
|
+
if status is None and title is None:
|
|
336
|
+
raise ValueError("status or title is required")
|
|
337
|
+
return task_manager.update(
|
|
338
|
+
task_id=task_id, status=status, title=title
|
|
339
|
+
).to_dict()
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
"task_create": _task_create,
|
|
343
|
+
"task_update": _task_update,
|
|
344
|
+
"task_get": lambda task_id: task_manager.get(task_id).to_dict(),
|
|
345
|
+
"task_list": lambda status=None: [
|
|
346
|
+
task.to_dict() for task in task_manager.list(status=status)
|
|
347
|
+
],
|
|
348
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from bareagent.core.schema import tool_schema as _schema
|
|
7
|
+
|
|
8
|
+
VALID_TODO_STATUSES = {"pending", "in_progress", "done"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
TODO_TOOL_SCHEMAS: list[dict[str, Any]] = [
|
|
12
|
+
_schema(
|
|
13
|
+
"todo_write",
|
|
14
|
+
"Create or update an in-memory TODO item for the current task.",
|
|
15
|
+
{
|
|
16
|
+
"action": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"enum": ["add", "update"],
|
|
19
|
+
"description": "Whether to add a new TODO or update an existing one.",
|
|
20
|
+
},
|
|
21
|
+
"task": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"description": "The task text when action=add.",
|
|
24
|
+
},
|
|
25
|
+
"priority": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"description": "Task priority when action=add.",
|
|
28
|
+
"default": "normal",
|
|
29
|
+
},
|
|
30
|
+
"task_id": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"description": "The TODO id when action=update.",
|
|
33
|
+
},
|
|
34
|
+
"status": {
|
|
35
|
+
"type": "string",
|
|
36
|
+
"enum": sorted(VALID_TODO_STATUSES),
|
|
37
|
+
"description": "New status when action=update.",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
["action"],
|
|
41
|
+
),
|
|
42
|
+
_schema(
|
|
43
|
+
"todo_read",
|
|
44
|
+
"List all in-memory TODO items and their current status.",
|
|
45
|
+
{},
|
|
46
|
+
[],
|
|
47
|
+
),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TodoManager:
|
|
52
|
+
"""Manage short-lived in-memory TODO items for the active session."""
|
|
53
|
+
|
|
54
|
+
def __init__(self) -> None:
|
|
55
|
+
self.tasks: dict[str, dict[str, str]] = {}
|
|
56
|
+
self._next_id = 1
|
|
57
|
+
self._lock = threading.Lock()
|
|
58
|
+
|
|
59
|
+
def reset(self) -> None:
|
|
60
|
+
"""Clear all tasks and reset the ID counter."""
|
|
61
|
+
with self._lock:
|
|
62
|
+
self.tasks.clear()
|
|
63
|
+
self._next_id = 1
|
|
64
|
+
|
|
65
|
+
def add(self, task: str, priority: str = "normal") -> str:
|
|
66
|
+
with self._lock:
|
|
67
|
+
task_text = task.strip()
|
|
68
|
+
if not task_text:
|
|
69
|
+
raise ValueError("task must not be empty")
|
|
70
|
+
|
|
71
|
+
task_id = f"t{self._next_id}"
|
|
72
|
+
self._next_id += 1
|
|
73
|
+
self.tasks[task_id] = {
|
|
74
|
+
"task": task_text,
|
|
75
|
+
"status": "pending",
|
|
76
|
+
"priority": priority.strip() or "normal",
|
|
77
|
+
}
|
|
78
|
+
return (
|
|
79
|
+
f"Added TODO {task_id} [{self.tasks[task_id]['priority']}]: {task_text}"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def update(self, task_id: str, status: str) -> str:
|
|
83
|
+
with self._lock:
|
|
84
|
+
normalized_id = task_id.strip()
|
|
85
|
+
if normalized_id not in self.tasks:
|
|
86
|
+
raise ValueError(f"Unknown TODO id: {task_id}")
|
|
87
|
+
|
|
88
|
+
normalized_status = status.strip()
|
|
89
|
+
if normalized_status not in VALID_TODO_STATUSES:
|
|
90
|
+
valid = ", ".join(sorted(VALID_TODO_STATUSES))
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"Invalid TODO status: {status}. Expected one of: {valid}"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
self.tasks[normalized_id]["status"] = normalized_status
|
|
96
|
+
return f"Updated TODO {normalized_id} -> {normalized_status}"
|
|
97
|
+
|
|
98
|
+
def list(self) -> str:
|
|
99
|
+
with self._lock:
|
|
100
|
+
if not self.tasks:
|
|
101
|
+
return "No TODO items."
|
|
102
|
+
|
|
103
|
+
lines = ["TODO items:"]
|
|
104
|
+
for task_id, item in self.tasks.items():
|
|
105
|
+
lines.append(
|
|
106
|
+
f"- {task_id} [{item['status']}] ({item['priority']}) {item['task']}"
|
|
107
|
+
)
|
|
108
|
+
return "\n".join(lines)
|
|
109
|
+
|
|
110
|
+
def get_nag_reminder(self) -> str | None:
|
|
111
|
+
with self._lock:
|
|
112
|
+
pending_lines = [
|
|
113
|
+
f"- {task_id} [{item['status']}] ({item['priority']}) {item['task']}"
|
|
114
|
+
for task_id, item in self.tasks.items()
|
|
115
|
+
if item["status"] != "done"
|
|
116
|
+
]
|
|
117
|
+
if not pending_lines:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
return "\n".join(
|
|
121
|
+
[
|
|
122
|
+
"You still have unfinished TODO items. Keep them in sync with your progress.",
|
|
123
|
+
*pending_lines,
|
|
124
|
+
]
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def make_todo_handlers(todo_manager: TodoManager) -> dict[str, Any]:
|
|
129
|
+
def _todo_write(
|
|
130
|
+
action: str,
|
|
131
|
+
task: str | None = None,
|
|
132
|
+
priority: str = "normal",
|
|
133
|
+
task_id: str | None = None,
|
|
134
|
+
status: str | None = None,
|
|
135
|
+
) -> str:
|
|
136
|
+
if action == "add":
|
|
137
|
+
if task is None:
|
|
138
|
+
raise ValueError("task is required when action=add")
|
|
139
|
+
return todo_manager.add(task=task, priority=priority)
|
|
140
|
+
|
|
141
|
+
if action == "update":
|
|
142
|
+
if task_id is None:
|
|
143
|
+
raise ValueError("task_id is required when action=update")
|
|
144
|
+
if status is None:
|
|
145
|
+
raise ValueError("status is required when action=update")
|
|
146
|
+
return todo_manager.update(task_id=task_id, status=status)
|
|
147
|
+
|
|
148
|
+
raise ValueError(f"Unknown todo_write action: {action}")
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
"todo_write": _todo_write,
|
|
152
|
+
"todo_read": todo_manager.list,
|
|
153
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Git worktree lifecycle helpers for sub-agent isolation.
|
|
2
|
+
|
|
3
|
+
A thin, dependency-free wrapper around the ``git worktree`` CLI so that a
|
|
4
|
+
sub-agent can run with all of its file operations rooted in an isolated
|
|
5
|
+
working tree + temp branch, leaving the parent workspace untouched. This
|
|
6
|
+
module knows nothing about the agent loop or LLM providers and is unit
|
|
7
|
+
testable against a real temporary repository.
|
|
8
|
+
|
|
9
|
+
The git subprocess style mirrors ``src/core/context.py::_run_git_command``
|
|
10
|
+
(cwd + capture + text + utf-8 + errors="replace" + timeout). These calls
|
|
11
|
+
run as infrastructure (same tier as ``tasks.py`` / ``context.py``) and are
|
|
12
|
+
deliberately *not* routed through ``PermissionGuard`` — the sub-agent's own
|
|
13
|
+
bash/write tools are still gated by its ``child_permission``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import subprocess
|
|
19
|
+
import tempfile
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from bareagent.core.fileutil import generate_random_id
|
|
24
|
+
|
|
25
|
+
_GIT_TIMEOUT = 30
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class WorktreeError(Exception):
|
|
29
|
+
"""Raised when creating a git worktree fails."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True)
|
|
33
|
+
class WorktreeHandle:
|
|
34
|
+
"""Identifies an isolated worktree created for a sub-agent."""
|
|
35
|
+
|
|
36
|
+
path: str
|
|
37
|
+
branch: str
|
|
38
|
+
base_workspace: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _run_git(workspace: str | Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
42
|
+
"""Run a git command, returning the completed process (no ``check``).
|
|
43
|
+
|
|
44
|
+
Callers inspect ``returncode`` and decide whether to raise or swallow —
|
|
45
|
+
creation raises, cleanup is best-effort.
|
|
46
|
+
"""
|
|
47
|
+
return subprocess.run(
|
|
48
|
+
["git", "-C", str(workspace), *args],
|
|
49
|
+
capture_output=True,
|
|
50
|
+
text=True,
|
|
51
|
+
encoding="utf-8",
|
|
52
|
+
errors="replace",
|
|
53
|
+
timeout=_GIT_TIMEOUT,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_git_repo(workspace: str | Path) -> bool:
|
|
58
|
+
"""Return ``True`` when *workspace* sits inside a git work tree."""
|
|
59
|
+
try:
|
|
60
|
+
completed = _run_git(workspace, "rev-parse", "--is-inside-work-tree")
|
|
61
|
+
except (OSError, subprocess.SubprocessError):
|
|
62
|
+
return False
|
|
63
|
+
return completed.returncode == 0 and completed.stdout.strip() == "true"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def create_worktree(workspace: str | Path) -> WorktreeHandle:
|
|
67
|
+
"""Create an isolated worktree + temp branch for *workspace*.
|
|
68
|
+
|
|
69
|
+
The worktree is placed under the system temp directory (outside the repo,
|
|
70
|
+
so the sub-agent's glob/grep cannot scan it and it never enters the git
|
|
71
|
+
index). ``mkdtemp`` pre-creates an empty directory; ``git worktree add``
|
|
72
|
+
accepts an existing empty directory as its target.
|
|
73
|
+
"""
|
|
74
|
+
worktree_id = generate_random_id(8)
|
|
75
|
+
branch = f"bareagent/wt-{worktree_id}"
|
|
76
|
+
path = tempfile.mkdtemp(prefix="bareagent-wt-")
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
completed = _run_git(workspace, "worktree", "add", path, "-b", branch)
|
|
80
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
81
|
+
raise WorktreeError(f"git worktree add failed: {exc}") from exc
|
|
82
|
+
|
|
83
|
+
if completed.returncode != 0:
|
|
84
|
+
detail = completed.stderr.strip() or completed.stdout.strip() or "unknown error"
|
|
85
|
+
raise WorktreeError(f"git worktree add failed: {detail}")
|
|
86
|
+
|
|
87
|
+
return WorktreeHandle(path=path, branch=branch, base_workspace=str(workspace))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def worktree_status(path: str | Path) -> tuple[bool, str]:
|
|
91
|
+
"""Return ``(dirty, summary)`` for the worktree at *path*.
|
|
92
|
+
|
|
93
|
+
Dirty is defined as a non-empty ``git status --porcelain`` — a sub-agent's
|
|
94
|
+
output is uncommitted changes (auto-commit is out of scope), so there is no
|
|
95
|
+
commits-ahead comparison.
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
completed = _run_git(path, "status", "--porcelain")
|
|
99
|
+
except (OSError, subprocess.SubprocessError):
|
|
100
|
+
return False, "status unavailable"
|
|
101
|
+
|
|
102
|
+
lines = [line for line in completed.stdout.splitlines() if line.strip()]
|
|
103
|
+
dirty = bool(lines)
|
|
104
|
+
summary = f"{len(lines)} file(s) changed" if dirty else "no changes"
|
|
105
|
+
return dirty, summary
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def remove_worktree(handle: WorktreeHandle) -> None:
|
|
109
|
+
"""Remove the worktree and delete its branch (best-effort, idempotent).
|
|
110
|
+
|
|
111
|
+
Both steps swallow failures: cleanup must never raise into the sub-agent's
|
|
112
|
+
result path. A leftover worktree is visible via ``git worktree list``.
|
|
113
|
+
"""
|
|
114
|
+
base = handle.base_workspace
|
|
115
|
+
for args in (
|
|
116
|
+
("worktree", "remove", "--force", handle.path),
|
|
117
|
+
("branch", "-D", handle.branch),
|
|
118
|
+
):
|
|
119
|
+
try:
|
|
120
|
+
_run_git(base, *args)
|
|
121
|
+
except (OSError, subprocess.SubprocessError):
|
|
122
|
+
pass
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Provider integrations for BareAgent."""
|