Browse Source

feat: pass event agent context summary

zhenyu.hu 3 weeks ago
parent
commit
c6d2849f04

+ 8 - 2
docs/plans/todo-21-event-agent-context-summary.md

@@ -1,6 +1,6 @@
 # Todo 21 EventAgent Context Summary Plan
 
-**Status:** in_progress
+**Status:** done
 
 ## Goal
 
@@ -21,4 +21,10 @@ The UI and contracts already expose EventAgent `system_prompt` and `extra_body`,
 
 - EventAgent tests prove custom resolvers receive `system_prompt` and `extra_body`.
 - Runtime tests prove the second ChatAgent round contains tool replies followed by the aggregate summary.
-- Full suite passes with `uv run pytest`.
+- `uv run pytest` passed: 50 tests, 1 existing Starlette deprecation warning.
+
+## Result
+
+- Added `ToolExecutionContext` for EventAgent parameter generation.
+- Passed EventAgent `system_prompt` and `extra_body` from `DebugRuntime` into EventAgent requests.
+- Kept individual provider-compatible `tool` replies and added one aggregate `EventAgent results` user message for the next ChatAgent round.

+ 1 - 1
docs/plans/todos.md

@@ -47,4 +47,4 @@
 | 18 | done | `docs/plans/todo-18-prompt-modal-tool-ui.md` | Move prompt workspace into a modal and improve visible tool-management UI. | `uv run pytest` passes (`46 passed`), plus local browser check confirms modal and tool UI render. |
 | 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 | in_progress | `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. | Tests prove resolvers receive EventAgent config and ChatAgent receives a combined EventAgent summary. |
+| 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). |

+ 36 - 3
src/agent_lab/application/event_agent.py

@@ -3,7 +3,11 @@ from collections.abc import Iterable, Sequence
 from dataclasses import dataclass
 from typing import Any
 
-from agent_lab.application.tools import ToolRegistry, build_default_tool_registry
+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
 
@@ -12,6 +16,8 @@ from agent_lab.domain.messages import ChatMessage
 class EventAgentRequest:
     events: list[ToolCallEvent]
     history: list[ChatMessage]
+    system_prompt: str = ""
+    extra_body: dict[str, Any] | None = None
 
 
 class EventAgent:
@@ -27,6 +33,8 @@ class EventAgent:
         self,
         event: ToolCallEvent,
         history: Sequence[ChatMessage] = (),
+        system_prompt: str = "",
+        extra_body: dict[str, Any] | None = None,
     ) -> ChatMessage:
         if event.name not in self.enabled_tools:
             return self._tool_reply(
@@ -38,7 +46,14 @@ class EventAgent:
             )
 
         try:
-            payload = self.registry.handle(event, history=history)
+            payload = self.registry.handle(
+                event,
+                context=ToolExecutionContext(
+                    history=history,
+                    system_prompt=system_prompt,
+                    extra_body=extra_body or {},
+                ),
+            )
         except Exception as exc:
             payload = {
                 "tool": event.name,
@@ -50,8 +65,26 @@ class EventAgent:
         self,
         events: Sequence[ToolCallEvent],
         history: Sequence[ChatMessage],
+        system_prompt: str = "",
+        extra_body: dict[str, Any] | None = None,
     ) -> list[ChatMessage]:
-        return [await self.handle(event, history=history) for event in events]
+        return [
+            await self.handle(
+                event,
+                history=history,
+                system_prompt=system_prompt,
+                extra_body=extra_body,
+            )
+            for event in events
+        ]
+
+    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),
+        )
 
     def _tool_reply(self, event: ToolCallEvent, payload: dict[str, Any]) -> ChatMessage:
         return ChatMessage(

+ 10 - 2
src/agent_lab/application/runtime.py

@@ -189,6 +189,8 @@ class DebugRuntime:
                     EventAgentRequest(
                         events=events,
                         history=list(messages),
+                        system_prompt=request.event_agent.system_prompt,
+                        extra_body=request.event_agent.extra_body,
                     )
                 )
                 tool_replies = await self._wait_for_tool_replies(
@@ -248,11 +250,17 @@ class DebugRuntime:
             request = await queues.events.get()
             if request is None:
                 return
-            for reply in await event_agent.handle_many(
+            replies = await event_agent.handle_many(
                 request.events,
                 history=request.history,
-            ):
+                system_prompt=request.system_prompt,
+                extra_body=request.extra_body,
+            )
+            for reply in replies:
                 await queues.input.put(reply)
+            summary = event_agent.summarize_replies(replies)
+            if summary is not None:
+                await queues.input.put(summary)
 
     async def _wait_for_tool_replies(
         self,

+ 33 - 20
src/agent_lab/application/tools.py

@@ -7,12 +7,6 @@ from agent_lab.domain.events import ToolCallEvent
 from agent_lab.domain.messages import ChatMessage
 
 
-ToolHandler = Callable[[ToolCallEvent], dict[str, Any]]
-ToolArgumentResolver = Callable[
-    [ToolCallEvent, Sequence[ChatMessage]],
-    dict[str, Any],
-]
-
 EVENT_NAME_ONLY_PARAMETERS: dict[str, Any] = {
     "type": "object",
     "properties": {},
@@ -20,6 +14,20 @@ EVENT_NAME_ONLY_PARAMETERS: dict[str, Any] = {
 }
 
 
+@dataclass(frozen=True)
+class ToolExecutionContext:
+    history: Sequence[ChatMessage]
+    system_prompt: str = ""
+    extra_body: dict[str, Any] | None = None
+
+
+ToolHandler = Callable[[ToolCallEvent], dict[str, Any]]
+ToolArgumentResolver = Callable[
+    [ToolCallEvent, ToolExecutionContext],
+    dict[str, Any],
+]
+
+
 @dataclass(frozen=True)
 class ToolDefinition:
     name: str
@@ -61,7 +69,7 @@ class ToolRegistry:
     def handle(
         self,
         event: ToolCallEvent,
-        history: Sequence[ChatMessage] = (),
+        context: ToolExecutionContext | None = None,
     ) -> dict[str, Any]:
         definition = self._definitions.get(event.name)
         if definition is None:
@@ -69,9 +77,14 @@ class ToolRegistry:
                 "tool": event.name,
                 "error": "unknown tool",
             }
+        resolved_context = context or ToolExecutionContext(history=())
         resolved_event = event.model_copy(
             update={
-                "arguments": self._resolve_arguments(definition, event, history),
+                "arguments": self._resolve_arguments(
+                    definition,
+                    event,
+                    resolved_context,
+                ),
             }
         )
         return definition.handler(resolved_event)
@@ -80,11 +93,11 @@ class ToolRegistry:
         self,
         definition: ToolDefinition,
         event: ToolCallEvent,
-        history: Sequence[ChatMessage],
+        context: ToolExecutionContext,
     ) -> dict[str, Any]:
         if definition.argument_resolver is not None:
-            return definition.argument_resolver(event, history)
-        if history:
+            return definition.argument_resolver(event, context)
+        if context.history:
             return {}
         return deepcopy(event.arguments)
 
@@ -137,30 +150,30 @@ def build_default_tool_registry() -> ToolRegistry:
 
 def _resolve_handoff_note_arguments(
     event: ToolCallEvent,
-    history: Sequence[ChatMessage],
+    context: ToolExecutionContext,
 ) -> dict[str, Any]:
-    content = _latest_content(history, preferred_roles=("assistant", "user"))
-    if not content and not history:
+    content = _latest_content(context.history, preferred_roles=("assistant", "user"))
+    if not content and not context.history:
         content = str(event.arguments.get("message", ""))
     return {"message": content}
 
 
 def _resolve_mock_search_arguments(
     event: ToolCallEvent,
-    history: Sequence[ChatMessage],
+    context: ToolExecutionContext,
 ) -> dict[str, Any]:
-    query = _latest_content(history, preferred_roles=("assistant", "user"))
-    if not query and not history:
+    query = _latest_content(context.history, preferred_roles=("assistant", "user"))
+    if not query and not context.history:
         query = str(event.arguments.get("query", ""))
     return {"query": query}
 
 
 def _resolve_mock_ticket_arguments(
     event: ToolCallEvent,
-    history: Sequence[ChatMessage],
+    context: ToolExecutionContext,
 ) -> dict[str, Any]:
-    title = _latest_content(history, preferred_roles=("assistant", "user"))
-    if not title and not history:
+    title = _latest_content(context.history, preferred_roles=("assistant", "user"))
+    if not title and not context.history:
         title = str(event.arguments.get("title", ""))
     return {"title": title}
 

+ 14 - 4
tests/test_debug_runtime.py

@@ -10,7 +10,7 @@ import pytest
 from agent_lab.application.contracts import AgentParams, DebugRunRequest, EventAgentParams
 from agent_lab.application.event_agent import EventAgentRequest
 from agent_lab.application.runtime import DebugRuntime
-from agent_lab.application.tools import ToolDefinition, ToolRegistry
+from agent_lab.application.tools import ToolDefinition, ToolExecutionContext, ToolRegistry
 from agent_lab.domain.events import ToolCallEvent
 from agent_lab.domain.messages import ChatMessage, StreamItem, TokenUsage
 
@@ -401,9 +401,9 @@ async def test_runtime_outputs_event_as_soon_as_chat_stream_detects_it():
 async def test_runtime_batches_round_events_before_continuing_chat_agent():
     def resolve_from_history(
         event: ToolCallEvent,
-        history: list[ChatMessage],
+        context: ToolExecutionContext,
     ) -> dict[str, Any]:
-        return {"message": history[-1].content, "event": event.name}
+        return {"message": context.history[-1].content, "event": event.name}
 
     registry = ToolRegistry(
         [
@@ -470,6 +470,7 @@ async def test_runtime_batches_round_events_before_continuing_chat_agent():
         "assistant",
         "tool",
         "tool",
+        "user",
     ]
     assistant_message = client.second_call_messages[1]
     assert assistant_message.content == "Checking events."
@@ -493,6 +494,11 @@ async def test_runtime_batches_round_events_before_continuing_chat_agent():
         {"tool": "handoff_note", "message": "Checking events."},
         {"tool": "audit_note", "message": "Checking events."},
     ]
+    assert client.second_call_messages[-1].content == (
+        "EventAgent results:\n"
+        '{"tool": "handoff_note", "message": "Checking events."}\n'
+        '{"tool": "audit_note", "message": "Checking events."}'
+    )
 
 
 @pytest.mark.asyncio
@@ -580,9 +586,11 @@ async def test_runtime_buffers_upstream_user_input_until_after_matching_tool_rep
         "assistant",
         "tool",
         "user",
+        "user",
     ]
     assert client.second_call_messages[2].tool_call_id == "call_1"
-    assert client.second_call_messages[3].content == "follow-up while tool runs"
+    assert client.second_call_messages[3].content.startswith("EventAgent results:\n")
+    assert client.second_call_messages[4].content == "follow-up while tool runs"
 
 
 @pytest.mark.asyncio
@@ -740,6 +748,7 @@ async def test_runtime_preserves_assistant_tool_calls_before_tool_reply():
         "user",
         "assistant",
         "tool",
+        "user",
     ]
     assistant_message = client.second_call_messages[2]
     tool_message = client.second_call_messages[3]
@@ -755,6 +764,7 @@ async def test_runtime_preserves_assistant_tool_calls_before_tool_reply():
         }
     ]
     assert tool_message.tool_call_id == "call_1"
+    assert client.second_call_messages[4].content.startswith("EventAgent results:\n")
     assert outputs[-1] == {"type": "done"}
 
 

+ 55 - 1
tests/test_event_agent.py

@@ -3,7 +3,7 @@ import json
 import pytest
 
 from agent_lab.application.event_agent import EventAgent
-from agent_lab.application.tools import ToolDefinition, ToolRegistry
+from agent_lab.application.tools import ToolDefinition, ToolExecutionContext, ToolRegistry
 from agent_lab.domain.events import ToolCallEvent
 from agent_lab.domain.messages import ChatMessage
 
@@ -112,3 +112,57 @@ async def test_event_agent_returns_structured_error_when_tool_handler_raises():
         "tool": "handoff_note",
         "error": "tool handler failed: boom",
     }
+
+
+@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"],
+        }
+
+    registry = ToolRegistry(
+        [
+            ToolDefinition(
+                name="handoff_note",
+                description="Send a note to the event agent.",
+                parameters={"type": "object"},
+                handler=lambda event: {
+                    "tool": event.name,
+                    "message": event.arguments["message"],
+                    "thinking": event.arguments["thinking"],
+                },
+                argument_resolver=resolve_arguments,
+            )
+        ]
+    )
+    event = ToolCallEvent(
+        id="call_1",
+        name="handoff_note",
+        arguments={},
+        raw_arguments="{}",
+    )
+
+    reply = await EventAgent(
+        enabled_tools=["handoff_note"],
+        registry=registry,
+    ).handle(
+        event,
+        history=[ChatMessage(role="user", content="debug this")],
+        system_prompt="Use strict tool parameters.",
+        extra_body={"thinking": {"type": "disabled"}},
+    )
+
+    assert captured_contexts[0].history[0].content == "debug this"
+    assert json.loads(reply.content) == {
+        "tool": "handoff_note",
+        "message": "Use strict tool parameters.",
+        "thinking": "disabled",
+    }

+ 2 - 0
tests/test_websocket_api.py

@@ -267,8 +267,10 @@ async def test_runtime_appends_assistant_message_before_tool_reply_history():
         "user",
         "assistant",
         "tool",
+        "user",
     ]
     assert client.second_call_messages[2].content == "Need event help."
+    assert client.second_call_messages[4].content.startswith("EventAgent results:\n")
     assert outputs[-1] == {"type": "done"}