@scemoon/cdh 1.0.0 → 1.0.3
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.
- package/cdh/__pycache__/__main__.cpython-314.pyc +0 -0
- package/cdh/__pycache__/cli.cpython-314.pyc +0 -0
- package/cdh/__pycache__/scaffold.cpython-314.pyc +0 -0
- package/cdh/cli.py +473 -87
- package/cdh/scaffold.py +386 -121
- package/onecode/__pycache__/cli.cpython-314.pyc +0 -0
- package/onecode/__pycache__/commands.cpython-314.pyc +0 -0
- package/onecode/__pycache__/config.cpython-314.pyc +0 -0
- package/onecode/__pycache__/config_screen.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/cdh_loader.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/engine.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/onecode_agent_acp.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/session.cpython-314.pyc +0 -0
- package/onecode/agent/agents/__pycache__/types.cpython-314.pyc +0 -0
- package/onecode/agent/agents/types.py +123 -79
- package/onecode/agent/cdh_loader.py +26 -13
- package/onecode/agent/engine.py +426 -235
- package/onecode/agent/onecode_agent_acp.py +200 -73
- package/onecode/agent/session.py +8 -13
- package/onecode/agent/tools/__pycache__/agent_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/task_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/todo_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/agent_tools.py +4 -4
- package/onecode/agent/tools/todo_tools.py +188 -0
- package/onecode/cli.py +3 -3
- package/onecode/commands.py +439 -194
- package/onecode/config.py +3 -3
- package/onecode/config_screen.py +4 -2
- package/onecode/models/__pycache__/errors.cpython-314.pyc +0 -0
- package/onecode/models/__pycache__/messages.cpython-314.pyc +0 -0
- package/onecode/models/__pycache__/provider.cpython-314.pyc +0 -0
- package/onecode/models/errors.py +11 -1
- package/onecode/models/messages.py +35 -16
- package/onecode/models/provider.py +243 -12
- package/onecode/models/providers/__pycache__/anthropic_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/deepseek_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/glm_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/minimax_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/minimaxi_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/ollama_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/openai_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/anthropic_provider.py +4 -2
- package/onecode/models/providers/deepseek_provider.py +2 -0
- package/onecode/models/providers/glm_provider.py +2 -1
- package/onecode/models/providers/minimax_provider.py +2 -1
- package/onecode/models/providers/minimaxi_provider.py +76 -25
- package/onecode/models/providers/ollama_provider.py +2 -1
- package/onecode/models/providers/openai_provider.py +4 -0
- package/onecode/skills/__pycache__/manager.cpython-314.pyc +0 -0
- package/onecode/tasks/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/tasks/__pycache__/manager.cpython-314.pyc +0 -0
- package/onecode/tasks/__pycache__/models.cpython-314.pyc +0 -0
- package/onecode/tasks/manager.py +1 -1
- package/package.json +1 -1
- package/pyproject.toml +10 -0
- package/run.js +24 -7
- package/tui/__pycache__/app.cpython-314.pyc +0 -0
- package/tui/__pycache__/cli.cpython-314.pyc +0 -0
- package/tui/__pycache__/db.cpython-314.pyc +0 -0
- package/tui/__pycache__/directory_suggester.cpython-314.pyc +0 -0
- package/tui/__pycache__/directory_watcher.cpython-314.pyc +0 -0
- package/tui/__pycache__/messages.cpython-314.pyc +0 -0
- package/tui/__pycache__/paths.cpython-314.pyc +0 -0
- package/tui/acp/__pycache__/agent.cpython-314.pyc +0 -0
- package/tui/acp/__pycache__/api.cpython-314.pyc +0 -0
- package/tui/acp/__pycache__/messages.cpython-314.pyc +0 -0
- package/tui/acp/__pycache__/prompt.cpython-314.pyc +0 -0
- package/tui/acp/__pycache__/protocol.cpython-314.pyc +0 -0
- package/tui/acp/agent.py +60 -11
- package/tui/acp/api.py +6 -0
- package/tui/acp/messages.py +11 -0
- package/tui/acp/prompt.py +1 -1
- package/tui/acp/protocol.py +31 -0
- package/tui/app.py +60 -8
- package/tui/db.py +23 -0
- package/tui/directory_watcher.py +35 -1
- package/tui/messages.py +4 -1
- package/tui/paths.py +3 -3
- package/tui/screens/__pycache__/agent_modal.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/component_picker.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/log.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/main.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/projects_app.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/projects_screen.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/settings.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/store.cpython-314.pyc +0 -0
- package/tui/screens/component_picker.py +148 -0
- package/tui/screens/main.py +47 -95
- package/tui/screens/projects.tcss +10 -0
- package/tui/screens/projects_app.py +33 -5
- package/tui/screens/projects_screen.py +171 -9
- package/tui/widgets/__pycache__/acp_content.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/ask_user.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/conversation.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/diff_view.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/directory_input.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/mandelbrot.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/modified_files.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/plan.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/project_directory_tree.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/project_grid_select.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/question.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/subagent.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/subagent_screen.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/tool_call.cpython-314.pyc +0 -0
- package/tui/widgets/ask_user.py +1 -1
- package/tui/widgets/conversation.py +99 -42
- package/tui/widgets/modified_files.py +155 -23
- package/tui/widgets/plan.py +22 -0
- package/tui/widgets/project_directory_tree.py +22 -10
- package/tui/widgets/project_grid_select.py +23 -1
- package/tui/widgets/question.py +1 -1
- package/tui/widgets/subagent.py +202 -36
- package/tui/widgets/subagent_screen.py +186 -0
- package/tui/widgets/tool_call.py +5 -8
- package/onecode/agent/tools/task_tools.py +0 -249
package/onecode/agent/engine.py
CHANGED
|
@@ -15,10 +15,15 @@ from onecode.agent.permissions_store import PermissionStore
|
|
|
15
15
|
from onecode.models.provider import ContentBlockType, ProviderRegistry
|
|
16
16
|
|
|
17
17
|
from onecode.agent.session import AgentSession
|
|
18
|
+
from onecode.memory import AgentMemory, MemoryLayer
|
|
19
|
+
from onecode.models.errors import safe_error_msg
|
|
18
20
|
from onecode.models.messages import StreamEvent, StreamEventType
|
|
19
21
|
|
|
20
22
|
logger = logging.getLogger("onecode.agent.engine")
|
|
21
23
|
|
|
24
|
+
# Subagent depth limit — subagents are leaf nodes (max depth = 1).
|
|
25
|
+
_MAX_SUBAGENT_DEPTH = 1
|
|
26
|
+
|
|
22
27
|
|
|
23
28
|
@dataclass(frozen=True)
|
|
24
29
|
class ToolEvent:
|
|
@@ -239,20 +244,24 @@ def _extract_legacy_tool_uses(
|
|
|
239
244
|
return tool_uses, cleaned, counter
|
|
240
245
|
|
|
241
246
|
|
|
242
|
-
|
|
247
|
+
_TODO_STATUSES = {"pending", "in_progress", "completed"}
|
|
248
|
+
|
|
243
249
|
|
|
250
|
+
class TodoManager:
|
|
251
|
+
"""Unified todo manager (formerly TodoManager).
|
|
244
252
|
|
|
245
|
-
|
|
246
|
-
|
|
253
|
+
Each todo has: id, subject, description, activeForm, status, owner, blocks,
|
|
254
|
+
blockedBy, metadata, output. Supports dependency tracking and progress
|
|
255
|
+
display in the TUI Plan sidebar.
|
|
247
256
|
|
|
248
|
-
|
|
249
|
-
|
|
257
|
+
Persistence format (``to_dict``/``from_dict``) accepts both the new
|
|
258
|
+
single-list layout and the legacy ``{"tasks": [...], "todos": [...]}``
|
|
259
|
+
layout for backwards compatibility with existing session files.
|
|
250
260
|
"""
|
|
251
261
|
|
|
252
262
|
def __init__(self, on_change: Callable[[], None] | None = None):
|
|
253
|
-
self.
|
|
254
|
-
self.
|
|
255
|
-
self._plan: list[str] = []
|
|
263
|
+
self._todos: dict[str, dict] = {}
|
|
264
|
+
self._order: list[str] = []
|
|
256
265
|
self._id_counter = 0
|
|
257
266
|
self._on_change = on_change
|
|
258
267
|
|
|
@@ -264,19 +273,19 @@ class TaskManager:
|
|
|
264
273
|
self._id_counter += 1
|
|
265
274
|
return f"t{self._id_counter}"
|
|
266
275
|
|
|
267
|
-
# ──
|
|
276
|
+
# ── Todo CRUD ──
|
|
268
277
|
|
|
269
|
-
def
|
|
278
|
+
def create_todo(
|
|
270
279
|
self,
|
|
271
280
|
subject: str,
|
|
272
281
|
description: str = "",
|
|
273
282
|
active_form: str = "",
|
|
274
283
|
metadata: dict | None = None,
|
|
275
284
|
) -> dict:
|
|
276
|
-
"""Create a
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
"id":
|
|
285
|
+
"""Create a todo with dependency tracking support."""
|
|
286
|
+
todo_id = self._next_id()
|
|
287
|
+
todo = {
|
|
288
|
+
"id": todo_id,
|
|
280
289
|
"_order": self._id_counter,
|
|
281
290
|
"subject": subject,
|
|
282
291
|
"description": description,
|
|
@@ -288,161 +297,141 @@ class TaskManager:
|
|
|
288
297
|
"metadata": dict(metadata or {}),
|
|
289
298
|
"output": "",
|
|
290
299
|
}
|
|
291
|
-
self.
|
|
300
|
+
self._todos[todo_id] = todo
|
|
301
|
+
self._order.append(todo_id)
|
|
292
302
|
self._mark_dirty()
|
|
293
|
-
return
|
|
303
|
+
return todo
|
|
294
304
|
|
|
295
|
-
def
|
|
296
|
-
return self.
|
|
305
|
+
def get_todo(self, todo_id: str) -> dict | None:
|
|
306
|
+
return self._todos.get(todo_id)
|
|
297
307
|
|
|
298
|
-
def
|
|
299
|
-
"""List all
|
|
300
|
-
return
|
|
301
|
-
|
|
302
|
-
def
|
|
303
|
-
"""Update
|
|
304
|
-
owner, metadata, addBlocks, addBlockedBy. Returns updated
|
|
305
|
-
|
|
306
|
-
if
|
|
308
|
+
def list_todos(self) -> list[dict]:
|
|
309
|
+
"""List all todos in creation order."""
|
|
310
|
+
return [self._todos[tid] for tid in self._order if tid in self._todos]
|
|
311
|
+
|
|
312
|
+
def update_todo(self, todo_id: str, **fields) -> dict | None:
|
|
313
|
+
"""Update todo fields. Supports: subject, description, activeForm, status,
|
|
314
|
+
owner, metadata, addBlocks, addBlockedBy. Returns updated todo or None."""
|
|
315
|
+
todo = self._todos.get(todo_id)
|
|
316
|
+
if todo is None:
|
|
307
317
|
return None
|
|
308
318
|
|
|
309
319
|
updated = []
|
|
310
320
|
|
|
311
|
-
# String fields
|
|
312
321
|
for field in ("subject", "description", "activeForm", "owner"):
|
|
313
322
|
if field in fields:
|
|
314
323
|
v = fields[field]
|
|
315
|
-
if isinstance(v, str) and v !=
|
|
316
|
-
|
|
324
|
+
if isinstance(v, str) and v != todo.get(field):
|
|
325
|
+
todo[field] = v
|
|
317
326
|
updated.append(field)
|
|
318
327
|
|
|
319
|
-
# Status field
|
|
320
328
|
if "status" in fields:
|
|
321
329
|
status = fields["status"]
|
|
322
330
|
if status == "deleted":
|
|
323
|
-
self.
|
|
331
|
+
self._todos.pop(todo_id, None)
|
|
332
|
+
if todo_id in self._order:
|
|
333
|
+
self._order.remove(todo_id)
|
|
324
334
|
self._mark_dirty()
|
|
325
|
-
return {"id":
|
|
326
|
-
if status in
|
|
327
|
-
|
|
335
|
+
return {"id": todo_id, "deleted": True}
|
|
336
|
+
if status in _TODO_STATUSES and status != todo.get("status"):
|
|
337
|
+
todo["status"] = status
|
|
328
338
|
updated.append("status")
|
|
329
339
|
|
|
330
|
-
# Dependency fields
|
|
331
340
|
for rel_field, input_key in (("blocks", "addBlocks"), ("blockedBy", "addBlockedBy")):
|
|
332
341
|
if input_key in fields:
|
|
333
342
|
ids = fields[input_key]
|
|
334
343
|
if isinstance(ids, list):
|
|
335
|
-
cur = list(
|
|
344
|
+
cur = list(todo.get(rel_field) or [])
|
|
336
345
|
for x in ids:
|
|
337
346
|
if isinstance(x, str) and x not in cur:
|
|
338
347
|
cur.append(x)
|
|
339
|
-
if cur !=
|
|
340
|
-
|
|
348
|
+
if cur != todo.get(rel_field):
|
|
349
|
+
todo[rel_field] = cur
|
|
341
350
|
updated.append(rel_field)
|
|
342
351
|
|
|
343
|
-
# Metadata
|
|
344
352
|
if "metadata" in fields:
|
|
345
353
|
md = fields["metadata"]
|
|
346
354
|
if isinstance(md, dict):
|
|
347
|
-
existing = dict(
|
|
355
|
+
existing = dict(todo.get("metadata") or {})
|
|
348
356
|
for k, v in md.items():
|
|
349
357
|
if v is None:
|
|
350
358
|
existing.pop(k, None)
|
|
351
359
|
else:
|
|
352
360
|
existing[k] = v
|
|
353
|
-
|
|
361
|
+
todo["metadata"] = existing
|
|
354
362
|
updated.append("metadata")
|
|
355
363
|
|
|
356
|
-
# Output
|
|
357
364
|
if "output" in fields:
|
|
358
365
|
v = fields["output"]
|
|
359
366
|
if isinstance(v, str):
|
|
360
|
-
|
|
367
|
+
todo["output"] = v
|
|
361
368
|
updated.append("output")
|
|
362
369
|
|
|
363
370
|
self._mark_dirty()
|
|
364
|
-
return
|
|
371
|
+
return todo
|
|
365
372
|
|
|
366
|
-
def
|
|
367
|
-
"""Get output for a
|
|
368
|
-
|
|
369
|
-
if
|
|
373
|
+
def get_todo_output(self, todo_id: str) -> dict:
|
|
374
|
+
"""Get output for a todo."""
|
|
375
|
+
todo = self._todos.get(todo_id)
|
|
376
|
+
if todo is None:
|
|
370
377
|
return {"retrieval_status": "not_found", "task": None}
|
|
371
|
-
output = str(
|
|
378
|
+
output = str(todo.get("output") or "")
|
|
372
379
|
return {
|
|
373
380
|
"retrieval_status": "success" if output else "not_ready",
|
|
374
381
|
"task": {
|
|
375
|
-
"task_id":
|
|
376
|
-
"task_type": "
|
|
377
|
-
"status":
|
|
378
|
-
"description":
|
|
382
|
+
"task_id": todo_id,
|
|
383
|
+
"task_type": "todo_list",
|
|
384
|
+
"status": todo.get("status"),
|
|
385
|
+
"description": todo.get("description"),
|
|
379
386
|
"output": output,
|
|
380
387
|
},
|
|
381
388
|
}
|
|
382
389
|
|
|
383
|
-
def clear_tasks(self) -> None:
|
|
384
|
-
self._tasks.clear()
|
|
385
|
-
self._mark_dirty()
|
|
386
|
-
|
|
387
|
-
# ── Todo Management (unchanged API) ──
|
|
388
|
-
|
|
389
|
-
def add_todo(self, text: str) -> str:
|
|
390
|
-
tid = self._next_id()
|
|
391
|
-
self._todos.append({"text": text, "done": False, "id": tid})
|
|
392
|
-
self._mark_dirty()
|
|
393
|
-
return tid
|
|
394
|
-
|
|
395
|
-
def complete_todo(self, todo_id: str) -> bool:
|
|
396
|
-
for t in self._todos:
|
|
397
|
-
if t["id"] == todo_id:
|
|
398
|
-
t["done"] = True
|
|
399
|
-
self._mark_dirty()
|
|
400
|
-
return True
|
|
401
|
-
return False
|
|
402
|
-
|
|
403
|
-
def remove_todo(self, todo_id: str) -> bool:
|
|
404
|
-
before = len(self._todos)
|
|
405
|
-
self._todos = [t for t in self._todos if t["id"] != todo_id]
|
|
406
|
-
result = len(self._todos) < before
|
|
407
|
-
self._mark_dirty()
|
|
408
|
-
return result
|
|
409
|
-
|
|
410
|
-
def list_todos(self) -> list[dict]:
|
|
411
|
-
return self._todos
|
|
412
|
-
|
|
413
390
|
def clear_todos(self) -> None:
|
|
414
|
-
self._todos
|
|
415
|
-
self.
|
|
416
|
-
|
|
417
|
-
def set_plan(self, plan: list[str]) -> None:
|
|
418
|
-
self._plan = plan
|
|
391
|
+
self._todos.clear()
|
|
392
|
+
self._order.clear()
|
|
419
393
|
self._mark_dirty()
|
|
420
394
|
|
|
421
|
-
def get_plan(self) -> list[str]:
|
|
422
|
-
return self._plan
|
|
423
|
-
|
|
424
395
|
# ── Serialization ──
|
|
425
396
|
|
|
426
397
|
def to_dict(self) -> dict:
|
|
427
|
-
"""Serialize
|
|
398
|
+
"""Serialize todos for persistence."""
|
|
428
399
|
return {
|
|
429
|
-
"
|
|
430
|
-
"todos": [dict(t) for t in self._todos],
|
|
431
|
-
"plan": list(self._plan),
|
|
400
|
+
"todos": [dict(self._todos[tid]) for tid in self._order if tid in self._todos],
|
|
432
401
|
"id_counter": self._id_counter,
|
|
433
402
|
}
|
|
434
403
|
|
|
435
404
|
@classmethod
|
|
436
|
-
def from_dict(cls, data: dict, on_change: Callable[[], None] | None = None) ->
|
|
437
|
-
"""Restore
|
|
405
|
+
def from_dict(cls, data: dict, on_change: Callable[[], None] | None = None) -> "TodoManager":
|
|
406
|
+
"""Restore todo manager from saved dict.
|
|
407
|
+
|
|
408
|
+
Accepts the unified ``{"todos": [...]}`` layout (preferred) and the
|
|
409
|
+
legacy ``{"tasks": [...], "todos": [...]}`` layout where ``todos``
|
|
410
|
+
entries may use the old ``{text, done}`` shape — these are converted
|
|
411
|
+
on the fly to the new ``{subject, status}`` shape.
|
|
412
|
+
"""
|
|
438
413
|
tm = cls(on_change=on_change)
|
|
439
414
|
tm._id_counter = data.get("id_counter", 0)
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
415
|
+
|
|
416
|
+
raw_todos: list[dict] = []
|
|
417
|
+
if "todos" in data:
|
|
418
|
+
raw_todos.extend(data.get("todos") or [])
|
|
419
|
+
if "tasks" in data:
|
|
420
|
+
raw_todos.extend(data.get("tasks") or [])
|
|
421
|
+
|
|
422
|
+
for t in raw_todos:
|
|
423
|
+
todo = dict(t)
|
|
424
|
+
if "subject" not in todo and "text" in todo:
|
|
425
|
+
todo["subject"] = todo.pop("text")
|
|
426
|
+
if "status" not in todo and "done" in todo:
|
|
427
|
+
todo["status"] = "completed" if todo.pop("done") else "pending"
|
|
428
|
+
todo.setdefault("status", "pending")
|
|
429
|
+
tid = todo.get("id") or tm._next_id()
|
|
430
|
+
todo["id"] = tid
|
|
431
|
+
todo.setdefault("_order", tm._id_counter)
|
|
432
|
+
tm._todos[tid] = todo
|
|
433
|
+
if tid not in tm._order:
|
|
434
|
+
tm._order.append(tid)
|
|
446
435
|
return tm
|
|
447
436
|
|
|
448
437
|
|
|
@@ -473,9 +462,15 @@ class AgentEngine:
|
|
|
473
462
|
self.total_tokens = 0
|
|
474
463
|
self._skills_loaded = False
|
|
475
464
|
self._session: Optional[AgentSession] = None
|
|
465
|
+
self._memory = AgentMemory()
|
|
476
466
|
self._hooks = HookManager()
|
|
477
|
-
self.
|
|
467
|
+
self._todo_manager = TodoManager(on_change=self._on_todo_change)
|
|
478
468
|
self._plan_dirty: bool = False
|
|
469
|
+
# Cache of the last plan snapshot that was emitted to the TUI. Used
|
|
470
|
+
# by ``_emit_plan_update`` to dedupe redundant emits when the todos
|
|
471
|
+
# have not actually changed between turns. The first emit of a
|
|
472
|
+
# session/cycle always wins (cache starts empty).
|
|
473
|
+
self._last_emitted_plan: tuple = ()
|
|
479
474
|
self._project_config: dict = {}
|
|
480
475
|
self._project_context_loaded = False
|
|
481
476
|
self._pending_approval: dict | None = None # {tool_call, result_key}
|
|
@@ -511,6 +506,9 @@ class AgentEngine:
|
|
|
511
506
|
# Cancellation support
|
|
512
507
|
self._cancelled: bool = False
|
|
513
508
|
|
|
509
|
+
# Subagent depth limit — subagents are leaf nodes (max depth = 1).
|
|
510
|
+
self._subagent_depth: int = 0
|
|
511
|
+
|
|
514
512
|
# Monotonic tool-call id counter — shared across every
|
|
515
513
|
# ``chat_stream`` turn so the same id is never reused even when the
|
|
516
514
|
# model re-emits ``legacy-0`` / ``minimax-0`` in a fresh turn.
|
|
@@ -535,7 +533,7 @@ class AgentEngine:
|
|
|
535
533
|
|
|
536
534
|
# ReAct state tracking
|
|
537
535
|
self._react_phase: str = "thought" # "thought" | "action" | "observation"
|
|
538
|
-
self._direct_execution_count: int = 0 # Track direct tool use for
|
|
536
|
+
self._direct_execution_count: int = 0 # Track direct tool use for routing-decision reminder
|
|
539
537
|
|
|
540
538
|
def _build_tool_registry(self) -> ToolRegistry:
|
|
541
539
|
from onecode.agent.tools.registry import ToolRegistry
|
|
@@ -544,8 +542,8 @@ class AgentEngine:
|
|
|
544
542
|
from onecode.agent.tools.bash_tool import BashTool
|
|
545
543
|
from onecode.agent.tools.web_tools import WebFetchTool, WebSearchTool
|
|
546
544
|
from onecode.agent.tools.communication_tools import SendMessageTool, AskUserTool, ToolSearchTool
|
|
547
|
-
from onecode.agent.tools.
|
|
548
|
-
|
|
545
|
+
from onecode.agent.tools.todo_tools import (TodoCreateTool, TodoGetTool, TodoListTool, TodoUpdateTool,
|
|
546
|
+
TodoOutputTool, TodoStopTool)
|
|
549
547
|
from onecode.agent.tools.agent_tools import AgentTool, TaskTool
|
|
550
548
|
from onecode.agent.tools.skill_tools import SkillTool
|
|
551
549
|
from onecode.agent.tools.mcp_tools import MCPTool as MCPToolTool, MCPResourcesTool
|
|
@@ -573,15 +571,12 @@ class AgentEngine:
|
|
|
573
571
|
registry.register(AskUserTool())
|
|
574
572
|
registry.register(ToolSearchTool(registry))
|
|
575
573
|
# Task management
|
|
576
|
-
registry.register(
|
|
577
|
-
registry.register(
|
|
578
|
-
registry.register(
|
|
579
|
-
registry.register(
|
|
580
|
-
registry.register(
|
|
581
|
-
registry.register(
|
|
582
|
-
registry.register(TodoCreateTool(self._task_manager))
|
|
583
|
-
registry.register(TodoListTool(self._task_manager))
|
|
584
|
-
registry.register(TodoCompleteTool(self._task_manager))
|
|
574
|
+
registry.register(TodoCreateTool(self._todo_manager))
|
|
575
|
+
registry.register(TodoGetTool(self._todo_manager))
|
|
576
|
+
registry.register(TodoListTool(self._todo_manager))
|
|
577
|
+
registry.register(TodoUpdateTool(self._todo_manager))
|
|
578
|
+
registry.register(TodoOutputTool(self._todo_manager))
|
|
579
|
+
registry.register(TodoStopTool(self._todo_manager))
|
|
585
580
|
# Agent tools
|
|
586
581
|
registry.register(AgentTool(registry, permission_checker=self._check_tool_permission))
|
|
587
582
|
registry.register(TaskTool(self._spawn_subagent_async))
|
|
@@ -661,31 +656,104 @@ class AgentEngine:
|
|
|
661
656
|
return
|
|
662
657
|
|
|
663
658
|
def _emit_plan_update(self) -> list[StreamEvent]:
|
|
664
|
-
"""Emit a plan update event from current
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
"
|
|
675
|
-
|
|
676
|
-
"priority"
|
|
677
|
-
|
|
678
|
-
|
|
659
|
+
"""Emit a plan update event from current todos, deduped against last emit.
|
|
660
|
+
|
|
661
|
+
Returns an empty list when the plan snapshot is byte-identical to the
|
|
662
|
+
last emission, so callers can no-op without paying the JSON-RPC + TUI
|
|
663
|
+
widget-recompose cost. The cache is a tuple of ``(content, status,
|
|
664
|
+
priority)`` triples — order-sensitive, so reordering the todo list
|
|
665
|
+
also triggers a re-emit.
|
|
666
|
+
"""
|
|
667
|
+
entries = [
|
|
668
|
+
(
|
|
669
|
+
t.get("subject") or t.get("description", ""),
|
|
670
|
+
t.get("status", "pending"),
|
|
671
|
+
t.get("metadata", {}).get("priority", "medium"),
|
|
672
|
+
)
|
|
673
|
+
for t in self._todo_manager.list_todos()
|
|
674
|
+
]
|
|
675
|
+
if entries == self._last_emitted_plan:
|
|
676
|
+
return []
|
|
677
|
+
self._last_emitted_plan = entries
|
|
678
|
+
# Convert the cached tuples back to wire-format dicts for the stream
|
|
679
|
+
# event. The TUI side expects dicts; the dedupe cache stays tuples
|
|
680
|
+
# to avoid the per-emit dict-construction cost on the hot path.
|
|
681
|
+
wire_entries = [
|
|
682
|
+
{"content": c, "status": s, "priority": p}
|
|
683
|
+
for (c, s, p) in entries
|
|
684
|
+
]
|
|
685
|
+
return [StreamEvent.plan(wire_entries)]
|
|
686
|
+
|
|
687
|
+
def _refresh_pending_todos_nudge(self) -> None:
|
|
688
|
+
"""Inject a reminder that nudges the agent to advance unfinished todos.
|
|
689
|
+
|
|
690
|
+
Called at the start of each ReAct turn. If there are pending or
|
|
691
|
+
in-progress todos, a ``<!-- PENDING_TODOS -->`` section is added to
|
|
692
|
+
the system context so the LLM sees it during its next Thought phase
|
|
693
|
+
and prefers to:
|
|
694
|
+
1. continue the in-progress todo, or
|
|
695
|
+
2. start the next pending todo (respecting ``addBlockedBy`` DAG), or
|
|
696
|
+
3. call ``AskUser`` if the todo is blocked on user input.
|
|
697
|
+
|
|
698
|
+
When no todos are open, any stale nudge is removed so the context
|
|
699
|
+
stays clean.
|
|
700
|
+
"""
|
|
701
|
+
marker = "<!-- PENDING_TODOS -->"
|
|
702
|
+
todos = self._todo_manager.list_todos()
|
|
703
|
+
open_todos = [t for t in todos if t.get("status") in ("pending", "in_progress")]
|
|
704
|
+
if not open_todos:
|
|
705
|
+
self.context.remove_system_by_marker(marker)
|
|
706
|
+
return
|
|
707
|
+
|
|
708
|
+
in_progress = [t for t in open_todos if t.get("status") == "in_progress"]
|
|
709
|
+
pending = [t for t in open_todos if t.get("status") == "pending"]
|
|
710
|
+
|
|
711
|
+
def _label(t: dict) -> str:
|
|
712
|
+
sid = t.get("id", "?")
|
|
713
|
+
sub = t.get("subject") or t.get("description", "")
|
|
714
|
+
owner = t.get("owner")
|
|
715
|
+
owner_part = f" (owner={owner})" if owner else ""
|
|
716
|
+
return f"- `{sid}`{owner_part}: {sub}"
|
|
717
|
+
|
|
718
|
+
lines: list[str] = []
|
|
719
|
+
lines.append(
|
|
720
|
+
"There are unfinished todos from earlier in this session. Prioritize "
|
|
721
|
+
"them before starting new work: finish in-progress items, then move "
|
|
722
|
+
"to the next pending todo in DAG order (respect `addBlockedBy`). "
|
|
723
|
+
"If a todo is blocked on user input or external information, call "
|
|
724
|
+
"`AskUser` to clarify instead of starting a new branch."
|
|
725
|
+
)
|
|
726
|
+
if in_progress:
|
|
727
|
+
lines.append("")
|
|
728
|
+
lines.append("**In progress:**")
|
|
729
|
+
lines.extend(_label(t) for t in in_progress)
|
|
730
|
+
if pending:
|
|
731
|
+
lines.append("")
|
|
732
|
+
lines.append("**Pending:**")
|
|
733
|
+
lines.extend(_label(t) for t in pending)
|
|
734
|
+
lines.append("")
|
|
735
|
+
lines.append(
|
|
736
|
+
"Next action: either `TodoUpdate(status=\"in_progress\")` to pick one "
|
|
737
|
+
"up, advance it with the appropriate tool, and `TodoUpdate(status=\"completed\")` "
|
|
738
|
+
"when done — or `AskUser` to unblock."
|
|
739
|
+
)
|
|
740
|
+
body = f"{marker}\n" + "\n".join(lines)
|
|
741
|
+
if not self.context.replace_system_section(marker, body):
|
|
742
|
+
self.context.add_system(body)
|
|
679
743
|
|
|
680
|
-
def
|
|
681
|
-
"""Callback invoked by
|
|
744
|
+
def _on_todo_change(self) -> None:
|
|
745
|
+
"""Callback invoked by TodoManager whenever todos mutate.
|
|
682
746
|
|
|
683
747
|
Sets a dirty flag so the next opportunity in ``chat_stream`` will
|
|
684
748
|
emit a fresh plan event. Decoupling the callback from the emit
|
|
685
749
|
keeps the public API synchronous and avoids re-entrancy when
|
|
686
|
-
subagents mutate the same
|
|
750
|
+
subagents mutate the same TodoManager concurrently.
|
|
751
|
+
|
|
752
|
+
Also persists todos to ``.cdh/todos.json`` immediately so they
|
|
753
|
+
survive a crash or Ctrl+C before the next ACP turn boundary.
|
|
687
754
|
"""
|
|
688
755
|
self._plan_dirty = True
|
|
756
|
+
self.save_todos_to_project()
|
|
689
757
|
|
|
690
758
|
@property
|
|
691
759
|
def _workspace(self) -> Path:
|
|
@@ -727,10 +795,12 @@ class AgentEngine:
|
|
|
727
795
|
"\n## Response style\n"
|
|
728
796
|
"- **Every turn must start with Chain of Thought reasoning** "
|
|
729
797
|
"inside `<thinking>`. Analyze the current state, determine the "
|
|
730
|
-
"next step, and decide whether to delegate via `
|
|
731
|
-
"- **
|
|
732
|
-
"
|
|
733
|
-
"
|
|
798
|
+
"next step, and decide whether to delegate via `Spawn` or track via `TodoCreate`.\n"
|
|
799
|
+
"- **Route by complexity**:\n"
|
|
800
|
+
" - Simple / single-step work → use the direct tool and optionally "
|
|
801
|
+
"`TodoCreate` to surface progress in the sidebar.\n"
|
|
802
|
+
" - Complex / multi-step work → use `Spawn(agent_type, prompt)` to "
|
|
803
|
+
"delegate to a focused subagent rather than calling execution tools directly.\n"
|
|
734
804
|
"- If you need to reason between tool calls, wrap your "
|
|
735
805
|
"reasoning in `<thinking>...</thinking>`. The TUI will "
|
|
736
806
|
"render the wrapped block as a collapsible thought and "
|
|
@@ -909,7 +979,7 @@ class AgentEngine:
|
|
|
909
979
|
"Glob": "glob",
|
|
910
980
|
"Grep": "grep",
|
|
911
981
|
"List": "list",
|
|
912
|
-
"
|
|
982
|
+
"Spawn": "task",
|
|
913
983
|
"Agent": "task",
|
|
914
984
|
"Skill": "skill",
|
|
915
985
|
}
|
|
@@ -954,7 +1024,7 @@ class AgentEngine:
|
|
|
954
1024
|
return {**base, "content": output, "is_error": result.is_error}
|
|
955
1025
|
except Exception as e:
|
|
956
1026
|
logger.exception(f"Tool execution error: {e}")
|
|
957
|
-
return {**base, "content": f"Error: {e}", "is_error": True}
|
|
1027
|
+
return {**base, "content": f"Error: {safe_error_msg(e)}", "is_error": True}
|
|
958
1028
|
|
|
959
1029
|
def _format_tool_output(self, result: RegistryToolResult) -> str:
|
|
960
1030
|
import json
|
|
@@ -1012,6 +1082,20 @@ class AgentEngine:
|
|
|
1012
1082
|
except Exception as e:
|
|
1013
1083
|
logger.warning("Codebase retrieval failed: %s", e)
|
|
1014
1084
|
|
|
1085
|
+
# ── Long-term memory recall ──
|
|
1086
|
+
if isinstance(user_input, str) and self._session:
|
|
1087
|
+
try:
|
|
1088
|
+
results = self._memory.search_memories(user_input, top_k=5)
|
|
1089
|
+
if results:
|
|
1090
|
+
lines = ["## Relevant past memories"]
|
|
1091
|
+
for r in results:
|
|
1092
|
+
lines.append(r.content[:300])
|
|
1093
|
+
ctx_text = "<!-- MEMORY -->\n" + "\n".join(lines)
|
|
1094
|
+
if not self.context.replace_system_section("MEMORY", ctx_text):
|
|
1095
|
+
self.context.add_system(ctx_text)
|
|
1096
|
+
except Exception as e:
|
|
1097
|
+
logger.warning("Memory recall failed: %s", e)
|
|
1098
|
+
|
|
1015
1099
|
self.context.add_user(user_input)
|
|
1016
1100
|
|
|
1017
1101
|
# Plan gate: activate based on agent mode, not content heuristics.
|
|
@@ -1028,11 +1112,10 @@ class AgentEngine:
|
|
|
1028
1112
|
model_name = self.app.current_model
|
|
1029
1113
|
logger.info(f"Using provider='{provider_name}', model='{model_name}'")
|
|
1030
1114
|
logger.info(
|
|
1031
|
-
"chat_stream engine state: project=%s agent=%s
|
|
1115
|
+
"chat_stream engine state: project=%s agent=%s todos=%d context_msgs=%d ctx_tokens=%d",
|
|
1032
1116
|
project_name or "(none)",
|
|
1033
1117
|
self.current_agent.name if self.current_agent else "None",
|
|
1034
|
-
len(self.
|
|
1035
|
-
len(self._task_manager.list_todos()),
|
|
1118
|
+
len(self._todo_manager.list_todos()),
|
|
1036
1119
|
len(self.context.messages),
|
|
1037
1120
|
self.context._token_count,
|
|
1038
1121
|
)
|
|
@@ -1074,10 +1157,12 @@ class AgentEngine:
|
|
|
1074
1157
|
"Begin this turn by reasoning step by step in `<thinking>`:\n"
|
|
1075
1158
|
"1. **Current state**: Analyze the user request and what needs to be done.\n"
|
|
1076
1159
|
"2. **Goal**: What should I accomplish this turn?\n"
|
|
1077
|
-
"3. **
|
|
1078
|
-
"
|
|
1079
|
-
"
|
|
1080
|
-
"
|
|
1160
|
+
"3. **Routing**: For the next action, choose by complexity:\n"
|
|
1161
|
+
" - Simple (1 tool call, 1 file) → direct tool + optional `TodoCreate` for tracking\n"
|
|
1162
|
+
" - Complex (multi-file, multi-step, research-heavy) → `Spawn(agent_type, prompt)`\n"
|
|
1163
|
+
" - Pure research → `Read/Grep/Glob/WebFetch` directly, or `explore/scout` subagent\n"
|
|
1164
|
+
"4. **Plan when needed**: For multi-step work, create a todo plan first via "
|
|
1165
|
+
"`TodoCreate`, then mark each todo as `in_progress` / `completed` as you go.\n"
|
|
1081
1166
|
)
|
|
1082
1167
|
if not self.context.replace_system_section("REACT_PHASE", cot_phase_init):
|
|
1083
1168
|
self.context.add_system(cot_phase_init)
|
|
@@ -1089,7 +1174,11 @@ class AgentEngine:
|
|
|
1089
1174
|
|
|
1090
1175
|
# ── Thought Phase: update CoT reasoning guidance for this turn ──
|
|
1091
1176
|
# Cleanup stale reminders that may have accumulated
|
|
1092
|
-
self.context.remove_system_by_marker("<!--
|
|
1177
|
+
self.context.remove_system_by_marker("<!-- ROUTING_REMINDER -->")
|
|
1178
|
+
# Inject (or refresh) a nudge that prioritizes any unfinished todos
|
|
1179
|
+
# from this session. The marker is replaced in-place so the context
|
|
1180
|
+
# stays bounded — the agent always sees the current open list.
|
|
1181
|
+
self._refresh_pending_todos_nudge()
|
|
1093
1182
|
if turn > 0:
|
|
1094
1183
|
cot_phase = (
|
|
1095
1184
|
f"<!-- REACT_PHASE -->\n"
|
|
@@ -1097,10 +1186,11 @@ class AgentEngine:
|
|
|
1097
1186
|
"Begin this turn by reasoning step by step in `<thinking>`:\n"
|
|
1098
1187
|
"1. **Current state**: What just happened? What results do I have?\n"
|
|
1099
1188
|
"2. **Goal**: What should I accomplish this turn?\n"
|
|
1100
|
-
"3. **
|
|
1101
|
-
"
|
|
1102
|
-
"
|
|
1103
|
-
"
|
|
1189
|
+
"3. **Routing**: For the next action, choose by complexity:\n"
|
|
1190
|
+
" - Simple (1 tool call, 1 file) → direct tool + optional `TodoCreate` for tracking\n"
|
|
1191
|
+
" - Complex (multi-file, multi-step, research-heavy) → `Spawn(agent_type, prompt)`\n"
|
|
1192
|
+
"4. **Plan when needed**: For multi-step work, advance todos via `TodoUpdate` "
|
|
1193
|
+
"and delegate substantial items via `Spawn`.\n"
|
|
1104
1194
|
)
|
|
1105
1195
|
self.context.replace_system_section("REACT_PHASE", cot_phase)
|
|
1106
1196
|
|
|
@@ -1199,7 +1289,7 @@ class AgentEngine:
|
|
|
1199
1289
|
turn_usage = model_response.usage
|
|
1200
1290
|
except Exception as e:
|
|
1201
1291
|
logger.exception(f"Error during chat() fallback turn {turn+1}: {e}")
|
|
1202
|
-
yield StreamEvent.error(
|
|
1292
|
+
yield StreamEvent.error(safe_error_msg(e))
|
|
1203
1293
|
break
|
|
1204
1294
|
except Exception as e:
|
|
1205
1295
|
ctx_msgs = len(self.context.messages)
|
|
@@ -1209,7 +1299,7 @@ class AgentEngine:
|
|
|
1209
1299
|
f" provider={provider_name} model={model_name} "
|
|
1210
1300
|
f"context_msgs={ctx_msgs} ctx_tokens={ctx_tokens}"
|
|
1211
1301
|
)
|
|
1212
|
-
yield StreamEvent.error(f"Provider error (turn {turn+1}): {e}")
|
|
1302
|
+
yield StreamEvent.error(f"Provider error (turn {turn+1}): {safe_error_msg(e)}")
|
|
1213
1303
|
break
|
|
1214
1304
|
|
|
1215
1305
|
# Track usage
|
|
@@ -1237,7 +1327,7 @@ class AgentEngine:
|
|
|
1237
1327
|
# Log the raw model response so developers can verify the
|
|
1238
1328
|
# model is actually emitting ``<thinking>`` markers (and not
|
|
1239
1329
|
# bleeding planning prose into the visible answer). Goes
|
|
1240
|
-
# to the onecode root logger → ~/.
|
|
1330
|
+
# to the onecode root logger → ~/.cdh/logs/cdh.log when
|
|
1241
1331
|
# ``setup_logging(DEBUG)`` is in effect.
|
|
1242
1332
|
logger.debug(
|
|
1243
1333
|
"RAW_RESPONSE turn=%d text_len=%d first_200=%r "
|
|
@@ -1279,6 +1369,19 @@ class AgentEngine:
|
|
|
1279
1369
|
yield StreamEvent.text_delta(clean_text[i:i + 12])
|
|
1280
1370
|
elif self._last_user_msg:
|
|
1281
1371
|
yield StreamEvent.text_delta(self._last_user_msg)
|
|
1372
|
+
|
|
1373
|
+
# ── Store conversation in long-term memory ──
|
|
1374
|
+
if self._session:
|
|
1375
|
+
try:
|
|
1376
|
+
conv_id = self._session.id
|
|
1377
|
+
input_str = user_input if isinstance(user_input, str) else str(user_input)
|
|
1378
|
+
self._memory.remember(
|
|
1379
|
+
MemoryLayer.L0_CONVERSATION,
|
|
1380
|
+
f"user: {input_str}\nassistant: {clean_text}",
|
|
1381
|
+
{"conversation_id": conv_id},
|
|
1382
|
+
)
|
|
1383
|
+
except Exception as e:
|
|
1384
|
+
logger.warning("Memory store failed: %s", e)
|
|
1282
1385
|
break
|
|
1283
1386
|
|
|
1284
1387
|
# Emit ToolEvents (Clawd-Code pattern) and StreamEvents for TUI
|
|
@@ -1296,13 +1399,13 @@ class AgentEngine:
|
|
|
1296
1399
|
for tu in tool_uses:
|
|
1297
1400
|
logger.info(f"Executing tool: {tu['name']} (id={tu['id']})")
|
|
1298
1401
|
|
|
1299
|
-
#
|
|
1402
|
+
# Spawn tool: forward subagent text deltas to the TUI as
|
|
1300
1403
|
# subagent_chunk events so the SubAgent widget actually has
|
|
1301
|
-
# content to render. The
|
|
1404
|
+
# content to render. The Spawn tool's spec is (agent_type,
|
|
1302
1405
|
# prompt); we read them from the tool input.
|
|
1303
|
-
if tu["name"] == "
|
|
1406
|
+
if tu["name"] == "Spawn":
|
|
1304
1407
|
# Permission check
|
|
1305
|
-
denied = self._check_tool_permission("
|
|
1408
|
+
denied = self._check_tool_permission("Spawn", tu["input"])
|
|
1306
1409
|
if denied:
|
|
1307
1410
|
result = {
|
|
1308
1411
|
"tool_use_id": tu["id"],
|
|
@@ -1313,78 +1416,118 @@ class AgentEngine:
|
|
|
1313
1416
|
else:
|
|
1314
1417
|
subagent_type = tu["input"].get("agent_type", "general")
|
|
1315
1418
|
subagent_prompt = tu["input"].get("prompt", "")
|
|
1316
|
-
|
|
1419
|
+
is_failed = False
|
|
1420
|
+
error_msg = ""
|
|
1421
|
+
yield StreamEvent.subagent_start(subagent_type, tu["id"], subagent_prompt)
|
|
1317
1422
|
accumulated: list[str] = []
|
|
1318
1423
|
async for sub_event, sub_text in self._spawn_subagent_async_streaming(
|
|
1319
1424
|
subagent_type, subagent_prompt
|
|
1320
1425
|
):
|
|
1321
|
-
if sub_text
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1426
|
+
if not sub_text:
|
|
1427
|
+
continue
|
|
1428
|
+
if sub_event.type == StreamEventType.SUBAGENT_END:
|
|
1429
|
+
# Carry status/error from subagent_end event
|
|
1430
|
+
if hasattr(sub_event, "subagent_status") and sub_event.subagent_status == "failed":
|
|
1431
|
+
is_failed = True
|
|
1432
|
+
error_msg = sub_event.subagent_error or ""
|
|
1433
|
+
continue # 修复 Bug 1: 不 append final combined text(避免文本重复)
|
|
1434
|
+
if sub_event.type == StreamEventType.SUBAGENT_THINKING:
|
|
1435
|
+
yield StreamEvent.subagent_thinking(tu["id"], sub_text)
|
|
1436
|
+
continue
|
|
1437
|
+
# TEXT_DELTA
|
|
1438
|
+
accumulated.append(sub_text)
|
|
1439
|
+
yield StreamEvent.subagent_chunk(tu["id"], sub_text)
|
|
1440
|
+
yield StreamEvent.subagent_end(
|
|
1441
|
+
tu["id"],
|
|
1442
|
+
agent_type=subagent_type,
|
|
1443
|
+
status="failed" if is_failed else "completed",
|
|
1444
|
+
error=error_msg,
|
|
1445
|
+
)
|
|
1446
|
+
raw_output = "".join(accumulated)
|
|
1327
1447
|
formatted = self._format_subagent_output(
|
|
1328
|
-
subagent_type, subagent_prompt,
|
|
1448
|
+
subagent_type, subagent_prompt, raw_output
|
|
1449
|
+
) if not is_failed else (
|
|
1450
|
+
f"SUMMARY:\nSub-agent failed with error: {error_msg}\n\n"
|
|
1451
|
+
"CHANGES:\nNone.\n\nEVIDENCE:\nNone.\n\nRISKS:\nNone.\n\n"
|
|
1452
|
+
f"BLOCKERS:\n{error_msg}"
|
|
1329
1453
|
)
|
|
1330
1454
|
result = {
|
|
1331
1455
|
"tool_use_id": tu["id"],
|
|
1332
|
-
"is_error":
|
|
1456
|
+
"is_error": is_failed, # 修复 Bug 2: 子智能体失败时 is_error=True
|
|
1333
1457
|
"category": "task",
|
|
1334
1458
|
"content": formatted,
|
|
1459
|
+
"raw_content": raw_output,
|
|
1335
1460
|
}
|
|
1336
1461
|
elif (self._plan_gate_mode != "off"
|
|
1337
|
-
and not self.
|
|
1462
|
+
and not self._todo_manager.list_todos()
|
|
1338
1463
|
and tu["name"] in _EXECUTION_TOOLS
|
|
1339
1464
|
and self._plan_gate_first_turn()):
|
|
1340
1465
|
if self._plan_gate_mode == "hard":
|
|
1341
1466
|
result = {
|
|
1342
1467
|
"tool_use_id": tu["id"],
|
|
1343
1468
|
"is_error": True,
|
|
1344
|
-
"category": "
|
|
1469
|
+
"category": "todo",
|
|
1345
1470
|
"content": json.dumps({
|
|
1346
1471
|
"success": False,
|
|
1347
1472
|
"error": (
|
|
1348
1473
|
"Plan required. You are in plan mode (ReAct: Thought → Action → Observation). "
|
|
1349
|
-
"
|
|
1350
|
-
"
|
|
1351
|
-
"
|
|
1474
|
+
"Before using execution tools, create a todo plan via `TodoCreate()`. "
|
|
1475
|
+
"Choose the right granularity:\n"
|
|
1476
|
+
"- Each todo = one focused unit of work (file, function, or concern).\n"
|
|
1477
|
+
"- Set `addBlockedBy` on dependent todos to express the DAG.\n"
|
|
1478
|
+
"- For complex multi-step work, mark the todo with "
|
|
1479
|
+
"`metadata={\"delegate_to\": \"general\"}` to indicate it should be "
|
|
1480
|
+
"executed by a `Spawn` subagent rather than direct tool calls.\n"
|
|
1481
|
+
"Create ALL todos upfront, present the plan for user review, "
|
|
1352
1482
|
"and only then proceed to execute."
|
|
1353
1483
|
),
|
|
1354
1484
|
}),
|
|
1355
1485
|
}
|
|
1356
1486
|
else:
|
|
1357
|
-
# Soft gate: execute but inject a reminder for next turn
|
|
1487
|
+
# Soft gate: execute but inject a routing reminder for next turn
|
|
1358
1488
|
self.context.add_system(
|
|
1359
1489
|
"<!-- PLAN_REMINDER -->\n"
|
|
1360
|
-
"
|
|
1361
|
-
"
|
|
1362
|
-
"
|
|
1363
|
-
"
|
|
1364
|
-
"
|
|
1490
|
+
"## Routing Decision\n"
|
|
1491
|
+
"Before your next action, choose the right tool based on complexity:\n\n"
|
|
1492
|
+
"**Simple / single-step work** → use `TodoCreate` to track a lightweight "
|
|
1493
|
+
"checklist item in the sidebar. Examples: 1 file edit, 1 Read, 1 Bash. "
|
|
1494
|
+
"The Todo widget shows progress to the user.\n\n"
|
|
1495
|
+
"**Complex / multi-step work** → use `Spawn(agent_type, prompt)` to "
|
|
1496
|
+
"delegate to a focused subagent. Examples: cross-file refactor, multi-"
|
|
1497
|
+
"file feature, research requiring 3+ tool calls, work needing isolated "
|
|
1498
|
+
"context. The subagent encapsulates work and returns structured output.\n\n"
|
|
1499
|
+
"**Default**: When unsure → `Spawn`. Todo is for visible progress on "
|
|
1500
|
+
"simple items; Spawn is for substantial work.\n"
|
|
1365
1501
|
)
|
|
1366
1502
|
result = await self._execute_tool(tu)
|
|
1367
1503
|
else:
|
|
1368
1504
|
result = await self._execute_tool(tu)
|
|
1369
|
-
#
|
|
1505
|
+
# Routing: track direct execution tool use → nudge routing decision
|
|
1370
1506
|
if tu["name"] in _EXECUTION_TOOLS:
|
|
1371
1507
|
self._direct_execution_count += 1
|
|
1372
1508
|
if self._direct_execution_count >= 2:
|
|
1373
1509
|
self.context.add_system(
|
|
1374
|
-
"<!--
|
|
1375
|
-
"
|
|
1376
|
-
"
|
|
1377
|
-
"
|
|
1510
|
+
"<!-- ROUTING_REMINDER -->\n"
|
|
1511
|
+
"You've used direct execution tools repeatedly. Pause and decide:\n"
|
|
1512
|
+
"- If this is a single-step trivial change → continue, but "
|
|
1513
|
+
"consider `TodoCreate` to surface progress in the sidebar.\n"
|
|
1514
|
+
"- If this work involves >1 tool call, multiple files, or "
|
|
1515
|
+
"research → STOP direct execution and delegate via "
|
|
1516
|
+
"`Spawn(agent_type=\"general\", prompt=\"...\")` instead. The "
|
|
1517
|
+
"subagent isolates context and returns a structured summary."
|
|
1378
1518
|
)
|
|
1379
1519
|
result_str = str(result.get("content", ""))
|
|
1380
1520
|
is_error = result.get("is_error", False)
|
|
1381
1521
|
category = result.get("category", "unknown")
|
|
1522
|
+
# For Task tools, use raw subagent output for TUI display
|
|
1523
|
+
# (no SUMMARY/CHANGES/… structured wrapper)
|
|
1524
|
+
tui_content = str(result.get("raw_content", result_str))
|
|
1382
1525
|
|
|
1383
1526
|
# Handle SendMessage — user-visible only, skip from LLM context
|
|
1384
1527
|
if tu["name"] == "SendMessage":
|
|
1385
1528
|
try:
|
|
1386
1529
|
parsed = json.loads(result_str) if result_str else {}
|
|
1387
|
-
msg = parsed.get("message", "")
|
|
1530
|
+
msg = parsed.get("message", "") if isinstance(parsed, dict) else ""
|
|
1388
1531
|
if msg:
|
|
1389
1532
|
self._last_user_msg = msg
|
|
1390
1533
|
yield StreamEvent.text_delta(f"\n💬 {msg}\n")
|
|
@@ -1401,9 +1544,9 @@ class AgentEngine:
|
|
|
1401
1544
|
# Handle AskUser — trigger user interaction dialog, don't add to LLM context yet
|
|
1402
1545
|
if tu["name"] == "AskUser":
|
|
1403
1546
|
parsed = json.loads(result_str) if result_str else {}
|
|
1404
|
-
question = parsed.get("question", "")
|
|
1405
|
-
options = parsed.get("options", [])
|
|
1406
|
-
questions = parsed.get("questions", [])
|
|
1547
|
+
question = parsed.get("question", "") if isinstance(parsed, dict) else ""
|
|
1548
|
+
options = parsed.get("options", []) if isinstance(parsed, dict) else []
|
|
1549
|
+
questions = parsed.get("questions", []) if isinstance(parsed, dict) else []
|
|
1407
1550
|
|
|
1408
1551
|
# Check for auto-default on single question + single option
|
|
1409
1552
|
if not questions and len(options) == 1 and options[0].get("default"):
|
|
@@ -1462,7 +1605,7 @@ class AgentEngine:
|
|
|
1462
1605
|
requires_approval = False
|
|
1463
1606
|
try:
|
|
1464
1607
|
parsed = json.loads(result_str) if result_str else {}
|
|
1465
|
-
requires_approval = parsed.get("requires_approval", False)
|
|
1608
|
+
requires_approval = isinstance(parsed, dict) and parsed.get("requires_approval", False)
|
|
1466
1609
|
except (json.JSONDecodeError, ValueError):
|
|
1467
1610
|
pass
|
|
1468
1611
|
|
|
@@ -1499,7 +1642,7 @@ class AgentEngine:
|
|
|
1499
1642
|
))
|
|
1500
1643
|
yield StreamEvent.tool_result(
|
|
1501
1644
|
call_id=tu["id"],
|
|
1502
|
-
content=
|
|
1645
|
+
content=tui_content,
|
|
1503
1646
|
is_error=is_error,
|
|
1504
1647
|
category=result_cat,
|
|
1505
1648
|
)
|
|
@@ -1513,7 +1656,7 @@ class AgentEngine:
|
|
|
1513
1656
|
name=tu["id"],
|
|
1514
1657
|
)
|
|
1515
1658
|
# Emit plan update when tasks/todos changed. Any
|
|
1516
|
-
# mutating path through
|
|
1659
|
+
# mutating path through TodoManager flips
|
|
1517
1660
|
# ``_plan_dirty`` via the ``on_change`` callback, so we
|
|
1518
1661
|
# do not need to enumerate tool names here.
|
|
1519
1662
|
if self._plan_dirty:
|
|
@@ -1604,8 +1747,12 @@ class AgentEngine:
|
|
|
1604
1747
|
self._react_phase = "thought"
|
|
1605
1748
|
self._direct_execution_count = 0
|
|
1606
1749
|
self._plan_gate_fired = False
|
|
1750
|
+
# Invalidate the plan-emit dedupe cache so the next chat_stream
|
|
1751
|
+
# always produces a fresh snapshot for the TUI.
|
|
1752
|
+
self._last_emitted_plan = ()
|
|
1607
1753
|
self.context.remove_system_by_marker("<!-- REACT_PHASE -->")
|
|
1608
|
-
self.context.remove_system_by_marker("<!--
|
|
1754
|
+
self.context.remove_system_by_marker("<!-- ROUTING_REMINDER -->")
|
|
1755
|
+
self.context.remove_system_by_marker("<!-- PENDING_TODOS -->")
|
|
1609
1756
|
|
|
1610
1757
|
def reset(self):
|
|
1611
1758
|
self.context.reset()
|
|
@@ -1691,16 +1838,23 @@ class AgentEngine:
|
|
|
1691
1838
|
|
|
1692
1839
|
Yields ``(event, text)`` pairs so the caller (typically the Task-tool
|
|
1693
1840
|
path in :meth:`chat_stream`) can forward each text delta as a
|
|
1694
|
-
``subagent_chunk`` notification on the ACP wire.
|
|
1695
|
-
is also accumulated and emitted in a single ``(subagent_end, text)``
|
|
1696
|
-
payload at the end so callers that only care about the final output
|
|
1697
|
-
can ignore the intermediate chunks.
|
|
1841
|
+
``subagent_chunk`` notification on the ACP wire.
|
|
1698
1842
|
|
|
1699
1843
|
Inherits project context and skills from the parent
|
|
1700
1844
|
engine so the subagent does not start from a blank slate.
|
|
1701
1845
|
"""
|
|
1846
|
+
# Depth check: subagents are leaf nodes (max depth = 1).
|
|
1847
|
+
if self._subagent_depth >= _MAX_SUBAGENT_DEPTH:
|
|
1848
|
+
err = "Subagent cannot spawn nested subagents (max depth=1)"
|
|
1849
|
+
yield (StreamEvent.subagent_end(
|
|
1850
|
+
"", agent_type=agent_type, status="failed", error=err,
|
|
1851
|
+
), err)
|
|
1852
|
+
return
|
|
1853
|
+
|
|
1702
1854
|
sub_engine = AgentEngine(self.app, project_dir=self._project_dir, perm_store=self._perm_store)
|
|
1703
1855
|
|
|
1856
|
+
sub_engine._subagent_depth = self._subagent_depth + 1
|
|
1857
|
+
|
|
1704
1858
|
# Inherit parent context: project info, skills
|
|
1705
1859
|
sub_engine._project_context_loaded = self._project_context_loaded
|
|
1706
1860
|
sub_engine._project_config = dict(self._project_config)
|
|
@@ -1714,7 +1868,6 @@ class AgentEngine:
|
|
|
1714
1868
|
# Track child so parent cancellation cascades
|
|
1715
1869
|
self._child_engines.append(sub_engine)
|
|
1716
1870
|
|
|
1717
|
-
# Link the subagent's cancellation flag to the parent
|
|
1718
1871
|
parts: list[str] = []
|
|
1719
1872
|
try:
|
|
1720
1873
|
async for chunk in sub_engine.chat_stream(prompt):
|
|
@@ -1722,6 +1875,9 @@ class AgentEngine:
|
|
|
1722
1875
|
if chunk.type == StreamEventType.TEXT_DELTA and chunk.text:
|
|
1723
1876
|
parts.append(chunk.text)
|
|
1724
1877
|
yield chunk, chunk.text
|
|
1878
|
+
elif chunk.type == StreamEventType.THINKING and chunk.thinking:
|
|
1879
|
+
# 修复 Bug 4: 转发思考块
|
|
1880
|
+
yield chunk, chunk.thinking
|
|
1725
1881
|
else:
|
|
1726
1882
|
if chunk:
|
|
1727
1883
|
parts.append(chunk)
|
|
@@ -1732,9 +1888,13 @@ class AgentEngine:
|
|
|
1732
1888
|
"CHANGES:\nNone.\n\nEVIDENCE:\nNone.\n\nRISKS:\nNone.\n\n"
|
|
1733
1889
|
f"BLOCKERS:\n{e}"
|
|
1734
1890
|
)
|
|
1735
|
-
yield StreamEvent.subagent_end(
|
|
1891
|
+
yield (StreamEvent.subagent_end(
|
|
1892
|
+
"", agent_type=agent_type, status="failed", error=str(e),
|
|
1893
|
+
), err_text)
|
|
1736
1894
|
else:
|
|
1737
|
-
yield StreamEvent.subagent_end(
|
|
1895
|
+
yield (StreamEvent.subagent_end(
|
|
1896
|
+
"", agent_type=agent_type, status="completed",
|
|
1897
|
+
), "".join(parts))
|
|
1738
1898
|
finally:
|
|
1739
1899
|
# Clean up completed subagent to prevent memory leak
|
|
1740
1900
|
if sub_engine in self._child_engines:
|
|
@@ -1787,7 +1947,7 @@ class AgentEngine:
|
|
|
1787
1947
|
result = asyncio.run(self._spawn_subagent_async(agent_type, prompt))
|
|
1788
1948
|
except Exception as e:
|
|
1789
1949
|
logger.exception(f"Subagent error: {e}")
|
|
1790
|
-
return {"success": False, "error":
|
|
1950
|
+
return {"success": False, "error": safe_error_msg(e)}
|
|
1791
1951
|
return {
|
|
1792
1952
|
"success": True,
|
|
1793
1953
|
"agent_type": agent_type,
|
|
@@ -1799,19 +1959,17 @@ class AgentEngine:
|
|
|
1799
1959
|
result = self.spawn_subagent(agent_type, prompt)
|
|
1800
1960
|
if result["success"]:
|
|
1801
1961
|
return result["response"]
|
|
1802
|
-
return f"Error: {result.get('error'
|
|
1962
|
+
return f"Error: {result.get('error') or 'Unknown error'}"
|
|
1803
1963
|
|
|
1804
1964
|
def attach_session(self, session: AgentSession) -> None:
|
|
1805
1965
|
self._session = session
|
|
1806
1966
|
if session.messages:
|
|
1807
1967
|
self.context.load_from_session(session.messages)
|
|
1808
|
-
if session.
|
|
1809
|
-
self.
|
|
1810
|
-
"tasks": session.tasks,
|
|
1968
|
+
if session.todos:
|
|
1969
|
+
self._todo_manager = TodoManager.from_dict({
|
|
1811
1970
|
"todos": session.todos,
|
|
1812
|
-
"
|
|
1813
|
-
|
|
1814
|
-
}, on_change=self._on_task_change)
|
|
1971
|
+
"id_counter": len(session.todos),
|
|
1972
|
+
}, on_change=self._on_todo_change)
|
|
1815
1973
|
# Restore context usage stats
|
|
1816
1974
|
self._restore_session_stats(session)
|
|
1817
1975
|
|
|
@@ -1819,8 +1977,7 @@ class AgentEngine:
|
|
|
1819
1977
|
if self._session:
|
|
1820
1978
|
try:
|
|
1821
1979
|
self._session.messages = self.context.to_session_format()
|
|
1822
|
-
tm_data = self.
|
|
1823
|
-
self._session.tasks = tm_data.get("tasks", [])
|
|
1980
|
+
tm_data = self._todo_manager.to_dict()
|
|
1824
1981
|
self._session.todos = tm_data.get("todos", [])
|
|
1825
1982
|
# Persist context usage stats so they survive session reload
|
|
1826
1983
|
self._session.update_state("stats", {
|
|
@@ -1837,20 +1994,54 @@ class AgentEngine:
|
|
|
1837
1994
|
|
|
1838
1995
|
def load_session(self, session_id: str) -> bool:
|
|
1839
1996
|
session = AgentSession(session_id)
|
|
1840
|
-
if session.load():
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1997
|
+
if not session.load():
|
|
1998
|
+
return False
|
|
1999
|
+
self._session = session
|
|
2000
|
+
self.context.load_from_session(session.messages)
|
|
2001
|
+
if session.todos:
|
|
2002
|
+
self._todo_manager = TodoManager.from_dict({
|
|
2003
|
+
"todos": session.todos,
|
|
2004
|
+
"id_counter": len(session.todos),
|
|
2005
|
+
}, on_change=self._on_todo_change)
|
|
2006
|
+
self._inject_loaded_tasks_into_context()
|
|
2007
|
+
# Restore context usage stats
|
|
2008
|
+
self._restore_session_stats(session)
|
|
2009
|
+
return True
|
|
2010
|
+
|
|
2011
|
+
def _inject_loaded_tasks_into_context(self) -> None:
|
|
2012
|
+
"""Surface loaded todos to the LLM so it can continue them.
|
|
2013
|
+
|
|
2014
|
+
Todos loaded from a previous session are not automatically visible
|
|
2015
|
+
in the LLM context — without this nudge the agent would re-create
|
|
2016
|
+
them on every fresh turn instead of resuming pending work.
|
|
2017
|
+
"""
|
|
2018
|
+
all_todos = self._todo_manager.list_todos()
|
|
2019
|
+
pending = [t for t in all_todos if t.get("status") in ("pending", "in_progress")]
|
|
2020
|
+
if not pending:
|
|
2021
|
+
return
|
|
2022
|
+
|
|
2023
|
+
lines = ["# Resumed todos from previous session", ""]
|
|
2024
|
+
lines.append("## Todos")
|
|
2025
|
+
for t in pending:
|
|
2026
|
+
status = t.get("status", "pending")
|
|
2027
|
+
marker = {"in_progress": "[~]", "pending": "[ ]"}.get(status, "[?]")
|
|
2028
|
+
subject = t.get("subject") or t.get("description", "")
|
|
2029
|
+
desc = t.get("description") or ""
|
|
2030
|
+
if desc and desc != subject:
|
|
2031
|
+
lines.append(f"- {marker} (id={t.get('id')}) {subject} — {desc}")
|
|
2032
|
+
else:
|
|
2033
|
+
lines.append(f"- {marker} (id={t.get('id')}) {subject}")
|
|
2034
|
+
lines.append("")
|
|
2035
|
+
|
|
2036
|
+
lines.append(
|
|
2037
|
+
"Continue working on any in_progress or pending todos above. "
|
|
2038
|
+
"Use TodoList/TodoGet to inspect details and TodoUpdate to "
|
|
2039
|
+
"advance status. Do NOT recreate todos that already exist."
|
|
2040
|
+
)
|
|
2041
|
+
marker = "<!-- loaded_todos_resume -->"
|
|
2042
|
+
body = f"{marker}\n" + "\n".join(lines)
|
|
2043
|
+
if not self.context.replace_system_section(marker, body):
|
|
2044
|
+
self.context.add_system(body)
|
|
1854
2045
|
|
|
1855
2046
|
def _restore_session_stats(self, session: AgentSession) -> None:
|
|
1856
2047
|
"""Restore context usage stats from session lifecycle_state."""
|
|
@@ -1863,32 +2054,32 @@ class AgentEngine:
|
|
|
1863
2054
|
if restored_turns and isinstance(restored_turns, list):
|
|
1864
2055
|
self._turn_usages = list(restored_turns)
|
|
1865
2056
|
|
|
1866
|
-
def
|
|
1867
|
-
"""Save
|
|
2057
|
+
def save_todos_to_project(self) -> None:
|
|
2058
|
+
"""Save todos to .cdh/todos.json in the project directory."""
|
|
1868
2059
|
from onecode.agent.cdh_loader import CdhProjectLoader
|
|
1869
2060
|
cdh_dir = CdhProjectLoader.find_cdh_dir(self._project_dir)
|
|
1870
2061
|
if cdh_dir is None:
|
|
1871
2062
|
return
|
|
1872
2063
|
try:
|
|
1873
|
-
tm_data = self.
|
|
1874
|
-
CdhProjectLoader.
|
|
2064
|
+
tm_data = self._todo_manager.to_dict()
|
|
2065
|
+
CdhProjectLoader.save_todos(cdh_dir, tm_data)
|
|
1875
2066
|
except Exception as e:
|
|
1876
|
-
logger.warning("Failed to save
|
|
2067
|
+
logger.warning("Failed to save todos to .cdh: %s", e)
|
|
1877
2068
|
|
|
1878
|
-
def
|
|
1879
|
-
"""Restore
|
|
2069
|
+
def load_todos_from_project(self) -> None:
|
|
2070
|
+
"""Restore todos from .cdh/todos.json (with legacy tasks.json fallback)."""
|
|
1880
2071
|
from onecode.agent.cdh_loader import CdhProjectLoader
|
|
1881
2072
|
cdh_dir = CdhProjectLoader.find_cdh_dir(self._project_dir)
|
|
1882
2073
|
if cdh_dir is None:
|
|
1883
2074
|
return
|
|
1884
2075
|
try:
|
|
1885
|
-
data = CdhProjectLoader.
|
|
2076
|
+
data = CdhProjectLoader.load_todos(cdh_dir)
|
|
1886
2077
|
if data and (data.get("tasks") or data.get("todos")):
|
|
1887
|
-
self.
|
|
1888
|
-
data, on_change=self.
|
|
2078
|
+
self._todo_manager = TodoManager.from_dict(
|
|
2079
|
+
data, on_change=self._on_todo_change
|
|
1889
2080
|
)
|
|
1890
2081
|
except Exception as e:
|
|
1891
|
-
logger.warning("Failed to load
|
|
2082
|
+
logger.warning("Failed to load todos from .cdh: %s", e)
|
|
1892
2083
|
|
|
1893
2084
|
def get_session(self) -> Optional[AgentSession]:
|
|
1894
2085
|
return self._session
|