소스 검색

feat: route event tools through event agent llm

zhenyu.hu 3 주 전
부모
커밋
f823686ccf

+ 40 - 0
docs/plans/todo-23-agent-prompt-and-event-llm.md

@@ -0,0 +1,40 @@
+# Todo 23 Agent Prompt and EventAgent LLM Plan
+
+**Status:** done
+
+## Goal
+
+Move prompt configuration into Agent configuration and make EventAgent use a real LLM tool-call step to generate tool parameters.
+
+## Why
+
+Prompt Config is a separate modal while ChatAgent already owns the prompt that shapes visible replies. This splits one concept across two places. EventAgent also still resolves mock parameters locally; the target design requires EventAgent to use conversation context plus the selected event tool schema to ask the LLM for real arguments before executing tools.
+
+## Scope
+
+- Remove the standalone Prompt Config button and modal.
+- Move prompt set management, system prompts, and pre-messages into the ChatAgent config modal.
+- Show the generated available-event protocol prompt in ChatAgent prompt by default.
+- Keep ChatAgent free of provider tools.
+- Restore provider `tool_calls` parsing for EventAgent LLM parameter generation only.
+- Make EventAgent call the LLM with only the corresponding event tool schema, then execute the returned arguments through the registry.
+- Run multiple EventAgent events concurrently.
+
+## Implementation
+
+- Removed the standalone Prompt Config modal and moved prompt-set, system prompt, and pre-message controls into ChatAgent config.
+- Added a generated available-event protocol prompt from the selected tools and populated it into ChatAgent system prompt by default.
+- Kept ChatAgent provider calls tool-free while preserving text event extraction from `delta.content`.
+- Restored provider `tool_calls` parsing for EventAgent LLM requests.
+- EventAgent now calls the LLM with exactly the selected event tool schema, then executes the returned arguments.
+- EventAgent handles multiple events concurrently with `asyncio.gather`.
+
+## Verification
+
+- Parser test proves provider `tool_calls` still become internal events for EventAgent.
+- EventAgent tests prove it calls the LLM with the target tool schema, passes history/config, and executes generated arguments.
+- Static UI tests prove Prompt Config is gone and prompt controls live in ChatAgent config.
+- Runtime tests continue to prove ChatAgent receives no tools or `tool` role history.
+- `uv run pytest` passes (`51 passed`, one existing Starlette deprecation warning).
+- `UV_CACHE_DIR=.uv-cache uv run python -m py_compile ...` passes for the changed Python modules.
+- Local server started and returned the page, static assets, and `/api/tools`; browser automation timed out during click verification, so modal click behavior is covered by static binding tests rather than a completed browser click run.

+ 1 - 0
docs/plans/todos.md

@@ -49,3 +49,4 @@
 | 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. |
+| 23 | done | `docs/plans/todo-23-agent-prompt-and-event-llm.md` | Move Prompt Config into ChatAgent config, show available-event prompt by default, and make EventAgent use LLM tool calls to generate tool arguments. | `uv run pytest` passes (`51 passed`, one existing Starlette deprecation warning); changed Python modules compile; local server returned page/static assets/tools API. |

+ 114 - 19
src/agent_lab/application/event_agent.py

@@ -1,15 +1,27 @@
+import asyncio
 import json
-from collections.abc import Iterable, Sequence
+from collections.abc import AsyncIterator, Iterable, Sequence
 from dataclasses import dataclass
-from typing import Any
+from typing import Any, Protocol
 
+from agent_lab.application.contracts import AgentParams
 from agent_lab.application.tools import (
     ToolExecutionContext,
     ToolRegistry,
     build_default_tool_registry,
 )
 from agent_lab.domain.events import ToolCallEvent
-from agent_lab.domain.messages import ChatMessage
+from agent_lab.domain.messages import ChatMessage, StreamItem
+
+
+class EventAgentChatClient(Protocol):
+    async def stream_chat(
+        self,
+        messages: list[ChatMessage],
+        tools: list[dict[str, Any]],
+        params: AgentParams,
+    ) -> AsyncIterator[StreamItem]:
+        ...
 
 
 @dataclass(frozen=True)
@@ -25,9 +37,13 @@ class EventAgent:
         self,
         enabled_tools: Iterable[str],
         registry: ToolRegistry | None = None,
+        chat_client: EventAgentChatClient | None = None,
+        params: AgentParams | None = None,
     ) -> None:
         self.enabled_tools = set(enabled_tools)
         self.registry = registry or build_default_tool_registry()
+        self.chat_client = chat_client
+        self.params = params or AgentParams()
 
     async def handle(
         self,
@@ -46,14 +62,23 @@ class EventAgent:
             )
 
         try:
-            payload = self.registry.handle(
-                event,
-                context=ToolExecutionContext(
-                    history=history,
-                    system_prompt=system_prompt,
-                    extra_body=extra_body or {},
-                ),
-            )
+            if self.chat_client is None:
+                payload = self.registry.handle(
+                    event,
+                    context=ToolExecutionContext(
+                        history=history,
+                        system_prompt=system_prompt,
+                        extra_body=extra_body or {},
+                    ),
+                )
+            else:
+                resolved_event = await self._resolve_event_with_llm(
+                    event,
+                    history,
+                    system_prompt,
+                    extra_body,
+                )
+                payload = self.registry.execute(resolved_event)
         except Exception as exc:
             payload = {
                 "tool": event.name,
@@ -68,22 +93,92 @@ class EventAgent:
         system_prompt: str = "",
         extra_body: dict[str, Any] | None = None,
     ) -> list[ChatMessage]:
-        return [
-            await self.handle(
-                event,
-                history=history,
-                system_prompt=system_prompt,
-                extra_body=extra_body,
+        return await asyncio.gather(
+            *[
+                self.handle(
+                    event,
+                    history=history,
+                    system_prompt=system_prompt,
+                    extra_body=extra_body,
+                )
+                for event in events
+            ]
+        )
+
+    async def _resolve_event_with_llm(
+        self,
+        event: ToolCallEvent,
+        history: Sequence[ChatMessage],
+        system_prompt: str,
+        extra_body: dict[str, Any] | None,
+    ) -> ToolCallEvent:
+        assert self.chat_client is not None
+        tool = self.registry.tool_schema(event.name)
+        if tool is None:
+            return event
+
+        messages = self._build_argument_messages(event, history, system_prompt)
+        params = self.params.model_copy(
+            update={
+                "extra_body": (
+                    extra_body if extra_body is not None else self.params.extra_body
+                ),
+            }
+        )
+        async for item in self.chat_client.stream_chat(
+            messages=messages,
+            tools=[tool],
+            params=params,
+        ):
+            if item.kind == "event" and item.event is not None:
+                return event.model_copy(
+                    update={
+                        "arguments": item.event.arguments,
+                        "raw_arguments": item.event.raw_arguments,
+                    }
+                )
+
+        raise ValueError(f"event agent did not return arguments for {event.name}")
+
+    def _build_argument_messages(
+        self,
+        event: ToolCallEvent,
+        history: Sequence[ChatMessage],
+        system_prompt: str,
+    ) -> list[ChatMessage]:
+        messages = [
+            ChatMessage(
+                role="system",
+                content=(
+                    "Generate parameters for the requested EventAgent tool. "
+                    "Call exactly the provided tool with valid JSON arguments."
+                ),
             )
-            for event in events
         ]
+        if system_prompt.strip():
+            messages.append(ChatMessage(role="system", content=system_prompt))
+        messages.extend(
+            message
+            for message in history
+            if message.role in {"system", "user", "assistant"}
+        )
+        messages.append(
+            ChatMessage(
+                role="user",
+                content=f"Resolve arguments for event `{event.name}`.",
+            )
+        )
+        return messages
 
     def summarize_replies(self, replies: Sequence[ChatMessage]) -> ChatMessage | None:
         if not replies:
             return None
         return ChatMessage(
             role="user",
-            content="EventAgent results:\n" + "\n".join(reply.content for reply in replies),
+            content=(
+                "EventAgent results:\n"
+                + "\n".join(reply.content for reply in replies)
+            ),
             name="event_agent",
         )
 

+ 14 - 1
src/agent_lab/application/runtime.py

@@ -8,7 +8,7 @@ from agent_lab.application.contracts import AgentParams, DebugRunRequest
 from agent_lab.application.event_agent import EventAgent, EventAgentRequest
 from agent_lab.application.queues import RuntimeQueues
 from agent_lab.application.tools import ToolRegistry, build_default_tool_registry
-from agent_lab.domain.events import ToolCallEvent
+from agent_lab.domain.events import EVENT_BLOCK_START, ToolCallEvent
 from agent_lab.domain.messages import ChatMessage, StreamItem
 
 
@@ -77,6 +77,8 @@ class DebugRuntime:
         event_agent = EventAgent(
             request.event_agent.enabled_tools,
             registry=self.registry,
+            chat_client=self.chat_client,
+            params=request.event_agent,
         )
         event_worker = asyncio.create_task(self._consume_events(queues, event_agent))
         try:
@@ -289,6 +291,9 @@ class DebugRuntime:
         messages: list[ChatMessage],
         enabled_events: list[str],
     ) -> list[ChatMessage]:
+        if self._has_event_instructions(messages):
+            return list(messages)
+
         event_prompt = self.registry.chat_event_system_message(enabled_events)
         if not event_prompt:
             return list(messages)
@@ -303,6 +308,14 @@ class DebugRuntime:
             *messages[insert_at:],
         ]
 
+    def _has_event_instructions(self, messages: list[ChatMessage]) -> bool:
+        return any(
+            message.role == "system"
+            and "Available events:" in message.content
+            and EVENT_BLOCK_START in message.content
+            for message in messages
+        )
+
     async def _drain_input(
         self,
         queues: RuntimeQueues,

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

@@ -70,6 +70,19 @@ class ToolRegistry:
             ]
         )
 
+    def tool_schema(self, name: str) -> dict[str, Any] | None:
+        definition = self._definitions.get(name)
+        if definition is None:
+            return None
+        return {
+            "type": "function",
+            "function": {
+                "name": definition.name,
+                "description": definition.description,
+                "parameters": deepcopy(definition.parameters),
+            },
+        }
+
     def handle(
         self,
         event: ToolCallEvent,
@@ -93,6 +106,15 @@ class ToolRegistry:
         )
         return definition.handler(resolved_event)
 
+    def execute(self, event: ToolCallEvent) -> dict[str, Any]:
+        definition = self._definitions.get(event.name)
+        if definition is None:
+            return {
+                "tool": event.name,
+                "error": "unknown tool",
+            }
+        return definition.handler(event)
+
     def _resolve_arguments(
         self,
         definition: ToolDefinition,

+ 60 - 0
src/agent_lab/infrastructure/openai_compatible.py

@@ -1,3 +1,4 @@
+import json
 import re
 from typing import Any
 
@@ -8,6 +9,7 @@ from agent_lab.domain.messages import StreamItem, TokenUsage
 class ChatCompletionStreamParser:
     def __init__(self) -> None:
         self._text_events = TextEventProtocolParser()
+        self._tool_calls: dict[int, dict[str, Any]] = {}
 
     def feed(self, chunk: dict[str, Any]) -> list[StreamItem]:
         items: list[StreamItem] = []
@@ -18,6 +20,12 @@ class ChatCompletionStreamParser:
             if "content" in delta and delta["content"] is not None:
                 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())
 
@@ -40,6 +48,58 @@ class ChatCompletionStreamParser:
     def flush(self) -> list[StreamItem]:
         return self._text_events.flush()
 
+    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]:
+        events: list[StreamItem] = []
+        for index in sorted(self._tool_calls):
+            state = self._tool_calls[index]
+            raw_arguments = state["arguments"]
+            events.append(
+                StreamItem.event(
+                    ToolCallEvent(
+                        id=state["id"],
+                        name=state["name"],
+                        arguments=self._parse_arguments(raw_arguments),
+                        raw_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}
+
+        if isinstance(parsed, dict):
+            return parsed
+        return {"value": parsed}
+
 
 class TextEventProtocolParser:
     _event_name_pattern = re.compile(r"^[A-Za-z_][A-Za-z0-9_.:-]*$")

+ 49 - 8
src/agent_lab/presentation/static/app.js

@@ -10,7 +10,6 @@ const preMessages = document.querySelector("#pre-messages");
 const preMessageTemplate = document.querySelector("#pre-message-template");
 const toolList = document.querySelector("#tool-list");
 const toolCount = document.querySelector("#tool-count");
-const promptDialog = document.querySelector("#prompt-dialog");
 const chatConfigDialog = document.querySelector("#chat-config-dialog");
 const eventConfigDialog = document.querySelector("#event-config-dialog");
 const PROMPT_SETS_STORAGE_KEY = "agent-lab.prompt-sets.v1";
@@ -22,6 +21,8 @@ let firstTokenAt = 0;
 let elapsedTimer = 0;
 let hasBackendRoundStats = false;
 let pendingEnabledTools = null;
+let availableTools = [];
+let generatedChatPrompt = "";
 
 bindClick("#add-system-prompt", () => {
   systemPrompts.append(createSystemPrompt(""));
@@ -34,12 +35,6 @@ bindClick("#add-pre-message", () => {
 bindClick("#save-prompt-set", savePromptSet);
 bindClick("#load-prompt-set", loadPromptSet);
 bindClick("#delete-prompt-set", deletePromptSet);
-bindClick("#open-prompt-config", () => {
-  promptDialog.showModal();
-});
-bindClick("#close-prompt-config", () => {
-  promptDialog.close();
-});
 bindClick("#open-chat-config-panel", openChatConfig);
 bindClick("#close-chat-config", () => {
   chatConfigDialog.close();
@@ -91,10 +86,12 @@ async function loadTools() {
 }
 
 function renderTools(tools) {
+  availableTools = tools;
   toolList.textContent = "";
   if (!tools.length) {
     toolList.textContent = "No tools available";
     updateToolCount();
+    updateChatSystemPromptDefault(tools);
     return;
   }
 
@@ -111,7 +108,10 @@ function renderTools(tools) {
     checkbox.className = "tool-toggle";
     checkbox.value = tool.name;
     checkbox.checked = true;
-    checkbox.addEventListener("change", updateToolCount);
+    checkbox.addEventListener("change", () => {
+      updateToolCount();
+      updateChatSystemPromptDefault(availableTools);
+    });
 
     label.append(checkbox, ` ${tool.name}`);
 
@@ -132,6 +132,7 @@ function renderTools(tools) {
     pendingEnabledTools = null;
   }
   updateToolCount();
+  updateChatSystemPromptDefault(tools);
 }
 
 function formatRequiredParameters(tool) {
@@ -146,6 +147,7 @@ function setAllTools(checked) {
     checkbox.checked = checked;
   });
   updateToolCount();
+  updateChatSystemPromptDefault(availableTools);
 }
 
 function updateToolCount() {
@@ -254,6 +256,7 @@ function restorePromptSet(promptSet) {
   document.querySelector("#max-event-loops").value = eventAgent.max_event_loops ?? 1;
   const enabledTools = eventAgent.enabled_tools || [];
   pendingEnabledTools = applySelectedTools(enabledTools) ? null : enabledTools;
+  updateChatSystemPromptDefault(availableTools);
 }
 
 function buildExtraBody(prefix) {
@@ -322,9 +325,47 @@ function applySelectedTools(toolNames) {
     checkbox.checked = selected.has(checkbox.value);
   });
   updateToolCount();
+  updateChatSystemPromptDefault(availableTools);
   return true;
 }
 
+function buildAvailableEventsPrompt(tools) {
+  const eventLines = tools.map((tool) => `- ${tool.name}: ${tool.description || "No description"}`);
+  if (!eventLines.length) {
+    return "";
+  }
+  return [
+    "You may request EventAgent work using this text protocol.",
+    "Available events:",
+    ...eventLines,
+    "First write the user-facing reply normally. If events are needed, append this block after the visible reply:",
+    "<agent_events>",
+    "event_name",
+    "</agent_events>",
+    "Use exact event names only, one per line. Do not include parameters.",
+  ].join("\n");
+}
+
+function updateChatSystemPromptDefault(tools) {
+  const selected = new Set(selectedTools());
+  const prompt = buildAvailableEventsPrompt(
+    tools.filter((tool) => selected.has(tool.name)),
+  );
+  const systemPrompt = document.querySelector("#chat-system-prompt");
+  const preview = document.querySelector("#chat-event-prompt-preview");
+  if (preview) {
+    preview.value = prompt;
+  }
+  if (!systemPrompt) {
+    generatedChatPrompt = prompt;
+    return;
+  }
+  if (!systemPrompt.value.trim() || systemPrompt.value === generatedChatPrompt) {
+    systemPrompt.value = prompt;
+  }
+  generatedChatPrompt = prompt;
+}
+
 function refreshPromptSetSelector(selectedName = "") {
   const previousName = selectedName || savedPromptSets.value;
   const names = Object.keys(readPromptSets().sets).sort();

+ 16 - 26
src/agent_lab/presentation/static/index.html

@@ -10,7 +10,6 @@
     <header class="topbar">
       <h1>Agent Lab</h1>
       <div class="top-actions">
-        <button id="open-prompt-config" type="button">Prompt Config</button>
         <div id="connection-status" class="status">Idle</div>
       </div>
     </header>
@@ -55,13 +54,25 @@
       </section>
     </main>
 
-    <dialog id="prompt-dialog" class="modal">
+    <dialog id="chat-config-dialog" class="modal">
       <form method="dialog" class="modal-shell">
         <div class="modal-head">
-          <h2>Prompt Workspace</h2>
-          <button id="close-prompt-config" type="button">Close</button>
+          <h2>Chat Agent Config</h2>
+          <button id="close-chat-config" type="button">Close</button>
         </div>
-
+        <section>
+          <label>Model <input id="model" placeholder="Backend default" /></label>
+          <label>Temperature <input id="temperature" type="number" min="0" max="2" step="0.1" value="0.2" /></label>
+          <label>Max Tokens <input id="max-tokens" type="number" min="1" step="1" value="800" /></label>
+          <label>System Prompt <textarea id="chat-system-prompt" rows="5" placeholder="Optional ChatAgent system prompt"></textarea></label>
+          <label>Available Events Prompt <textarea id="chat-event-prompt-preview" rows="7" readonly></textarea></label>
+          <fieldset>
+            <legend>Extra Body</legend>
+            <label class="checkbox"><input id="chat-thinking-disabled" type="checkbox" checked /> Disable thinking</label>
+            <label class="checkbox"><input id="chat-enable-search" type="checkbox" /> Enable search</label>
+            <label class="checkbox"><input id="chat-forced-search" type="checkbox" /> Forced search</label>
+          </fieldset>
+        </section>
         <section>
           <label>Name <input id="prompt-set-name" placeholder="default debugger" /></label>
           <label>
@@ -95,27 +106,6 @@
       </form>
     </dialog>
 
-    <dialog id="chat-config-dialog" class="modal">
-      <form method="dialog" class="modal-shell">
-        <div class="modal-head">
-          <h2>Chat Agent Config</h2>
-          <button id="close-chat-config" type="button">Close</button>
-        </div>
-        <section>
-          <label>Model <input id="model" placeholder="Backend default" /></label>
-          <label>Temperature <input id="temperature" type="number" min="0" max="2" step="0.1" value="0.2" /></label>
-          <label>Max Tokens <input id="max-tokens" type="number" min="1" step="1" value="800" /></label>
-          <label>System Prompt <textarea id="chat-system-prompt" rows="5" placeholder="Optional ChatAgent system prompt"></textarea></label>
-          <fieldset>
-            <legend>Extra Body</legend>
-            <label class="checkbox"><input id="chat-thinking-disabled" type="checkbox" checked /> Disable thinking</label>
-            <label class="checkbox"><input id="chat-enable-search" type="checkbox" /> Enable search</label>
-            <label class="checkbox"><input id="chat-forced-search" type="checkbox" /> Forced search</label>
-          </fieldset>
-        </section>
-      </form>
-    </dialog>
-
     <dialog id="event-config-dialog" class="modal">
       <form method="dialog" class="modal-shell">
         <div class="modal-head">

+ 50 - 0
tests/test_debug_runtime.py

@@ -32,6 +32,38 @@ def _message_types(outputs: list[dict[str, Any]]) -> list[str]:
     return [message["type"] for message in _without_audit(outputs)]
 
 
+def _event_tool_call_from_tools(
+    tools: list[dict],
+    messages: list[ChatMessage],
+) -> StreamItem:
+    tool_name = tools[0]["function"]["name"]
+    content = ""
+    for role in ("assistant", "user"):
+        content = next(
+            (
+                message.content
+                for message in reversed(messages)
+                if message.role == role and message.content.strip()
+            ),
+            "",
+        )
+        if content:
+            break
+    arguments = {
+        "message": content,
+        "query": content,
+        "title": content,
+    }
+    return StreamItem.event(
+        ToolCallEvent(
+            id="event_agent_call_1",
+            name=tool_name,
+            arguments=arguments,
+            raw_arguments=json.dumps(arguments),
+        )
+    )
+
+
 async def _next_non_audit(
     stream: AsyncIterator[dict[str, Any]],
 ) -> dict[str, Any]:
@@ -88,6 +120,9 @@ class FakeChatClient:
         tools: list[dict],
         params: AgentParams,
     ) -> AsyncIterator[StreamItem]:
+        if tools:
+            yield _event_tool_call_from_tools(tools, messages)
+            return
         self.calls += 1
         if self.calls == 1:
             yield StreamItem.event(
@@ -116,6 +151,9 @@ class StrictHistoryChatClient:
         tools: list[dict],
         params: AgentParams,
     ) -> AsyncIterator[StreamItem]:
+        if tools:
+            yield _event_tool_call_from_tools(tools, messages)
+            return
         self.calls += 1
         if self.calls == 1:
             yield StreamItem.event(
@@ -143,6 +181,9 @@ class MultiEventChatClient:
         tools: list[dict],
         params: AgentParams,
     ) -> AsyncIterator[StreamItem]:
+        if tools:
+            yield _event_tool_call_from_tools(tools, messages)
+            return
         self.calls += 1
         if self.calls == 1:
             yield StreamItem.message_delta("Checking events.")
@@ -196,6 +237,9 @@ class EventLoopLimitChatClient:
         tools: list[dict],
         params: AgentParams,
     ) -> AsyncIterator[StreamItem]:
+        if tools:
+            yield _event_tool_call_from_tools(tools, messages)
+            return
         self.calls += 1
         self.tools_by_call.append(list(tools))
         self.messages_by_call.append(list(messages))
@@ -241,6 +285,9 @@ class EventRoundStatsChatClient:
         tools: list[dict],
         params: AgentParams,
     ) -> AsyncIterator[StreamItem]:
+        if tools:
+            yield _event_tool_call_from_tools(tools, messages)
+            return
         self.calls += 1
         if self.calls == 1:
             yield StreamItem.event(
@@ -274,6 +321,9 @@ class SlowAfterEventChatClient:
         tools: list[dict],
         params: AgentParams,
     ) -> AsyncIterator[StreamItem]:
+        if tools:
+            yield _event_tool_call_from_tools(tools, messages)
+            return
         self.calls += 1
         if self.calls == 1:
             yield StreamItem.message_delta("Need event.")

+ 67 - 19
tests/test_event_agent.py

@@ -1,16 +1,52 @@
 import json
+from collections.abc import AsyncIterator
 
 import pytest
 
+from agent_lab.application.contracts import AgentParams
 from agent_lab.application.event_agent import EventAgent
 from agent_lab.application.tools import ToolDefinition, ToolExecutionContext, ToolRegistry
 from agent_lab.domain.events import ToolCallEvent
-from agent_lab.domain.messages import ChatMessage
+from agent_lab.domain.messages import ChatMessage, StreamItem
+
+
+class ToolCallingChatClient:
+    def __init__(self, arguments: dict) -> None:
+        self.arguments = arguments
+        self.calls: list[dict] = []
+
+    async def stream_chat(
+        self,
+        messages: list[ChatMessage],
+        tools: list[dict],
+        params: AgentParams,
+    ) -> AsyncIterator[StreamItem]:
+        self.calls.append(
+            {
+                "messages": list(messages),
+                "tools": list(tools),
+                "params": params,
+            }
+        )
+        tool_name = tools[0]["function"]["name"]
+        yield StreamItem.event(
+            ToolCallEvent(
+                id="llm_call_1",
+                name=tool_name,
+                arguments=self.arguments,
+                raw_arguments=json.dumps(self.arguments),
+            )
+        )
 
 
 @pytest.mark.asyncio
-async def test_event_agent_resolves_tool_arguments_from_history():
-    agent = EventAgent(enabled_tools=["handoff_note"])
+async def test_event_agent_resolves_tool_arguments_with_llm_tool_call():
+    chat_client = ToolCallingChatClient({"message": "LLM generated handoff"})
+    agent = EventAgent(
+        enabled_tools=["handoff_note"],
+        chat_client=chat_client,
+        params=AgentParams(model="event-model", temperature=0, max_tokens=80),
+    )
     event = ToolCallEvent(
         id="call_1",
         name="handoff_note",
@@ -29,8 +65,25 @@ async def test_event_agent_resolves_tool_arguments_from_history():
     assert reply.name == "handoff_note"
     assert json.loads(reply.content) == {
         "tool": "handoff_note",
-        "message": "I need the event agent.",
+        "message": "LLM generated handoff",
     }
+    assert chat_client.calls[0]["tools"] == [
+        {
+            "type": "function",
+            "function": {
+                "name": "handoff_note",
+                "description": "Send a note to the event agent.",
+                "parameters": {
+                    "type": "object",
+                    "properties": {
+                        "message": {"type": "string"},
+                    },
+                    "required": ["message"],
+                },
+            },
+        }
+    ]
+    assert chat_client.calls[0]["params"].model == "event-model"
 
 
 @pytest.mark.asyncio
@@ -115,19 +168,10 @@ async def test_event_agent_returns_structured_error_when_tool_handler_raises():
 
 
 @pytest.mark.asyncio
-async def test_event_agent_argument_resolver_receives_agent_config_context():
-    captured_contexts: list[ToolExecutionContext] = []
-
-    def resolve_arguments(
-        event: ToolCallEvent,
-        context: ToolExecutionContext,
-    ) -> dict:
-        captured_contexts.append(context)
-        return {
-            "message": context.system_prompt,
-            "thinking": context.extra_body["thinking"]["type"],
-        }
-
+async def test_event_agent_llm_receives_history_and_agent_config_context():
+    chat_client = ToolCallingChatClient(
+        {"message": "Use strict tool parameters.", "thinking": "disabled"}
+    )
     registry = ToolRegistry(
         [
             ToolDefinition(
@@ -139,7 +183,6 @@ async def test_event_agent_argument_resolver_receives_agent_config_context():
                     "message": event.arguments["message"],
                     "thinking": event.arguments["thinking"],
                 },
-                argument_resolver=resolve_arguments,
             )
         ]
     )
@@ -153,6 +196,8 @@ async def test_event_agent_argument_resolver_receives_agent_config_context():
     reply = await EventAgent(
         enabled_tools=["handoff_note"],
         registry=registry,
+        chat_client=chat_client,
+        params=AgentParams(model="event-model", temperature=0.4, max_tokens=120),
     ).handle(
         event,
         history=[ChatMessage(role="user", content="debug this")],
@@ -160,7 +205,10 @@ async def test_event_agent_argument_resolver_receives_agent_config_context():
         extra_body={"thinking": {"type": "disabled"}},
     )
 
-    assert captured_contexts[0].history[0].content == "debug this"
+    messages = chat_client.calls[0]["messages"]
+    assert any(message.content == "debug this" for message in messages)
+    assert any(message.content == "Use strict tool parameters." for message in messages)
+    assert chat_client.calls[0]["params"].extra_body == {"thinking": {"type": "disabled"}}
     assert json.loads(reply.content) == {
         "tool": "handoff_note",
         "message": "Use strict tool parameters.",

+ 58 - 0
tests/test_openai_stream_parser.py

@@ -73,3 +73,61 @@ def test_parser_emits_visible_content_usage_and_text_protocol_events():
     usage = [item.usage for item in items if item.kind == "usage"][0]
     assert usage.total_tokens == 13
     assert usage.cached_tokens == 4
+
+
+def test_parser_emits_provider_tool_call_events_for_event_agent():
+    parser = ChatCompletionStreamParser()
+
+    items = []
+    items.extend(
+        parser.feed(
+            {
+                "choices": [
+                    {
+                        "delta": {
+                            "tool_calls": [
+                                {
+                                    "index": 0,
+                                    "id": "call_1",
+                                    "type": "function",
+                                    "function": {
+                                        "name": "mock_search",
+                                        "arguments": '{"query":"',
+                                    },
+                                }
+                            ]
+                        },
+                        "finish_reason": None,
+                    }
+                ]
+            }
+        )
+    )
+    items.extend(
+        parser.feed(
+            {
+                "choices": [
+                    {
+                        "delta": {
+                            "tool_calls": [
+                                {
+                                    "index": 0,
+                                    "function": {"arguments": 'latency docs"}'},
+                                }
+                            ]
+                        },
+                        "finish_reason": "tool_calls",
+                    }
+                ]
+            }
+        )
+    )
+
+    assert [item.event for item in items if item.kind == "event"] == [
+        ToolCallEvent(
+            id="call_1",
+            name="mock_search",
+            arguments={"query": "latency docs"},
+            raw_arguments='{"query":"latency docs"}',
+        )
+    ]

+ 51 - 7
tests/test_websocket_api.py

@@ -73,6 +73,38 @@ def _request_payload() -> dict:
     }
 
 
+def _event_tool_call_from_tools(
+    tools: list[dict],
+    messages: list[ChatMessage],
+) -> StreamItem:
+    tool_name = tools[0]["function"]["name"]
+    content = ""
+    for role in ("assistant", "user"):
+        content = next(
+            (
+                message.content
+                for message in reversed(messages)
+                if message.role == role and message.content.strip()
+            ),
+            "",
+        )
+        if content:
+            break
+    arguments = {
+        "message": content,
+        "query": content,
+        "title": content,
+    }
+    return StreamItem.event(
+        ToolCallEvent(
+            id="event_agent_call_1",
+            name=tool_name,
+            arguments=arguments,
+            raw_arguments=json.dumps(arguments),
+        )
+    )
+
+
 def test_agent_params_defaults_include_extra_body_and_event_agent_defaults_to_one_round():
     chat_params = AgentParams()
     event_params = EventAgentParams()
@@ -230,6 +262,9 @@ class HistoryCapturingChatClient:
         tools: list[dict],
         params: AgentParams,
     ) -> AsyncIterator[StreamItem]:
+        if tools:
+            yield _event_tool_call_from_tools(tools, messages)
+            return
         self.calls += 1
         if self.calls == 1:
             yield StreamItem.message_delta("Need event help.")
@@ -422,25 +457,34 @@ def test_static_tools_ui_is_dynamic_and_not_hardcoded_to_handoff_note_checkbox()
     assert "fetch" in js
 
 
-def test_static_prompt_workspace_controls_are_available():
+def test_static_prompt_workspace_controls_are_available_in_chat_agent_config():
     html = Path("src/agent_lab/presentation/static/index.html").read_text()
+    chat_dialog_start = html.index('<dialog id="chat-config-dialog"')
+    event_dialog_start = html.index('<dialog id="event-config-dialog"')
+    chat_dialog_html = html[chat_dialog_start:event_dialog_start]
 
+    assert 'id="open-prompt-config"' not in html
+    assert '<dialog id="prompt-dialog"' not in html
     assert 'id="prompt-set-name"' in html
     assert 'id="saved-prompt-sets"' in html
     assert 'id="save-prompt-set"' in html
     assert 'id="load-prompt-set"' in html
     assert 'id="delete-prompt-set"' in html
+    assert 'id="prompt-set-name"' in chat_dialog_html
+    assert 'id="system-prompts"' in chat_dialog_html
+    assert 'id="pre-messages"' in chat_dialog_html
 
 
-def test_static_prompt_workspace_is_opened_from_a_modal():
+def test_static_chat_agent_prompt_defaults_to_available_event_instructions():
     html = Path("src/agent_lab/presentation/static/index.html").read_text()
     js = Path("src/agent_lab/presentation/static/app.js").read_text()
 
-    assert 'id="open-prompt-config"' in html
-    assert '<dialog id="prompt-dialog"' in html
-    assert 'id="close-prompt-config"' in html
-    assert "promptDialog.showModal()" in js
-    assert "promptDialog.close()" in js
+    assert 'id="chat-system-prompt"' in html
+    assert "function buildAvailableEventsPrompt(" in js
+    assert "Available events:" in js
+    assert "updateChatSystemPromptDefault(tools)" in js
+    assert "#open-prompt-config" not in js
+    assert "promptDialog" not in js
 
 
 def test_static_app_script_is_versioned_and_click_binding_is_guarded():