mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-25 05:52:34 +00:00
fix(acp): emit native plan updates for todo
This commit is contained in:
parent
6fc0fa6e50
commit
4444d5fe4f
2 changed files with 82 additions and 1 deletions
|
|
@ -14,6 +14,7 @@ from collections import deque
|
||||||
from typing import Any, Callable, Deque, Dict
|
from typing import Any, Callable, Deque, Dict
|
||||||
|
|
||||||
import acp
|
import acp
|
||||||
|
from acp.schema import AgentPlanUpdate, PlanEntry
|
||||||
|
|
||||||
from .tools import (
|
from .tools import (
|
||||||
build_tool_complete,
|
build_tool_complete,
|
||||||
|
|
@ -24,6 +25,52 @@ from .tools import (
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_plan_update_from_todo_result(result: Any) -> AgentPlanUpdate | None:
|
||||||
|
"""Translate Hermes' todo tool result into ACP's native plan update.
|
||||||
|
|
||||||
|
Zed renders ``sessionUpdate: plan`` as its first-class task/todo panel. The
|
||||||
|
Hermes agent already maintains task state through the ``todo`` tool, so the
|
||||||
|
ACP adapter should expose that state natively instead of only as a generic
|
||||||
|
tool-call transcript block.
|
||||||
|
"""
|
||||||
|
if not isinstance(result, str) or not result.strip():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(result)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(data, dict) or not isinstance(data.get("todos"), list):
|
||||||
|
return None
|
||||||
|
|
||||||
|
status_map = {
|
||||||
|
"pending": "pending",
|
||||||
|
"in_progress": "in_progress",
|
||||||
|
"completed": "completed",
|
||||||
|
# ACP plans only support pending/in_progress/completed. Preserve
|
||||||
|
# cancelled tasks as terminal entries instead of dropping them and
|
||||||
|
# making the client's full-list replacement lose visible context.
|
||||||
|
"cancelled": "completed",
|
||||||
|
}
|
||||||
|
entries: list[PlanEntry] = []
|
||||||
|
for item in data["todos"]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
content = str(item.get("content") or item.get("id") or "").strip()
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
raw_status = str(item.get("status") or "pending").strip()
|
||||||
|
status = status_map.get(raw_status, "pending")
|
||||||
|
if raw_status == "cancelled":
|
||||||
|
content = f"[cancelled] {content}"
|
||||||
|
entries.append(PlanEntry(content=content, priority="medium", status=status))
|
||||||
|
|
||||||
|
if not entries:
|
||||||
|
return None
|
||||||
|
return AgentPlanUpdate(session_update="plan", entries=entries)
|
||||||
|
|
||||||
|
|
||||||
def _send_update(
|
def _send_update(
|
||||||
conn: acp.Client,
|
conn: acp.Client,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
|
|
@ -175,6 +222,10 @@ def make_step_cb(
|
||||||
snapshot=meta.get("snapshot"),
|
snapshot=meta.get("snapshot"),
|
||||||
)
|
)
|
||||||
_send_update(conn, session_id, loop, update)
|
_send_update(conn, session_id, loop, update)
|
||||||
|
if tool_name == "todo":
|
||||||
|
plan_update = _build_plan_update_from_todo_result(result)
|
||||||
|
if plan_update is not None:
|
||||||
|
_send_update(conn, session_id, loop, plan_update)
|
||||||
if not queue:
|
if not queue:
|
||||||
tool_call_ids.pop(tool_name, None)
|
tool_call_ids.pop(tool_name, None)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import acp
|
import acp
|
||||||
from acp.schema import ToolCallStart, ToolCallProgress, AgentThoughtChunk, AgentMessageChunk
|
from acp.schema import AgentPlanUpdate, ToolCallStart, ToolCallProgress, AgentThoughtChunk, AgentMessageChunk
|
||||||
|
|
||||||
from acp_adapter.events import (
|
from acp_adapter.events import (
|
||||||
_send_update,
|
_send_update,
|
||||||
|
|
@ -296,6 +296,36 @@ class TestStepCallback:
|
||||||
}
|
}
|
||||||
mock_send.assert_called_once()
|
mock_send.assert_called_once()
|
||||||
|
|
||||||
|
def test_todo_completion_emits_native_plan_update(self, mock_conn, event_loop_fixture):
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
tool_call_ids = {"todo": deque(["tc-todo"])}
|
||||||
|
loop = event_loop_fixture
|
||||||
|
cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids, {})
|
||||||
|
todo_result = (
|
||||||
|
'{"todos":['
|
||||||
|
'{"id":"inspect","content":"Inspect ACP","status":"completed"},'
|
||||||
|
'{"id":"patch","content":"Patch renderer","status":"in_progress"},'
|
||||||
|
'{"id":"old","content":"Drop stale task","status":"cancelled"}'
|
||||||
|
'],"summary":{"total":3}}'
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("acp_adapter.events._send_update") as mock_send:
|
||||||
|
cb(1, [{"name": "todo", "result": todo_result}])
|
||||||
|
|
||||||
|
updates = [call.args[3] for call in mock_send.call_args_list]
|
||||||
|
plan_updates = [u for u in updates if getattr(u, "session_update", None) == "plan"]
|
||||||
|
assert len(plan_updates) == 1
|
||||||
|
plan = plan_updates[0]
|
||||||
|
assert isinstance(plan, AgentPlanUpdate)
|
||||||
|
assert [entry.content for entry in plan.entries] == [
|
||||||
|
"Inspect ACP",
|
||||||
|
"Patch renderer",
|
||||||
|
"[cancelled] Drop stale task",
|
||||||
|
]
|
||||||
|
assert [entry.status for entry in plan.entries] == ["completed", "in_progress", "completed"]
|
||||||
|
assert [entry.priority for entry in plan.entries] == ["medium", "medium", "medium"]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Message callback
|
# Message callback
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue