Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions echo/agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,42 @@ docker run --rm -p 8001:8001 --env-file .env echo-agent:local
- `GET /health`
- `POST /copilotkit/{project_id}`

## Tool taxonomy

Tools defined in `agent.py` fall into three buckets:

- **UI tools** render a card in the chat timeline. The `UI_TOOLS` frozenset in
`agent.py` is the source of truth: `navigateTo`, `proposeCanvas`,
`proposeGoal`, `proposeProjectUpdate`, `noteInsight`, `sendProgressUpdate`.
Each of these also carries a "renders a card in the chat UI" docstring line.
- **Read tools** fetch project data or product knowledge for the model only:
`findConversationsByKeywords`, `listConversationSummary`,
`listConversationFullTranscript`, `grepConversationSnippets`,
`listProjectConversations`, `getProjectSettings`, `getProjectTags`,
`getPortalLink`, `listDocs`, `readDoc`, `grepDocs`, `readSkill`,
`listProjectChats`, `readChat`, `getLiveConversationStatus`, `readMemory`,
`readGoal`, `listMethodologies`, `listCanvases`, `get_project_scope`.
- **Write tools** change durable state: `editProjectTags`, `editCanvas`,
`addToCanvas`, `removeFromCanvas`, `pauseCanvasLoop`, `resumeCanvasLoop`,
`stopCanvasLoop`, `remember`, `reachOutToDembraneSupport`, `noteInsight`
(which is also a UI tool).

### Renamed tools (wave 32)

Some tools were renamed for host-visible clarity. Persisted run histories still
carry the OLD names, so `TOOL_NAME_RENAMES` in `agent.py` normalizes old -> new
at the history-replay boundary (Vertex 400s on an unknown function name). The
old names are never registered as visible tools.

| old | new |
| --- | --- |
| `findConvosByKeywords` | `findConversationsByKeywords` |
| `listConvoSummary` | `listConversationSummary` |
| `listConvoFullTranscript` | `listConversationFullTranscript` |
| `grepConvoSnippets` | `grepConversationSnippets` |
| `reachOutToDembrane` | `reachOutToDembraneSupport` |
| `recordInsight` | `noteInsight` |

## Notes

- This service is intentionally scoped to one purpose: agentic chat execution.
Expand Down
196 changes: 163 additions & 33 deletions echo/agent/agent.py

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions echo/agent/echo_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,20 @@ async def list_project_tags(self, project_id: str) -> list[dict[str, Any]]:
payload = await self.get(f"/v2/bff/tags?project_id={project_id}")
return payload if isinstance(payload, list) else []

async def edit_project_tags(
self,
project_id: str,
add: list[str],
remove: list[str],
) -> dict[str, Any]:
response = await self._client.post(
f"/agentic/projects/{project_id}/tags",
json={"add": add, "remove": remove},
)
response.raise_for_status()
payload = response.json()
return payload if isinstance(payload, dict) else {}

async def get_conversation_transcript(self, conversation_id: str) -> str:
response = await self._client.get(f"/conversations/{conversation_id}/transcript")
response.raise_for_status()
Expand Down
71 changes: 65 additions & 6 deletions echo/agent/tests/test_agent_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -503,7 +503,7 @@ def test_fused_parallel_tool_call_name_is_split_with_concatenated_json_args():
tool_calls=[
{
"id": "call-fused",
"name": "recordInsightproposeCanvas",
"name": "noteInsightproposeCanvas",
"args": (
'{"kind":"wish","content":"The host wants a wall."}'
'{"brief":"Create a wall.","expires_at":"2026-07-10T00:00:00Z"}'
Expand All @@ -514,11 +514,11 @@ def test_fused_parallel_tool_call_name_is_split_with_concatenated_json_args():

normalized = _normalize_fused_tool_calls(
message,
{"recordInsight", "proposeCanvas", "remember"},
{"noteInsight", "proposeCanvas", "remember"},
)

assert [call["name"] for call in normalized.tool_calls] == [
"recordInsight",
"noteInsight",
"proposeCanvas",
]
assert normalized.tool_calls[0]["args"] == {
Expand All @@ -537,7 +537,7 @@ def test_fused_invalid_tool_call_is_recovered_when_args_are_concatenated_json():
invalid_tool_calls=[
{
"id": "call-fused",
"name": "recordInsightproposeCanvas",
"name": "noteInsightproposeCanvas",
"args": '{"kind":"wish","content":"Need a wall."}{"brief":"Create a wall."}',
"error": "Could not parse tool args",
}
Expand All @@ -546,11 +546,70 @@ def test_fused_invalid_tool_call_is_recovered_when_args_are_concatenated_json():

normalized = _normalize_fused_tool_calls(
message,
{"recordInsight", "proposeCanvas", "remember"},
{"noteInsight", "proposeCanvas", "remember"},
)

assert normalized.invalid_tool_calls == []
assert [call["name"] for call in normalized.tool_calls] == [
"recordInsight",
"noteInsight",
"proposeCanvas",
]


def test_fused_old_tool_name_in_replay_is_split_and_renamed_to_new_names():
# A replayed history may still carry the pre-wave-32 fused name; splitting
# against the recognized set (new + old) must land on the new names.
recognized = {
"noteInsight",
"proposeCanvas",
"remember",
} | set(agent.TOOL_NAME_RENAMES.keys())
message = AIMessage.model_construct(
content="",
tool_calls=[
{
"id": "call-fused",
"name": "recordInsightproposeCanvas",
"args": (
'{"kind":"wish","content":"The host wants a wall."}'
'{"brief":"Create a wall."}'
),
}
],
)

normalized = _normalize_fused_tool_calls(message, recognized)

assert [call["name"] for call in normalized.tool_calls] == [
"noteInsight",
"proposeCanvas",
]


def test_replayed_history_old_tool_names_are_normalized_to_new_names():
from langchain_core.messages import ToolMessage

from agent import _normalize_message_tool_names

recognized = {"noteInsight", "findConversationsByKeywords"} | set(
agent.TOOL_NAME_RENAMES.keys()
)

ai_message = AIMessage.model_construct(
content="(calling tools)",
tool_calls=[
{"id": "call-1", "name": "findConvosByKeywords", "args": {"keywords": "x"}},
{"id": "call-2", "name": "recordInsight", "args": {"kind": "wish", "content": "y"}},
],
)
normalized_ai = _normalize_message_tool_names(ai_message, recognized)
assert [call["name"] for call in normalized_ai.tool_calls] == [
"findConversationsByKeywords",
"noteInsight",
]

tool_message = ToolMessage(
content="{}", name="reachOutToDembrane", tool_call_id="call-3"
)
normalized_tool = _normalize_message_tool_names(tool_message, recognized)
assert normalized_tool.name == "reachOutToDembraneSupport"
Loading
Loading