浏览代码

feat: parse chat events from text stream

zhenyu.hu 3 周之前
父节点
当前提交
de21e50298

+ 34 - 0
docs/plans/todo-22-text-event-protocol.md

@@ -0,0 +1,34 @@
+# Todo 22 Text Event Protocol Plan
+
+**Status:** done
+
+## Goal
+
+Remove ChatAgent dependence on provider `tool_calls`, parse event names from streamed text, and fix Agent config buttons that could stop responding after stale script/HTML mismatches.
+
+## Why
+
+The target architecture keeps ChatAgent focused on fast user-facing replies. EventAgent extends external capability after ChatAgent names the needed events. Provider tool-call payloads force ChatAgent to know tool-call mechanics and can delay or complicate streaming.
+
+## Scope
+
+- Parse a small text protocol from `delta.content`: visible text streams to the UI, while names inside `<agent_events>...</agent_events>` become internal `ToolCallEvent` values.
+- Stop passing `tools` to ChatAgent and stop adding assistant `tool_calls` or `tool` role replies to ChatAgent history.
+- Insert a generated system message listing enabled event names and descriptions for each ChatAgent round that can still request events.
+- Keep EventAgent tool results visible to the UI and pass only one aggregate `EventAgent results` user message back to ChatAgent.
+- Version the static JS URL and guard click binding so missing/old buttons do not abort initialization.
+
+## Verification
+
+- Parser test proves text events are extracted from streamed content and hidden from displayed message deltas.
+- Runtime tests prove ChatAgent receives no tools, no `tool_calls`, no `tool` history, and does receive the event catalog system message plus aggregate EventAgent summary.
+- Static tests prove the app script is versioned and Agent config buttons use guarded click binding.
+- `uv run pytest` passed: 50 tests, 1 existing Starlette deprecation warning.
+- Local browser check confirmed one ChatAgent button, one EventAgent button, versioned `app.js`, and both dialogs open on click.
+
+## Result
+
+- Added streamed text event parsing around `<agent_events>`.
+- Replaced ChatAgent tool schemas with a generated event catalog system message.
+- Preserved EventAgent execution and UI tool-result visibility while simplifying ChatAgent history.
+- Fixed the practical button failure mode caused by stale or mismatched static JS.

+ 1 - 0
docs/plans/todos.md

@@ -48,3 +48,4 @@
 | 19 | done | `docs/plans/todo-19-latency-oriented-polish.md` | Do one optimization pass focused on reducing perceived reply wait time and cleaning frontend/backend rough edges. | `uv run pytest` passes (`49 passed`), plus local browser check confirms tools are visible in the first viewport. |
 | 20 | done | `docs/plans/todo-20-agent-config-modals.md` | Split ChatAgent and EventAgent settings into separate configuration modals so the sidebar is no longer crowded. | `uv run pytest` passes. |
 | 21 | done | `docs/plans/todo-21-event-agent-context-summary.md` | Make EventAgent config part of parameter generation context and enqueue an aggregated tool-result summary for the next ChatAgent round. | `uv run pytest` passes (`50 passed`, one existing Starlette deprecation warning). |
+| 22 | done | `docs/plans/todo-22-text-event-protocol.md` | Replace ChatAgent tool calls with streamed text event parsing, inject generated event descriptions as a system message, and harden Agent config buttons. | `uv run pytest` passes (`50 passed`, one existing Starlette deprecation warning), plus local browser click check. |

+ 1 - 0
src/agent_lab/application/event_agent.py

@@ -84,6 +84,7 @@ class EventAgent:
         return ChatMessage(
             role="user",
             content="EventAgent results:\n" + "\n".join(reply.content for reply in replies),
+            name="event_agent",
         )
 
     def _tool_reply(self, event: ToolCallEvent, payload: dict[str, Any]) -> ChatMessage:

+ 26 - 19
src/agent_lab/application/runtime.py

@@ -112,24 +112,24 @@ class DebugRuntime:
             cached_tokens = 0
             saw_event = False
             assistant_content: list[str] = []
-            assistant_tool_calls: list[dict[str, Any]] = []
             events: list[ToolCallEvent] = []
             tool_replies: list[ChatMessage] = []
-            tools = (
-                self.registry.chat_tools(request.event_agent.enabled_tools)
+            enabled_events = (
+                request.event_agent.enabled_tools
                 if event_loops < request.event_agent.max_event_loops
                 else []
             )
+            chat_messages = self._chat_messages_for_round(messages, enabled_events)
             await self._audit(
                 queues,
                 "chat_round_started",
                 round_index=round_index,
-                tools_enabled=[tool["function"]["name"] for tool in tools],
+                events_enabled=enabled_events,
             )
 
             async for item in self.chat_client.stream_chat(
-                messages=messages,
-                tools=tools,
+                messages=chat_messages,
+                tools=[],
                 params=request.chat_agent,
             ):
                 if item.kind == "message_delta":
@@ -165,22 +165,11 @@ class DebugRuntime:
                         event_id=event.id,
                         event_name=event.name,
                     )
-                    assistant_tool_calls.append(
-                        {
-                            "id": event.id,
-                            "type": "function",
-                            "function": {
-                                "name": event.name,
-                                "arguments": event.raw_arguments,
-                            },
-                        }
-                    )
 
-            if assistant_content or assistant_tool_calls:
+            if assistant_content or events:
                 assistant_message = ChatMessage(
                     role="assistant",
                     content="".join(assistant_content),
-                    tool_calls=assistant_tool_calls or None,
                 )
                 messages.append(assistant_message)
 
@@ -209,7 +198,6 @@ class DebugRuntime:
                     result_count=len(tool_replies),
                     result_summary="\n".join(reply.content for reply in tool_replies),
                 )
-            messages.extend(tool_replies)
 
             elapsed_ms = self._elapsed_ms(started_at)
             await queues.output.put(
@@ -296,6 +284,25 @@ class DebugRuntime:
         messages.extend(request.pre_messages)
         return messages
 
+    def _chat_messages_for_round(
+        self,
+        messages: list[ChatMessage],
+        enabled_events: list[str],
+    ) -> list[ChatMessage]:
+        event_prompt = self.registry.chat_event_system_message(enabled_events)
+        if not event_prompt:
+            return list(messages)
+
+        insert_at = 0
+        while insert_at < len(messages) and messages[insert_at].role == "system":
+            insert_at += 1
+
+        return [
+            *messages[:insert_at],
+            ChatMessage(role="system", content=event_prompt),
+            *messages[insert_at:],
+        ]
+
     async def _drain_input(
         self,
         queues: RuntimeQueues,

+ 22 - 18
src/agent_lab/application/tools.py

@@ -3,17 +3,10 @@ from copy import deepcopy
 from dataclasses import dataclass
 from typing import Any
 
-from agent_lab.domain.events import ToolCallEvent
+from agent_lab.domain.events import EVENT_BLOCK_END, EVENT_BLOCK_START, ToolCallEvent
 from agent_lab.domain.messages import ChatMessage
 
 
-EVENT_NAME_ONLY_PARAMETERS: dict[str, Any] = {
-    "type": "object",
-    "properties": {},
-    "additionalProperties": False,
-}
-
-
 @dataclass(frozen=True)
 class ToolExecutionContext:
     history: Sequence[ChatMessage]
@@ -51,20 +44,31 @@ class ToolRegistry:
             for definition in self._definitions.values()
         ]
 
-    def chat_tools(self, enabled_names: Iterable[str]) -> list[dict[str, Any]]:
+    def chat_event_system_message(self, enabled_names: Iterable[str]) -> str:
         enabled = set(enabled_names)
-        return [
-            {
-                "type": "function",
-                "function": {
-                    "name": definition.name,
-                    "description": definition.description,
-                    "parameters": deepcopy(EVENT_NAME_ONLY_PARAMETERS),
-                },
-            }
+        event_lines = [
+            f"- {definition.name}: {definition.description}"
             for definition in self._definitions.values()
             if definition.name in enabled
         ]
+        if not event_lines:
+            return ""
+
+        return "\n".join(
+            [
+                "You may request EventAgent work using this text protocol.",
+                "Available events:",
+                *event_lines,
+                (
+                    "First write the user-facing reply normally. If events are needed, "
+                    "append this block after the visible reply:"
+                ),
+                EVENT_BLOCK_START,
+                "event_name",
+                EVENT_BLOCK_END,
+                "Use exact event names only, one per line. Do not include parameters.",
+            ]
+        )
 
     def handle(
         self,

+ 4 - 0
src/agent_lab/domain/events.py

@@ -3,6 +3,10 @@ from typing import Any
 from pydantic import BaseModel, ConfigDict
 
 
+EVENT_BLOCK_START = "<agent_events>"
+EVENT_BLOCK_END = "</agent_events>"
+
+
 class ToolCallEvent(BaseModel):
     model_config = ConfigDict(extra="forbid")
 

+ 1 - 2
src/agent_lab/domain/messages.py

@@ -1,5 +1,5 @@
 from dataclasses import dataclass
-from typing import Any, Literal
+from typing import Literal
 
 from pydantic import BaseModel, ConfigDict
 
@@ -13,7 +13,6 @@ class ChatMessage(BaseModel):
     content: str
     name: str | None = None
     tool_call_id: str | None = None
-    tool_calls: list[dict[str, Any]] | None = None
 
 
 class TokenUsage(BaseModel):

+ 3 - 10
src/agent_lab/infrastructure/chat_client.py

@@ -65,6 +65,8 @@ class OpenAICompatibleChatClient:
                 if data is None:
                     continue
                 if data == "[DONE]":
+                    for item in parser.flush():
+                        yield item
                     break
 
                 chunk = json.loads(data)
@@ -89,19 +91,10 @@ class OpenAICompatibleChatClient:
 
     def _serialize_message(self, message: ChatMessage) -> dict[str, Any]:
         if message.role == "tool":
-            if not message.tool_call_id:
-                raise ValueError("tool messages require tool_call_id")
-            payload = {
-                "role": message.role,
-                "content": message.content,
-                "tool_call_id": message.tool_call_id,
-            }
-            return payload
+            raise ValueError("tool messages are internal and cannot be sent to ChatAgent")
 
         payload = {
             "role": message.role,
             "content": message.content,
         }
-        if message.role == "assistant" and message.tool_calls:
-            payload["tool_calls"] = message.tool_calls
         return payload

+ 114 - 50
src/agent_lab/infrastructure/openai_compatible.py

@@ -1,13 +1,13 @@
-import json
+import re
 from typing import Any
 
-from agent_lab.domain.events import ToolCallEvent
+from agent_lab.domain.events import EVENT_BLOCK_END, EVENT_BLOCK_START, ToolCallEvent
 from agent_lab.domain.messages import StreamItem, TokenUsage
 
 
 class ChatCompletionStreamParser:
     def __init__(self) -> None:
-        self._tool_calls: dict[int, dict[str, Any]] = {}
+        self._text_events = TextEventProtocolParser()
 
     def feed(self, chunk: dict[str, Any]) -> list[StreamItem]:
         items: list[StreamItem] = []
@@ -16,13 +16,10 @@ class ChatCompletionStreamParser:
             delta = choice.get("delta") or {}
 
             if "content" in delta and delta["content"] is not None:
-                items.append(StreamItem.message_delta(delta["content"]))
+                items.extend(self._text_events.feed(delta["content"]))
 
-            for tool_call in delta.get("tool_calls") or []:
-                self._accumulate_tool_call(tool_call)
-
-            if choice.get("finish_reason") == "tool_calls":
-                items.extend(self._drain_tool_events())
+            if choice.get("finish_reason") is not None:
+                items.extend(self.flush())
 
         usage = chunk.get("usage")
         if usage is not None:
@@ -40,54 +37,121 @@ class ChatCompletionStreamParser:
 
         return items
 
-    def _accumulate_tool_call(self, tool_call: dict[str, Any]) -> None:
-        index = int(tool_call["index"])
-        state = self._tool_calls.setdefault(
-            index,
-            {
-                "id": "",
-                "name": "",
-                "arguments": "",
-            },
-        )
-
-        if tool_call.get("id"):
-            state["id"] = tool_call["id"]
-
-        function = tool_call.get("function") or {}
-        if function.get("name"):
-            state["name"] = function["name"]
-        if "arguments" in function and function["arguments"] is not None:
-            state["arguments"] += function["arguments"]
-
-    def _drain_tool_events(self) -> list[StreamItem]:
+    def flush(self) -> list[StreamItem]:
+        return self._text_events.flush()
+
+
+class TextEventProtocolParser:
+    _event_name_pattern = re.compile(r"^[A-Za-z_][A-Za-z0-9_.:-]*$")
+
+    def __init__(self) -> None:
+        self._mode = "message"
+        self._message_buffer = ""
+        self._event_buffer = ""
+        self._event_count = 0
+
+    def feed(self, content: str) -> list[StreamItem]:
+        items: list[StreamItem] = []
+        pending = content
+
+        while pending:
+            if self._mode == "message":
+                pending = self._feed_message(pending, items)
+                continue
+            pending = self._feed_events(pending, items)
+
+        return items
+
+    def flush(self) -> list[StreamItem]:
+        items: list[StreamItem] = []
+        if self._mode == "message":
+            self._emit_message(self._message_buffer, items)
+            self._message_buffer = ""
+            return items
+
+        items.extend(self._emit_event_lines(self._event_buffer))
+        self._event_buffer = ""
+        return items
+
+    def _feed_message(
+        self,
+        content: str,
+        items: list[StreamItem],
+    ) -> str:
+        self._message_buffer += content
+        marker_index = self._message_buffer.find(EVENT_BLOCK_START)
+        if marker_index >= 0:
+            self._emit_message(self._message_buffer[:marker_index], items)
+            remainder = self._message_buffer[
+                marker_index + len(EVENT_BLOCK_START):
+            ]
+            self._message_buffer = ""
+            self._mode = "events"
+            return remainder
+
+        keep = self._partial_marker_suffix_length(self._message_buffer)
+        if keep:
+            self._emit_message(self._message_buffer[:-keep], items)
+            self._message_buffer = self._message_buffer[-keep:]
+            return ""
+
+        self._emit_message(self._message_buffer, items)
+        self._message_buffer = ""
+        return ""
+
+    def _feed_events(
+        self,
+        content: str,
+        items: list[StreamItem],
+    ) -> str:
+        self._event_buffer += content
+        marker_index = self._event_buffer.find(EVENT_BLOCK_END)
+        if marker_index >= 0:
+            event_text = self._event_buffer[:marker_index]
+            items.extend(self._emit_event_lines(event_text))
+            remainder = self._event_buffer[marker_index + len(EVENT_BLOCK_END):]
+            self._event_buffer = ""
+            self._mode = "message"
+            return remainder
+
+        last_newline = self._event_buffer.rfind("\n")
+        if last_newline >= 0:
+            complete_text = self._event_buffer[: last_newline + 1]
+            self._event_buffer = self._event_buffer[last_newline + 1:]
+            items.extend(self._emit_event_lines(complete_text))
+        return ""
+
+    def _emit_message(self, content: str, items: list[StreamItem]) -> None:
+        if content:
+            items.append(StreamItem.message_delta(content))
+
+    def _emit_event_lines(self, text: str) -> list[StreamItem]:
         events: list[StreamItem] = []
-        for index in sorted(self._tool_calls):
-            state = self._tool_calls[index]
-            raw_arguments = state["arguments"]
+        for line in text.splitlines():
+            name = self._clean_event_name(line)
+            if not name:
+                continue
+            if not self._event_name_pattern.fullmatch(name):
+                continue
+            self._event_count += 1
             events.append(
                 StreamItem.event(
                     ToolCallEvent(
-                        id=state["id"],
-                        name=state["name"],
-                        arguments=self._parse_arguments(raw_arguments),
-                        raw_arguments=raw_arguments,
+                        id=f"event_{self._event_count}",
+                        name=name,
+                        arguments={},
+                        raw_arguments="{}",
                     )
                 )
             )
-
-        self._tool_calls.clear()
         return events
 
-    def _parse_arguments(self, raw_arguments: str) -> dict[str, Any]:
-        if not raw_arguments:
-            return {}
-
-        try:
-            parsed = json.loads(raw_arguments)
-        except json.JSONDecodeError:
-            return {"raw": raw_arguments}
+    def _clean_event_name(self, line: str) -> str:
+        return line.strip().removeprefix("-").strip().strip("`'\"")
 
-        if isinstance(parsed, dict):
-            return parsed
-        return {"value": parsed}
+    def _partial_marker_suffix_length(self, content: str) -> int:
+        max_length = min(len(content), len(EVENT_BLOCK_START) - 1)
+        for length in range(max_length, 0, -1):
+            if EVENT_BLOCK_START.startswith(content[-length:]):
+                return length
+        return 0

+ 20 - 13
src/agent_lab/presentation/static/app.js

@@ -23,35 +23,35 @@ let elapsedTimer = 0;
 let hasBackendRoundStats = false;
 let pendingEnabledTools = null;
 
-document.querySelector("#add-system-prompt").addEventListener("click", () => {
+bindClick("#add-system-prompt", () => {
   systemPrompts.append(createSystemPrompt(""));
 });
 
-document.querySelector("#add-pre-message").addEventListener("click", () => {
+bindClick("#add-pre-message", () => {
   preMessages.append(createPreMessage({ role: "user", content: "" }));
 });
 
-document.querySelector("#save-prompt-set").addEventListener("click", savePromptSet);
-document.querySelector("#load-prompt-set").addEventListener("click", loadPromptSet);
-document.querySelector("#delete-prompt-set").addEventListener("click", deletePromptSet);
-document.querySelector("#open-prompt-config").addEventListener("click", () => {
+bindClick("#save-prompt-set", savePromptSet);
+bindClick("#load-prompt-set", loadPromptSet);
+bindClick("#delete-prompt-set", deletePromptSet);
+bindClick("#open-prompt-config", () => {
   promptDialog.showModal();
 });
-document.querySelector("#close-prompt-config").addEventListener("click", () => {
+bindClick("#close-prompt-config", () => {
   promptDialog.close();
 });
-document.querySelector("#open-chat-config-panel").addEventListener("click", openChatConfig);
-document.querySelector("#close-chat-config").addEventListener("click", () => {
+bindClick("#open-chat-config-panel", openChatConfig);
+bindClick("#close-chat-config", () => {
   chatConfigDialog.close();
 });
-document.querySelector("#open-event-config-panel").addEventListener("click", openEventConfig);
-document.querySelector("#close-event-config").addEventListener("click", () => {
+bindClick("#open-event-config-panel", openEventConfig);
+bindClick("#close-event-config", () => {
   eventConfigDialog.close();
 });
-document.querySelector("#select-all-tools").addEventListener("click", () => {
+bindClick("#select-all-tools", () => {
   setAllTools(true);
 });
-document.querySelector("#clear-tools").addEventListener("click", () => {
+bindClick("#clear-tools", () => {
   setAllTools(false);
 });
 
@@ -71,6 +71,13 @@ function openEventConfig() {
   eventConfigDialog.showModal();
 }
 
+function bindClick(selector, handler) {
+  const element = document.querySelector(selector);
+  if (element) {
+    element.addEventListener("click", handler);
+  }
+}
+
 async function loadTools() {
   try {
     const response = await fetch("/api/tools");

+ 1 - 1
src/agent_lab/presentation/static/index.html

@@ -149,6 +149,6 @@
       </div>
     </template>
 
-    <script src="/static/app.js"></script>
+    <script src="/static/app.js?v=20260704-text-events"></script>
   </body>
 </html>

+ 33 - 57
tests/test_debug_runtime.py

@@ -100,7 +100,8 @@ class FakeChatClient:
             )
             return
 
-        assert any(message.role == "tool" for message in messages)
+        assert not any(message.role == "tool" for message in messages)
+        assert any(message.name == "event_agent" for message in messages)
         yield StreamItem.message_delta("final answer")
 
 
@@ -170,6 +171,7 @@ class MultiEventChatClient:
 class ToolCapturingChatClient:
     def __init__(self) -> None:
         self.tools: list[dict[str, Any]] = []
+        self.messages: list[ChatMessage] = []
 
     async def stream_chat(
         self,
@@ -177,6 +179,7 @@ class ToolCapturingChatClient:
         tools: list[dict],
         params: AgentParams,
     ) -> AsyncIterator[StreamItem]:
+        self.messages = list(messages)
         self.tools = list(tools)
         yield StreamItem.message_delta("final answer")
 
@@ -185,6 +188,7 @@ class EventLoopLimitChatClient:
     def __init__(self) -> None:
         self.calls = 0
         self.tools_by_call: list[list[dict[str, Any]]] = []
+        self.messages_by_call: list[list[ChatMessage]] = []
 
     async def stream_chat(
         self,
@@ -194,6 +198,7 @@ class EventLoopLimitChatClient:
     ) -> AsyncIterator[StreamItem]:
         self.calls += 1
         self.tools_by_call.append(list(tools))
+        self.messages_by_call.append(list(messages))
         if self.calls == 1:
             yield StreamItem.event(
                 ToolCallEvent(
@@ -466,39 +471,20 @@ async def test_runtime_batches_round_events_before_continuing_chat_agent():
     ]
     assert client.calls == 2
     assert [message.role for message in client.second_call_messages] == [
+        "system",
         "user",
         "assistant",
-        "tool",
-        "tool",
         "user",
     ]
-    assistant_message = client.second_call_messages[1]
+    assistant_message = client.second_call_messages[2]
     assert assistant_message.content == "Checking events."
-    assert assistant_message.tool_calls == [
-        {
-            "id": "call_1",
-            "type": "function",
-            "function": {"name": "handoff_note", "arguments": "{}"},
-        },
-        {
-            "id": "call_2",
-            "type": "function",
-            "function": {"name": "audit_note", "arguments": "{}"},
-        },
-    ]
-    assert [
-        json.loads(message.content)
-        for message in client.second_call_messages
-        if message.role == "tool"
-    ] == [
-        {"tool": "handoff_note", "message": "Checking events."},
-        {"tool": "audit_note", "message": "Checking events."},
-    ]
+    assert not any(message.role == "tool" for message in client.second_call_messages)
     assert client.second_call_messages[-1].content == (
         "EventAgent results:\n"
         '{"tool": "handoff_note", "message": "Checking events."}\n'
         '{"tool": "audit_note", "message": "Checking events."}'
     )
+    assert client.second_call_messages[-1].name == "event_agent"
 
 
 @pytest.mark.asyncio
@@ -545,8 +531,14 @@ async def test_runtime_finalizes_chat_after_reaching_event_loop_limit():
     business_outputs = _without_audit(outputs)
 
     assert client.calls == 2
-    assert client.tools_by_call[0][0]["function"]["name"] == "handoff_note"
+    assert client.tools_by_call[0] == []
     assert client.tools_by_call[1] == []
+    assert "Available events:" in client.messages_by_call[0][0].content
+    assert not any(
+        "Available events:" in message.content
+        for message in client.messages_by_call[1]
+        if message.role == "system"
+    )
     assert _message_types(outputs) == [
         "session_started",
         "event",
@@ -582,14 +574,15 @@ async def test_runtime_buffers_upstream_user_input_until_after_matching_tool_rep
 
     assert remaining[-1] == {"type": "done"}
     assert [message.role for message in client.second_call_messages] == [
+        "system",
         "user",
         "assistant",
-        "tool",
         "user",
         "user",
     ]
-    assert client.second_call_messages[2].tool_call_id == "call_1"
+    assert not any(message.role == "tool" for message in client.second_call_messages)
     assert client.second_call_messages[3].content.startswith("EventAgent results:\n")
+    assert client.second_call_messages[3].name == "event_agent"
     assert client.second_call_messages[4].content == "follow-up while tool runs"
 
 
@@ -729,7 +722,7 @@ async def test_runtime_run_consumes_output_queue_in_stream_order():
 
 
 @pytest.mark.asyncio
-async def test_runtime_preserves_assistant_tool_calls_before_tool_reply():
+async def test_runtime_continues_with_event_summary_without_tool_call_history():
     request = DebugRunRequest(
         user_message="debug this",
         system_prompts=["You are a debugger."],
@@ -744,32 +737,23 @@ async def test_runtime_preserves_assistant_tool_calls_before_tool_reply():
 
     assert client.calls == 2
     assert [message.role for message in client.second_call_messages] == [
+        "system",
         "system",
         "user",
         "assistant",
-        "tool",
         "user",
     ]
-    assistant_message = client.second_call_messages[2]
-    tool_message = client.second_call_messages[3]
+    assistant_message = client.second_call_messages[3]
     assert assistant_message.content == ""
-    assert assistant_message.tool_calls == [
-        {
-            "id": "call_1",
-            "type": "function",
-            "function": {
-                "name": "handoff_note",
-                "arguments": "{}",
-            },
-        }
-    ]
-    assert tool_message.tool_call_id == "call_1"
+    assert "handoff_note" in client.second_call_messages[1].content
+    assert not any(message.role == "tool" for message in client.second_call_messages)
     assert client.second_call_messages[4].content.startswith("EventAgent results:\n")
+    assert client.second_call_messages[4].name == "event_agent"
     assert outputs[-1] == {"type": "done"}
 
 
 @pytest.mark.asyncio
-async def test_runtime_passes_event_names_without_tool_parameters_to_chat_agent():
+async def test_runtime_passes_event_catalog_system_message_without_chat_tools():
     registry = ToolRegistry(
         [
             ToolDefinition(
@@ -800,20 +784,12 @@ async def test_runtime_passes_event_names_without_tool_parameters_to_chat_agent(
     outputs = [message async for message in runtime.run(request)]
 
     assert outputs[-1] == {"type": "done"}
-    assert client.tools == [
-        {
-            "type": "function",
-            "function": {
-                "name": "handoff_note",
-                "description": "Registry-owned handoff tool.",
-                "parameters": {
-                    "type": "object",
-                    "properties": {},
-                    "additionalProperties": False,
-                },
-            },
-        }
-    ]
+    assert client.tools == []
+    assert client.messages[0].role == "system"
+    assert "Available events:" in client.messages[0].content
+    assert "- handoff_note: Registry-owned handoff tool." in client.messages[0].content
+    assert "message" not in client.messages[0].content
+    assert "priority" not in client.messages[0].content
 
 
 @pytest.mark.asyncio

+ 17 - 26
tests/test_openai_stream_parser.py

@@ -2,7 +2,7 @@ from agent_lab.domain.events import ToolCallEvent
 from agent_lab.infrastructure.openai_compatible import ChatCompletionStreamParser
 
 
-def test_parser_emits_content_usage_and_tool_event():
+def test_parser_emits_visible_content_usage_and_text_protocol_events():
     parser = ChatCompletionStreamParser()
 
     items = []
@@ -23,19 +23,7 @@ def test_parser_emits_content_usage_and_tool_event():
             {
                 "choices": [
                     {
-                        "delta": {
-                            "tool_calls": [
-                                {
-                                    "index": 0,
-                                    "id": "call_1",
-                                    "type": "function",
-                                    "function": {
-                                        "name": "handoff_note",
-                                        "arguments": '{"message":"',
-                                    },
-                                }
-                            ]
-                        },
+                        "delta": {"content": "\n<agent_"},
                         "finish_reason": None,
                     }
                 ]
@@ -48,14 +36,9 @@ def test_parser_emits_content_usage_and_tool_event():
                 "choices": [
                     {
                         "delta": {
-                            "tool_calls": [
-                                {
-                                    "index": 0,
-                                    "function": {"arguments": 'from model"}'},
-                                }
-                            ]
+                            "content": "events>\nhandoff_note\nmock_search\n</agent_events>"
                         },
-                        "finish_reason": "tool_calls",
+                        "finish_reason": "stop",
                     }
                 ],
                 "usage": {
@@ -68,17 +51,25 @@ def test_parser_emits_content_usage_and_tool_event():
         )
     )
 
-    assert [item.content for item in items if item.kind == "message_delta"] == ["hello"]
+    assert [item.content for item in items if item.kind == "message_delta"] == [
+        "hello",
+        "\n",
+    ]
     events = [item.event for item in items if item.kind == "event"]
     assert events == [
         ToolCallEvent(
-            id="call_1",
+            id="event_1",
             name="handoff_note",
-            arguments={"message": "from model"},
-            raw_arguments='{"message":"from model"}',
+            arguments={},
+            raw_arguments="{}",
+        ),
+        ToolCallEvent(
+            id="event_2",
+            name="mock_search",
+            arguments={},
+            raw_arguments="{}",
         )
     ]
     usage = [item.usage for item in items if item.kind == "usage"][0]
     assert usage.total_tokens == 13
     assert usage.cached_tokens == 4
-

+ 25 - 71
tests/test_websocket_api.py

@@ -248,7 +248,7 @@ class HistoryCapturingChatClient:
 
 
 @pytest.mark.asyncio
-async def test_runtime_appends_assistant_message_before_tool_reply_history():
+async def test_runtime_appends_event_summary_without_tool_reply_history():
     request = DebugRunRequest(
         user_message="debug this",
         system_prompts=["You are a debugger."],
@@ -263,14 +263,17 @@ async def test_runtime_appends_assistant_message_before_tool_reply_history():
 
     assert client.calls == 2
     assert [message.role for message in client.second_call_messages] == [
+        "system",
         "system",
         "user",
         "assistant",
-        "tool",
         "user",
     ]
-    assert client.second_call_messages[2].content == "Need event help."
+    assert "Available events:" in client.second_call_messages[1].content
+    assert client.second_call_messages[3].content == "Need event help."
+    assert not any(message.role == "tool" for message in client.second_call_messages)
     assert client.second_call_messages[4].content.startswith("EventAgent results:\n")
+    assert client.second_call_messages[4].name == "event_agent"
     assert outputs[-1] == {"type": "done"}
 
 
@@ -360,72 +363,7 @@ async def test_openai_chat_client_uses_default_model_when_request_model_is_blank
 
 
 @pytest.mark.asyncio
-async def test_openai_chat_client_serializes_tool_reply_without_name():
-    captured_payloads: list[dict] = []
-    assistant_tool_call = {
-        "id": "call_1",
-        "type": "function",
-        "function": {
-            "name": "handoff_note",
-            "arguments": '{"message":"inspect"}',
-        },
-    }
-
-    def handler(request: httpx.Request) -> httpx.Response:
-        captured_payloads.append(json.loads(request.content))
-        return httpx.Response(
-            200,
-            content=b'data: {"choices":[{"delta":{"content":"done"},"finish_reason":null}]}\n\n',
-        )
-
-    async with httpx.AsyncClient(
-        transport=httpx.MockTransport(handler),
-        base_url="https://llm.test/v1",
-    ) as http_client:
-        client = OpenAICompatibleChatClient(
-            api_key="",
-            base_url="https://llm.test/v1",
-            default_model="provider-default",
-            request_timeout_seconds=5,
-            http_client=http_client,
-        )
-        [
-            item
-            async for item in client.stream_chat(
-                messages=[
-                    ChatMessage(
-                        role="assistant",
-                        content="",
-                        tool_calls=[assistant_tool_call],
-                    ),
-                    ChatMessage(
-                        role="tool",
-                        content="noted",
-                        name="handoff_note",
-                        tool_call_id="call_1",
-                    ),
-                ],
-                tools=[],
-                params=AgentParams(model="provider-default", temperature=0.3, max_tokens=50),
-            )
-        ]
-
-    assert captured_payloads[0]["messages"] == [
-        {
-            "role": "assistant",
-            "content": "",
-            "tool_calls": [assistant_tool_call],
-        },
-        {
-            "role": "tool",
-            "content": "noted",
-            "tool_call_id": "call_1",
-        },
-    ]
-
-
-@pytest.mark.asyncio
-async def test_openai_chat_client_rejects_tool_message_without_tool_call_id_before_network():
+async def test_openai_chat_client_rejects_internal_tool_messages_before_network():
     network_called = False
 
     def handler(request: httpx.Request) -> httpx.Response:
@@ -448,11 +386,17 @@ async def test_openai_chat_client_rejects_tool_message_without_tool_call_id_befo
             http_client=http_client,
         )
 
-        with pytest.raises(ValueError, match="tool messages require tool_call_id"):
+        with pytest.raises(ValueError, match="tool messages are internal"):
             [
                 item
                 async for item in client.stream_chat(
-                    messages=[ChatMessage(role="tool", content="orphan tool result")],
+                    messages=[
+                        ChatMessage(
+                            role="tool",
+                            content="internal tool result",
+                            tool_call_id="call_1",
+                        )
+                    ],
                     tools=[],
                     params=AgentParams(model="provider-default", temperature=0.3, max_tokens=50),
                 )
@@ -499,6 +443,16 @@ def test_static_prompt_workspace_is_opened_from_a_modal():
     assert "promptDialog.close()" in js
 
 
+def test_static_app_script_is_versioned_and_click_binding_is_guarded():
+    html = Path("src/agent_lab/presentation/static/index.html").read_text()
+    js = Path("src/agent_lab/presentation/static/app.js").read_text()
+
+    assert '<script src="/static/app.js?v=' in html
+    assert "function bindClick(" in js
+    assert 'bindClick("#open-chat-config-panel", openChatConfig)' in js
+    assert 'bindClick("#open-event-config-panel", openEventConfig)' in js
+
+
 def test_static_agent_config_controls_are_aligned_for_chat_and_event_agents():
     html = Path("src/agent_lab/presentation/static/index.html").read_text()
     js = Path("src/agent_lab/presentation/static/app.js").read_text()