فهرست منبع

feat: add policy-driven event batches

Problem: EventAgent and direct Runtime executed event batches independently with unbounded gather behavior and unconditional LLM continuation, so timeouts, conflicts, terminal ordering, replay, and plugin result policies were inconsistent.

Risk: Batch scheduling and continuation now affect both invocation modes; focused and full tests cover bounded execution, timeout/failure isolation, stable transcripts, policy aggregation, and reusable-session termination.
zhenyu.hu 2 هفته پیش
والد
کامیت
c93c7baa42

+ 2 - 0
src/agent_lab/application/contracts.py

@@ -30,6 +30,8 @@ class AgentParams(BaseModel):
 class EventAgentParams(AgentParams):
 class EventAgentParams(AgentParams):
     enabled_tools: list[str] = Field(default_factory=lambda: ["handoff_note"])
     enabled_tools: list[str] = Field(default_factory=lambda: ["handoff_note"])
     max_event_loops: int = 1
     max_event_loops: int = 1
+    max_parallel_events: int = Field(default=4, ge=1, le=16)
+    batch_timeout_seconds: float = Field(default=15.0, gt=0, le=120)
 
 
 
 
 class DebugRunRequest(BaseModel):
 class DebugRunRequest(BaseModel):

+ 35 - 46
src/agent_lab/application/event_agent.py

@@ -1,11 +1,13 @@
 import asyncio
 import asyncio
 import json
 import json
-from collections.abc import AsyncIterator, Iterable, Sequence
+from collections.abc import AsyncIterator, Hashable, Iterable, Sequence
 from dataclasses import dataclass
 from dataclasses import dataclass
 from typing import Any, Protocol
 from typing import Any, Protocol
 
 
-from agent_lab.application.contracts import AgentParams
+from agent_lab.application.contracts import AgentParams, EventAgentParams
 from agent_lab.application.events import (
 from agent_lab.application.events import (
+    EventBatchExecutor,
+    EventBatchResult,
     EventDefinition,
     EventDefinition,
     EventExecutionContext,
     EventExecutionContext,
     EventKernel,
     EventKernel,
@@ -42,6 +44,8 @@ class EventAgentRequest:
     turn_index: int | None = None
     turn_index: int | None = None
     round_index: int | None = None
     round_index: int | None = None
     turn_started_at: float | None = None
     turn_started_at: float | None = None
+    scope: Hashable | None = None
+    response: asyncio.Future[EventBatchResult] | None = None
 
 
 
 
 class EventAgent:
 class EventAgent:
@@ -55,13 +59,22 @@ class EventAgent:
         self.enabled_tools = set(enabled_tools)
         self.enabled_tools = set(enabled_tools)
         self.registry = registry or build_default_tool_registry()
         self.registry = registry or build_default_tool_registry()
         self.chat_client = chat_client
         self.chat_client = chat_client
-        self.params = params or AgentParams()
+        self.params = params or EventAgentParams()
         self.kernel = EventKernel(
         self.kernel = EventKernel(
             self.registry.event_registry,
             self.registry.event_registry,
             argument_fallback=(
             argument_fallback=(
                 self._resolve_arguments_with_llm if chat_client is not None else None
                 self._resolve_arguments_with_llm if chat_client is not None else None
             ),
             ),
         )
         )
+        self.executor = EventBatchExecutor(
+            self.kernel,
+            max_parallel_events=getattr(self.params, "max_parallel_events", 4),
+            batch_timeout_seconds=getattr(
+                self.params,
+                "batch_timeout_seconds",
+                15.0,
+            ),
+        )
         self._raw_chunks_by_event: dict[str, list[dict[str, Any]]] = {}
         self._raw_chunks_by_event: dict[str, list[dict[str, Any]]] = {}
 
 
     async def handle(
     async def handle(
@@ -72,19 +85,13 @@ class EventAgent:
         extra_body: dict[str, Any] | None = None,
         extra_body: dict[str, Any] | None = None,
     ) -> ChatMessage:
     ) -> ChatMessage:
         self._raw_chunks_by_event[event.id] = []
         self._raw_chunks_by_event[event.id] = []
-        result = await self.kernel.execute(
-            self.registry.event_request(event),
-            enabled_names=self.enabled_tools,
-            context=ToolExecutionContext(
-                history=history,
-                system_prompt=system_prompt,
-                extra_body=(
-                    self.params.extra_body if extra_body is None else extra_body
-                ),
-            ),
+        batch = await self.handle_many(
+            [event],
+            history=history,
+            system_prompt=system_prompt,
+            extra_body=extra_body,
         )
         )
-        payload = self.registry.tool_payload(result)
-        return self._tool_reply(event, payload)
+        return self.registry.tool_reply(event, batch.results[0])
 
 
     async def handle_many(
     async def handle_many(
         self,
         self,
@@ -92,19 +99,21 @@ class EventAgent:
         history: Sequence[ChatMessage],
         history: Sequence[ChatMessage],
         system_prompt: str = "",
         system_prompt: str = "",
         extra_body: dict[str, Any] | None = None,
         extra_body: dict[str, Any] | None = None,
-    ) -> list[ChatMessage]:
+        scope: Hashable | None = None,
+    ) -> EventBatchResult:
         for event in events:
         for event in events:
             self._raw_chunks_by_event[event.id] = []
             self._raw_chunks_by_event[event.id] = []
-        return await asyncio.gather(
-            *[
-                self.handle(
-                    event,
-                    history=history,
-                    system_prompt=system_prompt,
-                    extra_body=extra_body,
-                )
-                for event in events
-            ]
+        return await self.executor.execute(
+            [self.registry.event_request(event) for event in events],
+            enabled_names=self.enabled_tools,
+            context=ToolExecutionContext(
+                history=history,
+                system_prompt=system_prompt,
+                extra_body=(
+                    self.params.extra_body if extra_body is None else extra_body
+                ),
+            ),
+            scope=scope if scope is not None else object(),
         )
         )
 
 
     def raw_model_chunks(
     def raw_model_chunks(
@@ -218,23 +227,3 @@ class EventAgent:
                 continue
                 continue
             projected.append(message)
             projected.append(message)
         return projected
         return projected
-
-    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)
-            ),
-            name="event_agent",
-        )
-
-    def _tool_reply(self, event: ToolCallEvent, payload: dict[str, Any]) -> ChatMessage:
-        return ChatMessage(
-            role="tool",
-            content=json.dumps(payload, ensure_ascii=False),
-            name=event.name,
-            tool_call_id=event.id,
-        )

+ 8 - 0
src/agent_lab/application/events/__init__.py

@@ -1,3 +1,4 @@
+from agent_lab.application.events.batch import EventBatchExecutor
 from agent_lab.application.events.kernel import ArgumentFallback, EventKernel
 from agent_lab.application.events.kernel import ArgumentFallback, EventKernel
 from agent_lab.application.events.builtin_plugins import (
 from agent_lab.application.events.builtin_plugins import (
     CalendarSchedulePort,
     CalendarSchedulePort,
@@ -15,11 +16,14 @@ from agent_lab.application.events.models import (
     EventArgumentNormalizer,
     EventArgumentNormalizer,
     EventArgumentResolution,
     EventArgumentResolution,
     EventArgumentResolver,
     EventArgumentResolver,
+    EventBatchDecision,
+    EventBatchResult,
     EventDefinition,
     EventDefinition,
     EventExecutionContext,
     EventExecutionContext,
     EventHandler,
     EventHandler,
     EventRequest,
     EventRequest,
     EventResult,
     EventResult,
+    EventResultMessageFactory,
     EventSource,
     EventSource,
     EventStatus,
     EventStatus,
     ResolvedEventArguments,
     ResolvedEventArguments,
@@ -35,6 +39,9 @@ __all__ = [
     "EventArgumentNormalizer",
     "EventArgumentNormalizer",
     "EventArgumentResolution",
     "EventArgumentResolution",
     "EventArgumentResolver",
     "EventArgumentResolver",
+    "EventBatchExecutor",
+    "EventBatchDecision",
+    "EventBatchResult",
     "EventDefinition",
     "EventDefinition",
     "EventExecutionContext",
     "EventExecutionContext",
     "EventHandler",
     "EventHandler",
@@ -42,6 +49,7 @@ __all__ = [
     "EventRegistry",
     "EventRegistry",
     "EventRequest",
     "EventRequest",
     "EventResult",
     "EventResult",
+    "EventResultMessageFactory",
     "EventSource",
     "EventSource",
     "EventStatus",
     "EventStatus",
     "DeviceVolumePort",
     "DeviceVolumePort",

+ 253 - 0
src/agent_lab/application/events/batch.py

@@ -0,0 +1,253 @@
+from __future__ import annotations
+
+import asyncio
+import json
+from collections.abc import Hashable, Iterable, Sequence
+from dataclasses import dataclass
+from typing import Any
+
+from agent_lab.application.events.kernel import EventKernel
+from agent_lab.application.events.models import (
+    EventBatchResult,
+    EventDefinition,
+    EventExecutionContext,
+    EventRequest,
+    EventResult,
+    EventStatus,
+)
+
+
+@dataclass(frozen=True)
+class _BatchItem:
+    key: str
+    request: EventRequest
+
+
+@dataclass(frozen=True)
+class _ExecutionOutcome:
+    result: EventResult
+    batch_timed_out: bool = False
+
+
+class EventBatchExecutor:
+    def __init__(
+        self,
+        kernel: EventKernel,
+        *,
+        max_parallel_events: int = 4,
+        batch_timeout_seconds: float = 15.0,
+    ) -> None:
+        if max_parallel_events < 1:
+            raise ValueError("max_parallel_events must be positive")
+        if batch_timeout_seconds <= 0:
+            raise ValueError("batch_timeout_seconds must be positive")
+        self.kernel = kernel
+        self.max_parallel_events = max_parallel_events
+        self.batch_timeout_seconds = batch_timeout_seconds
+        self._semaphore = asyncio.Semaphore(max_parallel_events)
+        self._conflict_locks: dict[str, asyncio.Lock] = {}
+        self._replay_results: dict[tuple[Hashable, str, str], EventResult] = {}
+
+    async def execute(
+        self,
+        requests: Sequence[EventRequest],
+        *,
+        enabled_names: Iterable[str] | None = None,
+        context: EventExecutionContext | None = None,
+        scope: Hashable,
+    ) -> EventBatchResult:
+        if not requests:
+            return EventBatchResult()
+
+        items: list[_BatchItem] = []
+        indexes_by_key: dict[str, list[int]] = {}
+        coalesced_count = 0
+        for index, request in enumerate(requests):
+            key = self._request_key(request)
+            indexes = indexes_by_key.get(key)
+            if indexes is not None:
+                indexes.append(index)
+                coalesced_count += 1
+                continue
+            indexes_by_key[key] = [index]
+            items.append(_BatchItem(key=key, request=request))
+
+        results_by_key: dict[str, EventResult] = {}
+        replayed_count = 0
+        pending: list[_BatchItem] = []
+        for item in items:
+            replay_key = (scope, item.request.id, item.key)
+            replayed = self._replay_results.get(replay_key)
+            if replayed is None:
+                pending.append(item)
+                continue
+            results_by_key[item.key] = replayed
+            replayed_count += 1
+
+        nonterminal: list[_BatchItem] = []
+        terminal: list[_BatchItem] = []
+        for item in pending:
+            definition = self.kernel.registry.definition(item.request.name)
+            if definition is not None and definition.terminal:
+                terminal.append(item)
+            else:
+                nonterminal.append(item)
+
+        deadline = asyncio.get_running_loop().time() + self.batch_timeout_seconds
+        deadline_exceeded = False
+        for phase in (nonterminal, terminal):
+            outcomes = await asyncio.gather(
+                *[
+                    self._execute_one(
+                        item.request,
+                        enabled_names=enabled_names,
+                        context=context,
+                        deadline=deadline,
+                    )
+                    for item in phase
+                ]
+            )
+            for item, outcome in zip(phase, outcomes, strict=True):
+                results_by_key[item.key] = outcome.result
+                self._replay_results[(scope, item.request.id, item.key)] = (
+                    outcome.result
+                )
+                deadline_exceeded = deadline_exceeded or outcome.batch_timed_out
+
+        ordered: list[EventResult | None] = [None] * len(requests)
+        for key, indexes in indexes_by_key.items():
+            result = results_by_key[key]
+            for index in indexes:
+                ordered[index] = result
+
+        return EventBatchResult(
+            results=tuple(result for result in ordered if result is not None),
+            coalesced_count=coalesced_count,
+            replayed_count=replayed_count,
+            deadline_exceeded=deadline_exceeded,
+        )
+
+    async def _execute_one(
+        self,
+        request: EventRequest,
+        *,
+        enabled_names: Iterable[str] | None,
+        context: EventExecutionContext | None,
+        deadline: float,
+    ) -> _ExecutionOutcome:
+        definition = self.kernel.registry.definition(request.name)
+        remaining = deadline - asyncio.get_running_loop().time()
+        event_timeout = definition.timeout_seconds if definition is not None else None
+        if remaining <= 0:
+            return _ExecutionOutcome(
+                self._failure_result(
+                    request,
+                    definition,
+                    EventStatus.TIMEOUT,
+                    "batch deadline exceeded",
+                ),
+                batch_timed_out=True,
+            )
+        batch_limited = event_timeout is None or remaining <= event_timeout
+        timeout = remaining if event_timeout is None else min(remaining, event_timeout)
+        acquired_locks: list[asyncio.Lock] = []
+        semaphore_acquired = False
+        try:
+            async with asyncio.timeout(timeout):
+                for key in sorted(set(definition.conflict_keys if definition else ())):
+                    lock = self._conflict_locks.setdefault(key, asyncio.Lock())
+                    await lock.acquire()
+                    acquired_locks.append(lock)
+                await self._semaphore.acquire()
+                semaphore_acquired = True
+                result = await self.kernel.execute(
+                    request,
+                    enabled_names=enabled_names,
+                    context=context,
+                )
+                return _ExecutionOutcome(result)
+        except TimeoutError:
+            error = (
+                "batch deadline exceeded"
+                if batch_limited
+                else f"event timed out after {event_timeout:g} seconds"
+            )
+            return _ExecutionOutcome(
+                self._failure_result(
+                    request,
+                    definition,
+                    EventStatus.TIMEOUT,
+                    error,
+                ),
+                batch_timed_out=batch_limited,
+            )
+        except Exception as exc:
+            return _ExecutionOutcome(
+                self._failure_result(
+                    request,
+                    definition,
+                    EventStatus.HANDLER_ERROR,
+                    f"event execution failed: {exc}",
+                )
+            )
+        finally:
+            if semaphore_acquired:
+                self._semaphore.release()
+            for lock in reversed(acquired_locks):
+                lock.release()
+
+    def _failure_result(
+        self,
+        request: EventRequest,
+        definition: EventDefinition | None,
+        status: EventStatus,
+        error: str,
+    ) -> EventResult:
+        metadata: dict[str, Any] = {}
+        if definition is not None:
+            metadata = {
+                "result_policy": definition.result_policy,
+                "confirmation_policy": definition.confirmation_policy,
+                "risk_level": definition.risk_level,
+                "idempotency_key_fields": definition.idempotency_key_fields,
+                "concurrency_class": definition.concurrency_class,
+                "conflict_keys": definition.conflict_keys,
+                "timeout_seconds": definition.timeout_seconds,
+                "terminal": definition.terminal,
+            }
+        return EventResult(
+            event_id=request.id,
+            event_name=request.name,
+            status=status,
+            source=request.source,
+            arguments=self._safe_arguments(request.arguments),
+            raw_arguments=request.raw_arguments,
+            error=error,
+            **metadata,
+        )
+
+    def _request_key(self, request: EventRequest) -> str:
+        try:
+            arguments = json.dumps(
+                request.arguments,
+                ensure_ascii=False,
+                sort_keys=True,
+                separators=(",", ":"),
+                allow_nan=False,
+            )
+        except (TypeError, ValueError):
+            arguments = request.raw_arguments
+        return json.dumps(
+            [request.id, request.name, request.source.value, arguments],
+            ensure_ascii=False,
+            separators=(",", ":"),
+        )
+
+    def _safe_arguments(self, arguments: dict[str, Any]) -> dict[str, Any]:
+        try:
+            value = json.loads(
+                json.dumps(arguments, ensure_ascii=False, allow_nan=False)
+            )
+        except (TypeError, ValueError):
+            return {}
+        return value if isinstance(value, dict) else {}

+ 9 - 0
src/agent_lab/application/events/builtin_plugins.py

@@ -12,6 +12,7 @@ from agent_lab.application.events.models import (
     EventDefinition,
     EventDefinition,
     EventExecutionContext,
     EventExecutionContext,
     EventRequest,
     EventRequest,
+    EventResult,
     ResultPolicy,
     ResultPolicy,
     RiskLevel,
     RiskLevel,
 )
 )
@@ -355,6 +356,7 @@ def _calendar_schedule_definition(port: CalendarSchedulePort) -> EventDefinition
         concurrency_class="schedule-write",
         concurrency_class="schedule-write",
         conflict_keys=("calendar.schedule",),
         conflict_keys=("calendar.schedule",),
         timeout_seconds=10.0,
         timeout_seconds=10.0,
+        result_message_factory=_calendar_schedule_message,
     )
     )
 
 
 
 
@@ -401,6 +403,13 @@ def _resolve_session_terminate(
     return deepcopy(request.arguments)
     return deepcopy(request.arguments)
 
 
 
 
+def _calendar_schedule_message(result: EventResult) -> str:
+    return (
+        f"Scheduled {result.arguments['title']} for "
+        f"{result.arguments['start_at']} ({result.arguments['timezone']})."
+    )
+
+
 def _resolve_device_volume(
 def _resolve_device_volume(
     request: EventRequest,
     request: EventRequest,
     context: EventExecutionContext,
     context: EventExecutionContext,

+ 18 - 0
src/agent_lab/application/events/models.py

@@ -15,6 +15,7 @@ class EventSource(StrEnum):
 
 
 class EventStatus(StrEnum):
 class EventStatus(StrEnum):
     SUCCESS = "success"
     SUCCESS = "success"
+    TIMEOUT = "timeout"
     UNKNOWN = "unknown"
     UNKNOWN = "unknown"
     DISABLED = "disabled"
     DISABLED = "disabled"
     INVALID_ARGUMENTS = "invalid_arguments"
     INVALID_ARGUMENTS = "invalid_arguments"
@@ -77,6 +78,7 @@ EventArgumentResolver = Callable[
     dict[str, Any] | EventArgumentResolution,
     dict[str, Any] | EventArgumentResolution,
 ]
 ]
 EventArgumentNormalizer = Callable[[dict[str, Any]], dict[str, Any]]
 EventArgumentNormalizer = Callable[[dict[str, Any]], dict[str, Any]]
+EventResultMessageFactory = Callable[["EventResult"], str]
 
 
 
 
 @dataclass(frozen=True)
 @dataclass(frozen=True)
@@ -97,6 +99,7 @@ class EventDefinition:
     timeout_seconds: float | None = None
     timeout_seconds: float | None = None
     terminal: bool = False
     terminal: bool = False
     normalizer: EventArgumentNormalizer | None = None
     normalizer: EventArgumentNormalizer | None = None
+    result_message_factory: EventResultMessageFactory | None = None
 
 
 
 
 @dataclass(frozen=True)
 @dataclass(frozen=True)
@@ -118,3 +121,18 @@ class EventResult:
     conflict_keys: tuple[str, ...] = ()
     conflict_keys: tuple[str, ...] = ()
     timeout_seconds: float | None = None
     timeout_seconds: float | None = None
     terminal: bool = False
     terminal: bool = False
+
+
+@dataclass(frozen=True)
+class EventBatchResult:
+    results: tuple[EventResult, ...] = ()
+    coalesced_count: int = 0
+    replayed_count: int = 0
+    deadline_exceeded: bool = False
+
+
+@dataclass(frozen=True)
+class EventBatchDecision:
+    template_messages: tuple[str, ...] = ()
+    llm_follow_up: bool = False
+    terminate: bool = False

+ 271 - 48
src/agent_lab/application/runtime.py

@@ -1,12 +1,22 @@
 import asyncio
 import asyncio
-import json
 import logging
 import logging
 import time
 import time
 from collections.abc import AsyncIterator, Callable
 from collections.abc import AsyncIterator, Callable
 from typing import Any, Protocol
 from typing import Any, Protocol
 
 
-from agent_lab.application.contracts import AgentParams, DebugRunRequest
+from agent_lab.application.contracts import (
+    AgentParams,
+    DebugRunRequest,
+    EventAgentParams,
+)
 from agent_lab.application.event_agent import EventAgent, EventAgentRequest
 from agent_lab.application.event_agent import EventAgent, EventAgentRequest
+from agent_lab.application.events import (
+    EventBatchDecision,
+    EventBatchExecutor,
+    EventBatchResult,
+    EventKernel,
+    EventSource,
+)
 from agent_lab.application.queues import RuntimeQueues
 from agent_lab.application.queues import RuntimeQueues
 from agent_lab.application.session_store import SessionStore
 from agent_lab.application.session_store import SessionStore
 from agent_lab.application.tools import ToolRegistry, build_default_tool_registry
 from agent_lab.application.tools import ToolRegistry, build_default_tool_registry
@@ -43,6 +53,7 @@ class DebugRuntime:
         self.session_store = session_store
         self.session_store = session_store
         self.clock = clock
         self.clock = clock
         self._tasks: list[asyncio.Task[None]] = []
         self._tasks: list[asyncio.Task[None]] = []
+        self._batch_executors: dict[tuple[int, float], EventBatchExecutor] = {}
 
 
     def start(self, request: DebugRunRequest) -> RuntimeQueues:
     def start(self, request: DebugRunRequest) -> RuntimeQueues:
         queues = self.queues or RuntimeQueues()
         queues = self.queues or RuntimeQueues()
@@ -161,6 +172,8 @@ class DebugRuntime:
             assistant_content: list[str] = []
             assistant_content: list[str] = []
             events: list[ToolCallEvent] = []
             events: list[ToolCallEvent] = []
             tool_replies: list[ChatMessage] = []
             tool_replies: list[ChatMessage] = []
+            batch_result: EventBatchResult | None = None
+            decision: EventBatchDecision | None = None
             message_stream_started = False
             message_stream_started = False
             message_delta_count = 0
             message_delta_count = 0
             configured_events = request.event_agent.enabled_tools
             configured_events = request.event_agent.enabled_tools
@@ -309,13 +322,27 @@ class DebugRuntime:
                 messages.append(assistant_message)
                 messages.append(assistant_message)
 
 
             if events:
             if events:
+                batch_scope = self._event_scope(request, queues, turn_index=1)
+                await self._audit(
+                    queues,
+                    "event_batch_started",
+                    turn_started_at=turn_started_at,
+                    round_index=round_index,
+                    scope=batch_scope,
+                    event_names=[event.name for event in events],
+                    max_parallel_events=request.event_agent.max_parallel_events,
+                    batch_timeout_seconds=request.event_agent.batch_timeout_seconds,
+                )
                 if request.tool_invocation_mode == "chat_agent_tools":
                 if request.tool_invocation_mode == "chat_agent_tools":
-                    tool_replies = await self._execute_provider_tools(
+                    batch_result, tool_replies = await self._execute_provider_tools(
                         events,
                         events,
                         enabled_names=request.event_agent.enabled_tools,
                         enabled_names=request.event_agent.enabled_tools,
+                        params=request.event_agent,
+                        scope=batch_scope,
                     )
                     )
                     messages.extend(tool_replies)
                     messages.extend(tool_replies)
                 else:
                 else:
+                    response = asyncio.get_running_loop().create_future()
                     await queues.events.put(
                     await queues.events.put(
                         EventAgentRequest(
                         EventAgentRequest(
                             events=events,
                             events=events,
@@ -323,8 +350,11 @@ class DebugRuntime:
                             system_prompt=request.event_agent.system_prompt,
                             system_prompt=request.event_agent.system_prompt,
                             extra_body=request.event_agent.extra_body,
                             extra_body=request.event_agent.extra_body,
                             turn_started_at=turn_started_at,
                             turn_started_at=turn_started_at,
+                            scope=batch_scope,
+                            response=response,
                         )
                         )
                     )
                     )
+                    batch_result = await response
                     tool_replies = await self._wait_for_tool_replies(
                     tool_replies = await self._wait_for_tool_replies(
                         queues,
                         queues,
                         [event.id for event in events],
                         [event.id for event in events],
@@ -352,10 +382,28 @@ class DebugRuntime:
                         round_index=round_index,
                         round_index=round_index,
                         event_names=[event.name for event in events],
                         event_names=[event.name for event in events],
                         result_count=len(tool_replies),
                         result_count=len(tool_replies),
-                        result_summary="\n".join(
-                            reply.content for reply in tool_replies
-                        ),
                     )
                     )
+                assert batch_result is not None
+                await self._audit_batch_result(
+                    queues,
+                    batch_result,
+                    turn_started_at=turn_started_at,
+                    round_index=round_index,
+                )
+                decision = await self._apply_batch_policy(
+                    queues,
+                    messages,
+                    batch_result,
+                    turn_started_at=turn_started_at,
+                    round_index=round_index,
+                )
+                if (
+                    decision.llm_follow_up
+                    and request.tool_invocation_mode == "dual_agent"
+                ):
+                    summary = self.registry.compact_results_message(batch_result)
+                    if summary is not None:
+                        messages.append(summary)
 
 
             elapsed_ms = self._elapsed_ms(started_at)
             elapsed_ms = self._elapsed_ms(started_at)
             await queues.output.put(
             await queues.output.put(
@@ -385,6 +433,9 @@ class DebugRuntime:
                 break
                 break
 
 
             event_loops += 1
             event_loops += 1
+            assert decision is not None
+            if decision.terminate or not decision.llm_follow_up:
+                break
 
 
         await self._audit(
         await self._audit(
             queues,
             queues,
@@ -425,7 +476,7 @@ class DebugRuntime:
             turn_index += 1
             turn_index += 1
             turn_started_at = next_turn_started_at or self.clock()
             turn_started_at = next_turn_started_at or self.clock()
             next_turn_started_at = None
             next_turn_started_at = None
-            await self._run_chat_turn(
+            terminated = await self._run_chat_turn(
                 request,
                 request,
                 queues,
                 queues,
                 messages,
                 messages,
@@ -434,6 +485,17 @@ class DebugRuntime:
                 session_id,
                 session_id,
                 turn_started_at,
                 turn_started_at,
             )
             )
+            if terminated:
+                await self._audit(
+                    queues,
+                    "session_finished",
+                    turn_started_at=turn_started_at,
+                    session_id=session_id,
+                    turn_index=turn_index,
+                    terminated=True,
+                )
+                await queues.output.put({"type": "done"})
+                return
 
 
     async def _run_chat_turn(
     async def _run_chat_turn(
         self,
         self,
@@ -444,7 +506,7 @@ class DebugRuntime:
         turn_index: int,
         turn_index: int,
         session_id: str | None = None,
         session_id: str | None = None,
         turn_started_at: float | None = None,
         turn_started_at: float | None = None,
-    ) -> None:
+    ) -> bool:
         turn_started_at = turn_started_at or self.clock()
         turn_started_at = turn_started_at or self.clock()
         messages.append(user_message)
         messages.append(user_message)
         self._start_persisted_turn(session_id, turn_index, user_message)
         self._start_persisted_turn(session_id, turn_index, user_message)
@@ -460,6 +522,7 @@ class DebugRuntime:
         event_loops = 0
         event_loops = 0
         round_index = 0
         round_index = 0
         deferred_user_messages: list[ChatMessage] = []
         deferred_user_messages: list[ChatMessage] = []
+        terminate_session = False
         while True:
         while True:
             if round_index:
             if round_index:
                 await self._drain_input(
                 await self._drain_input(
@@ -477,6 +540,8 @@ class DebugRuntime:
             saw_event = False
             saw_event = False
             assistant_content: list[str] = []
             assistant_content: list[str] = []
             events: list[ToolCallEvent] = []
             events: list[ToolCallEvent] = []
+            batch_result: EventBatchResult | None = None
+            decision: EventBatchDecision | None = None
             message_stream_started = False
             message_stream_started = False
             message_delta_count = 0
             message_delta_count = 0
             configured_events = request.event_agent.enabled_tools
             configured_events = request.event_agent.enabled_tools
@@ -642,13 +707,33 @@ class DebugRuntime:
                 )
                 )
 
 
             if events:
             if events:
+                batch_scope = self._event_scope(
+                    request,
+                    queues,
+                    turn_index=turn_index,
+                )
+                await self._audit(
+                    queues,
+                    "event_batch_started",
+                    turn_started_at=turn_started_at,
+                    session_id=session_id,
+                    turn_index=turn_index,
+                    round_index=round_index,
+                    scope=batch_scope,
+                    event_names=[event.name for event in events],
+                    max_parallel_events=request.event_agent.max_parallel_events,
+                    batch_timeout_seconds=request.event_agent.batch_timeout_seconds,
+                )
                 if request.tool_invocation_mode == "chat_agent_tools":
                 if request.tool_invocation_mode == "chat_agent_tools":
-                    tool_replies = await self._execute_provider_tools(
+                    batch_result, tool_replies = await self._execute_provider_tools(
                         events,
                         events,
                         enabled_names=request.event_agent.enabled_tools,
                         enabled_names=request.event_agent.enabled_tools,
+                        params=request.event_agent,
+                        scope=batch_scope,
                     )
                     )
                     messages.extend(tool_replies)
                     messages.extend(tool_replies)
                 else:
                 else:
+                    response = asyncio.get_running_loop().create_future()
                     await queues.events.put(
                     await queues.events.put(
                         EventAgentRequest(
                         EventAgentRequest(
                             events=events,
                             events=events,
@@ -659,8 +744,11 @@ class DebugRuntime:
                             turn_index=turn_index,
                             turn_index=turn_index,
                             round_index=round_index,
                             round_index=round_index,
                             turn_started_at=turn_started_at,
                             turn_started_at=turn_started_at,
+                            scope=batch_scope,
+                            response=response,
                         )
                         )
                     )
                     )
+                    batch_result = await response
                     tool_replies = await self._wait_for_tool_replies(
                     tool_replies = await self._wait_for_tool_replies(
                         queues,
                         queues,
                         [event.id for event in events],
                         [event.id for event in events],
@@ -692,10 +780,32 @@ class DebugRuntime:
                         round_index=round_index,
                         round_index=round_index,
                         event_names=[event.name for event in events],
                         event_names=[event.name for event in events],
                         result_count=len(tool_replies),
                         result_count=len(tool_replies),
-                        result_summary="\n".join(
-                            reply.content for reply in tool_replies
-                        ),
                     )
                     )
+                assert batch_result is not None
+                await self._audit_batch_result(
+                    queues,
+                    batch_result,
+                    turn_started_at=turn_started_at,
+                    session_id=session_id,
+                    turn_index=turn_index,
+                    round_index=round_index,
+                )
+                decision = await self._apply_batch_policy(
+                    queues,
+                    messages,
+                    batch_result,
+                    turn_started_at=turn_started_at,
+                    session_id=session_id,
+                    turn_index=turn_index,
+                    round_index=round_index,
+                )
+                if (
+                    decision.llm_follow_up
+                    and request.tool_invocation_mode == "dual_agent"
+                ):
+                    summary = self.registry.compact_results_message(batch_result)
+                    if summary is not None:
+                        messages.append(summary)
 
 
             elapsed_ms = self._elapsed_ms(started_at)
             elapsed_ms = self._elapsed_ms(started_at)
             self._append_persisted_usage(
             self._append_persisted_usage(
@@ -740,6 +850,12 @@ class DebugRuntime:
                 break
                 break
 
 
             event_loops += 1
             event_loops += 1
+            assert decision is not None
+            if decision.terminate:
+                terminate_session = True
+                break
+            if not decision.llm_follow_up:
+                break
 
 
         for deferred_message in deferred_user_messages:
         for deferred_message in deferred_user_messages:
             await queues.input.put(deferred_message)
             await queues.input.put(deferred_message)
@@ -759,6 +875,7 @@ class DebugRuntime:
                 "round_count": round_index,
                 "round_count": round_index,
             }
             }
         )
         )
+        return terminate_session
 
 
     async def _consume_events(
     async def _consume_events(
         self,
         self,
@@ -791,28 +908,35 @@ class DebugRuntime:
                     if tool is not None
                     if tool is not None
                 ],
                 ],
             )
             )
-            replies = await event_agent.handle_many(
-                request.events,
-                history=request.history,
-                system_prompt=request.system_prompt,
-                extra_body=request.extra_body,
-            )
-            await self._audit(
-                queues,
-                "event_agent_response",
-                turn_started_at=request.turn_started_at,
-                session_id=request.session_id,
-                agent="event_agent",
-                turn_index=request.turn_index,
-                round_index=request.round_index,
-                replies=[reply.model_dump() for reply in replies],
-                raw_model_chunks=event_agent.raw_model_chunks(request.events),
-            )
-            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)
+            try:
+                batch = await event_agent.handle_many(
+                    request.events,
+                    history=request.history,
+                    system_prompt=request.system_prompt,
+                    extra_body=request.extra_body,
+                    scope=request.scope,
+                )
+                replies = event_agent.registry.tool_replies(request.events, batch)
+                await self._audit(
+                    queues,
+                    "event_agent_response",
+                    turn_started_at=request.turn_started_at,
+                    session_id=request.session_id,
+                    agent="event_agent",
+                    turn_index=request.turn_index,
+                    round_index=request.round_index,
+                    replies=[reply.model_dump() for reply in replies],
+                    raw_model_chunks=event_agent.raw_model_chunks(request.events),
+                )
+                for reply in replies:
+                    await queues.input.put(reply)
+            except BaseException as exc:
+                if request.response is not None and not request.response.done():
+                    request.response.set_exception(exc)
+                raise
+            else:
+                if request.response is not None and not request.response.done():
+                    request.response.set_result(batch)
 
 
     async def _wait_for_tool_replies(
     async def _wait_for_tool_replies(
         self,
         self,
@@ -908,25 +1032,124 @@ class DebugRuntime:
         events: list[ToolCallEvent],
         events: list[ToolCallEvent],
         *,
         *,
         enabled_names: list[str],
         enabled_names: list[str],
-    ) -> list[ChatMessage]:
-        payloads = await asyncio.gather(
-            *[
-                self.registry.execute_async(
+        params: EventAgentParams,
+        scope: str,
+    ) -> tuple[EventBatchResult, list[ChatMessage]]:
+        executor = self._batch_executor(params)
+        batch = await executor.execute(
+            [
+                self.registry.event_request(
                     event,
                     event,
-                    enabled_names=enabled_names,
+                    source=EventSource.PROVIDER_RESOLVED,
                 )
                 )
                 for event in events
                 for event in events
-            ]
+            ],
+            enabled_names=enabled_names,
+            scope=scope,
         )
         )
-        return [
-            ChatMessage(
-                role="tool",
-                content=json.dumps(payload, ensure_ascii=False),
-                name=event.name,
-                tool_call_id=event.id,
+        return batch, self.registry.tool_replies(events, batch)
+
+    def _batch_executor(self, params: EventAgentParams) -> EventBatchExecutor:
+        key = (params.max_parallel_events, params.batch_timeout_seconds)
+        executor = self._batch_executors.get(key)
+        if executor is None:
+            executor = EventBatchExecutor(
+                EventKernel(self.registry.event_registry),
+                max_parallel_events=params.max_parallel_events,
+                batch_timeout_seconds=params.batch_timeout_seconds,
             )
             )
-            for event, payload in zip(events, payloads, strict=True)
-        ]
+            self._batch_executors[key] = executor
+        return executor
+
+    def _event_scope(
+        self,
+        request: DebugRunRequest,
+        queues: RuntimeQueues,
+        *,
+        turn_index: int,
+    ) -> str:
+        session_scope = request.session_id or f"runtime-{id(queues)}"
+        return f"{session_scope}:turn-{turn_index}"
+
+    async def _audit_batch_result(
+        self,
+        queues: RuntimeQueues,
+        batch: EventBatchResult,
+        *,
+        turn_started_at: float | None,
+        session_id: str | None = None,
+        turn_index: int | None = None,
+        round_index: int | None = None,
+    ) -> None:
+        await self._audit(
+            queues,
+            "event_batch_results",
+            turn_started_at=turn_started_at,
+            session_id=session_id,
+            turn_index=turn_index,
+            round_index=round_index,
+            results=[
+                {
+                    "event_id": result.event_id,
+                    "event_name": result.event_name,
+                    "status": result.status.value,
+                    "error": result.error,
+                    "result_policy": result.result_policy.value,
+                    "terminal": result.terminal,
+                }
+                for result in batch.results
+            ],
+            timeout_count=sum(
+                result.status.value == "timeout" for result in batch.results
+            ),
+            coalesced_count=batch.coalesced_count,
+            replayed_count=batch.replayed_count,
+            deadline_exceeded=batch.deadline_exceeded,
+        )
+
+    async def _apply_batch_policy(
+        self,
+        queues: RuntimeQueues,
+        messages: list[ChatMessage],
+        batch: EventBatchResult,
+        *,
+        turn_started_at: float | None,
+        session_id: str | None = None,
+        turn_index: int | None = None,
+        round_index: int | None = None,
+    ) -> EventBatchDecision:
+        decision = self.registry.batch_decision(batch)
+        for content in decision.template_messages:
+            message = ChatMessage(role="assistant", content=content)
+            messages.append(message)
+            self._append_persisted_message(session_id, turn_index or 0, message)
+            await queues.output.put({"type": "message_delta", "content": content})
+        await self._audit(
+            queues,
+            "event_policy_decision",
+            turn_started_at=turn_started_at,
+            session_id=session_id,
+            turn_index=turn_index,
+            round_index=round_index,
+            template_count=len(decision.template_messages),
+            llm_follow_up=decision.llm_follow_up,
+            terminate=decision.terminate,
+        )
+        if decision.terminate:
+            await self._audit(
+                queues,
+                "terminal_completed",
+                turn_started_at=turn_started_at,
+                session_id=session_id,
+                turn_index=turn_index,
+                round_index=round_index,
+                event_ids=[
+                    result.event_id
+                    for result in batch.results
+                    if result.terminal and result.status.value == "success"
+                ],
+            )
+        return decision
 
 
     def _chat_messages_for_round(
     def _chat_messages_for_round(
         self,
         self,

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

@@ -1,4 +1,5 @@
 import inspect
 import inspect
+import json
 from collections.abc import Awaitable, Callable, Iterable, Sequence
 from collections.abc import Awaitable, Callable, Iterable, Sequence
 from copy import deepcopy
 from copy import deepcopy
 from dataclasses import dataclass
 from dataclasses import dataclass
@@ -9,11 +10,14 @@ from agent_lab.application.events import (
     CalendarSchedulePort,
     CalendarSchedulePort,
     DeviceVolumePort,
     DeviceVolumePort,
     EventDefinition,
     EventDefinition,
+    EventBatchDecision,
+    EventBatchResult,
     EventExecutionContext,
     EventExecutionContext,
     EventKernel,
     EventKernel,
     EventRegistry,
     EventRegistry,
     EventRequest,
     EventRequest,
     EventResult,
     EventResult,
+    EventResultMessageFactory,
     EventSource,
     EventSource,
     EventStatus,
     EventStatus,
     ResultPolicy,
     ResultPolicy,
@@ -58,6 +62,7 @@ class ToolDefinition:
     timeout_seconds: float | None = None
     timeout_seconds: float | None = None
     terminal: bool = False
     terminal: bool = False
     normalizer: ToolArgumentNormalizer | None = None
     normalizer: ToolArgumentNormalizer | None = None
+    result_message_factory: EventResultMessageFactory | None = None
 
 
 
 
 class ToolRegistry:
 class ToolRegistry:
@@ -197,6 +202,79 @@ class ToolRegistry:
             error = error.replace("EventKernel.execute", "execute_async")
             error = error.replace("EventKernel.execute", "execute_async")
         return {"tool": result.event_name, "error": error}
         return {"tool": result.event_name, "error": error}
 
 
+    def tool_reply(
+        self,
+        event: ToolCallEvent,
+        result: EventResult,
+    ) -> ChatMessage:
+        return ChatMessage(
+            role="tool",
+            content=json.dumps(self.tool_payload(result), ensure_ascii=False),
+            name=event.name,
+            tool_call_id=event.id,
+        )
+
+    def tool_replies(
+        self,
+        events: Sequence[ToolCallEvent],
+        batch: EventBatchResult,
+    ) -> list[ChatMessage]:
+        return [
+            self.tool_reply(event, result)
+            for event, result in zip(events, batch.results, strict=True)
+        ]
+
+    def batch_decision(self, batch: EventBatchResult) -> EventBatchDecision:
+        templates: list[str] = []
+        llm_follow_up = False
+        terminate = False
+        for result in batch.results:
+            if result.status is not EventStatus.SUCCESS:
+                llm_follow_up = True
+                continue
+            if result.result_policy is ResultPolicy.TERMINATE:
+                terminate = True
+                continue
+            if result.result_policy is ResultPolicy.LLM_FOLLOW_UP:
+                llm_follow_up = True
+                continue
+            if result.result_policy is not ResultPolicy.TEMPLATE_FOLLOW_UP:
+                continue
+            definition = self.event_registry.definition(result.event_name)
+            factory = definition.result_message_factory if definition else None
+            if factory is None:
+                continue
+            try:
+                message = factory(result).strip()
+            except Exception:
+                llm_follow_up = True
+                continue
+            if message:
+                templates.append(message)
+        return EventBatchDecision(
+            template_messages=tuple(templates),
+            llm_follow_up=llm_follow_up and not terminate,
+            terminate=terminate,
+        )
+
+    def compact_results_message(
+        self,
+        batch: EventBatchResult,
+    ) -> ChatMessage | None:
+        if not batch.results:
+            return None
+        return ChatMessage(
+            role="user",
+            content=(
+                "EventAgent results:\n"
+                + "\n".join(
+                    json.dumps(self.tool_payload(result), ensure_ascii=False)
+                    for result in batch.results
+                )
+            ),
+            name="event_agent",
+        )
+
     def _to_event_definition(self, definition: ToolDefinition) -> EventDefinition:
     def _to_event_definition(self, definition: ToolDefinition) -> EventDefinition:
         def resolver(
         def resolver(
             request: EventRequest,
             request: EventRequest,
@@ -237,6 +315,7 @@ class ToolRegistry:
             conflict_keys=definition.conflict_keys,
             conflict_keys=definition.conflict_keys,
             timeout_seconds=definition.timeout_seconds,
             timeout_seconds=definition.timeout_seconds,
             terminal=definition.terminal,
             terminal=definition.terminal,
+            result_message_factory=definition.result_message_factory,
         )
         )
 
 
     def _tool_event(self, request: EventRequest) -> ToolCallEvent:
     def _tool_event(self, request: EventRequest) -> ToolCallEvent:

+ 270 - 0
tests/test_debug_runtime.py

@@ -9,6 +9,7 @@ import pytest
 
 
 from agent_lab.application.contracts import AgentParams, DebugRunRequest, EventAgentParams
 from agent_lab.application.contracts import AgentParams, DebugRunRequest, EventAgentParams
 from agent_lab.application.event_agent import EventAgentRequest
 from agent_lab.application.event_agent import EventAgentRequest
+from agent_lab.application.events import ResultPolicy
 from agent_lab.application.runtime import DebugRuntime
 from agent_lab.application.runtime import DebugRuntime
 from agent_lab.application.tools import ToolDefinition, ToolExecutionContext, ToolRegistry
 from agent_lab.application.tools import ToolDefinition, ToolExecutionContext, ToolRegistry
 from agent_lab.domain.events import ToolCallEvent
 from agent_lab.domain.events import ToolCallEvent
@@ -647,9 +648,12 @@ async def test_runtime_emits_audit_events_and_backend_logs(caplog):
         "chat_agent_request",
         "chat_agent_request",
         "chat_event_detected",
         "chat_event_detected",
         "chat_agent_response",
         "chat_agent_response",
+        "event_batch_started",
         "event_agent_request",
         "event_agent_request",
         "event_agent_response",
         "event_agent_response",
         "event_agent_completed",
         "event_agent_completed",
+        "event_batch_results",
+        "event_policy_decision",
         "chat_round_finished",
         "chat_round_finished",
         "chat_round_started",
         "chat_round_started",
         "chat_agent_request",
         "chat_agent_request",
@@ -1427,6 +1431,9 @@ async def test_runtime_emits_round_stats_for_each_chat_call_in_event_handoff():
             2.05,
             2.05,
             2.06,
             2.06,
             2.07,
             2.07,
+            2.08,
+            2.09,
+            2.1,
             2.25,
             2.25,
             2.26,
             2.26,
             3.0,
             3.0,
@@ -2222,3 +2229,266 @@ async def test_dual_mode_session_main_path_ignores_text_event_after_budget_exhau
         if message["type"] == "event"
         if message["type"] == "event"
     ] == ["session-accepted"]
     ] == ["session-accepted"]
     assert outputs[-1]["type"] == "turn_completed"
     assert outputs[-1]["type"] == "turn_completed"
+
+
+def _policy_request(
+    *,
+    enabled_tools: list[str],
+    mode: str = "dual_agent",
+    session_id: str | None = None,
+) -> DebugRunRequest:
+    return DebugRunRequest(
+        session_id=session_id,
+        user_message="exercise event result policy",
+        system_prompts=[],
+        pre_messages=[],
+        chat_agent=AgentParams(model="chat-model"),
+        event_agent=EventAgentParams(
+            enabled_tools=enabled_tools,
+            max_event_loops=1,
+        ),
+        tool_invocation_mode=mode,
+    )
+
+
+@pytest.mark.asyncio
+async def test_dual_silent_success_does_not_enqueue_legacy_summary_or_call_chat_again():
+    registry = ToolRegistry(
+        [
+            ToolDefinition(
+                name="example.silent",
+                description="Complete silently.",
+                parameters={"type": "object"},
+                handler=lambda event: {"tool": event.name, "status": "applied"},
+                result_policy=ResultPolicy.SILENT_SUCCESS,
+            )
+        ]
+    )
+    client = ScriptedChatClient(
+        [
+            [
+                StreamItem.message_delta("Applying it now."),
+                StreamItem.text_event(_tool_call("silent-1", name="example.silent")),
+            ]
+        ]
+    )
+
+    outputs = await _collect_outputs(
+        DebugRuntime(client, registry=registry).run(
+            _policy_request(enabled_tools=["example.silent"])
+        )
+    )
+
+    assert client.calls == 1
+    assert outputs[-1] == {"type": "done"}
+    assert not any(
+        message.get("details", {}).get("result_summary")
+        for message in outputs
+        if message.get("event") == "event_agent_completed"
+    )
+
+
+@pytest.mark.asyncio
+async def test_direct_template_success_emits_plugin_confirmation_without_model_call():
+    schedule = ToolCallEvent(
+        id="schedule-1",
+        name="calendar.schedule.create",
+        arguments={
+            "title": "Design review",
+            "start_at": "2026-07-14T09:30:00+08:00",
+            "timezone": "Asia/Shanghai",
+        },
+        raw_arguments=(
+            '{"title":"Design review","start_at":"2026-07-14T09:30:00+08:00",'
+            '"timezone":"Asia/Shanghai"}'
+        ),
+    )
+    client = ScriptedChatClient(
+        [
+            [
+                StreamItem.message_delta("I will add it."),
+                StreamItem.provider_tool_call(schedule),
+            ]
+        ]
+    )
+
+    outputs = await _collect_outputs(
+        DebugRuntime(client).run(
+            _policy_request(
+                enabled_tools=["calendar.schedule.create"],
+                mode="chat_agent_tools",
+            )
+        )
+    )
+
+    assert client.calls == 1
+    assert [
+        message["content"]
+        for message in outputs
+        if message["type"] == "message_delta"
+    ] == [
+        "I will add it.",
+        "Scheduled Design review for 2026-07-14T09:30:00+08:00 (Asia/Shanghai).",
+    ]
+    assert outputs[-1] == {"type": "done"}
+
+
+@pytest.mark.asyncio
+async def test_direct_silent_failure_requests_exactly_one_corrective_follow_up():
+    def fail(event: ToolCallEvent) -> dict[str, Any]:
+        raise RuntimeError("device offline")
+
+    registry = ToolRegistry(
+        [
+            ToolDefinition(
+                name="example.silent",
+                description="Complete silently on success.",
+                parameters={"type": "object"},
+                handler=fail,
+                result_policy=ResultPolicy.SILENT_SUCCESS,
+            )
+        ]
+    )
+    client = ScriptedChatClient(
+        [
+            [
+                StreamItem.message_delta("Trying it."),
+                StreamItem.provider_tool_call(
+                    _tool_call("silent-failure", name="example.silent")
+                ),
+            ],
+            [StreamItem.message_delta("I could not apply that change.")],
+        ]
+    )
+
+    outputs = await _collect_outputs(
+        DebugRuntime(client, registry=registry).run(
+            _policy_request(
+                enabled_tools=["example.silent"],
+                mode="chat_agent_tools",
+            )
+        )
+    )
+
+    assert client.calls == 2
+    assert [
+        message["content"]
+        for message in outputs
+        if message["type"] == "message_delta"
+    ] == ["Trying it.", "I could not apply that change."]
+
+
+@pytest.mark.asyncio
+async def test_mixed_builtin_batch_emits_template_then_terminates_without_llm():
+    calls = [
+        ToolCallEvent(
+            id="terminate-1",
+            name="session.terminate",
+            arguments={},
+            raw_arguments="{}",
+        ),
+        ToolCallEvent(
+            id="schedule-1",
+            name="calendar.schedule.create",
+            arguments={
+                "title": "Design review",
+                "start_at": "2026-07-14T09:30:00+08:00",
+                "timezone": "Asia/Shanghai",
+            },
+            raw_arguments=(
+                '{"title":"Design review","start_at":"2026-07-14T09:30:00+08:00",'
+                '"timezone":"Asia/Shanghai"}'
+            ),
+        ),
+        ToolCallEvent(
+            id="search-1",
+            name="knowledge.web.search",
+            arguments={"query": "event batch executors"},
+            raw_arguments='{"query":"event batch executors"}',
+        ),
+        ToolCallEvent(
+            id="volume-1",
+            name="device.volume.adjust",
+            arguments={"mode": "absolute", "value": 30},
+            raw_arguments='{"mode":"absolute","value":30}',
+        ),
+    ]
+    client = ScriptedChatClient(
+        [
+            [
+                StreamItem.message_delta("Goodbye, I will finish those first."),
+                *[StreamItem.provider_tool_call(call) for call in calls],
+            ]
+        ]
+    )
+
+    outputs = await _collect_outputs(
+        DebugRuntime(client).run(
+            _policy_request(
+                enabled_tools=[call.name for call in calls],
+                mode="chat_agent_tools",
+            )
+        )
+    )
+
+    assert client.calls == 1
+    assert [
+        message["message"]["tool_call_id"]
+        for message in outputs
+        if message["type"] == "tool_result"
+    ] == [call.id for call in calls]
+    assert [
+        message["content"]
+        for message in outputs
+        if message["type"] == "message_delta"
+    ][-1] == (
+        "Scheduled Design review for 2026-07-14T09:30:00+08:00 "
+        "(Asia/Shanghai)."
+    )
+    decision = next(
+        message
+        for message in outputs
+        if message.get("event") == "event_policy_decision"
+    )
+    assert decision["details"]["terminate"] is True
+    assert decision["details"]["llm_follow_up"] is False
+    assert any(
+        message.get("event") == "terminal_completed" for message in outputs
+    )
+    assert outputs[-1] == {"type": "done"}
+
+
+@pytest.mark.asyncio
+async def test_reusable_session_terminate_completes_turn_and_session_task():
+    client = ScriptedChatClient(
+        [
+            [
+                StreamItem.message_delta("Goodbye."),
+                StreamItem.provider_tool_call(
+                    ToolCallEvent(
+                        id="terminate-session",
+                        name="session.terminate",
+                        arguments={},
+                        raw_arguments="{}",
+                    )
+                ),
+            ]
+        ]
+    )
+    runtime = DebugRuntime(client)
+    queues = runtime.start_session(
+        _policy_request(
+            enabled_tools=["session.terminate"],
+            mode="chat_agent_tools",
+            session_id="reusable-session",
+        )
+    )
+    outputs: list[dict[str, Any]] = []
+
+    while not any(message["type"] == "done" for message in outputs):
+        outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
+
+    await asyncio.wait_for(runtime._wait_for_tasks(), timeout=1)
+
+    business_types = [message["type"] for message in _without_audit(outputs)]
+    assert business_types[-2:] == ["turn_completed", "done"]

+ 8 - 4
tests/test_event_agent.py

@@ -4,7 +4,7 @@ from collections.abc import AsyncIterator
 
 
 import pytest
 import pytest
 
 
-from agent_lab.application.contracts import AgentParams
+from agent_lab.application.contracts import AgentParams, EventAgentParams
 from agent_lab.application.event_agent import EventAgent
 from agent_lab.application.event_agent import EventAgent
 from agent_lab.application.tools import ToolDefinition, ToolExecutionContext, ToolRegistry
 from agent_lab.application.tools import ToolDefinition, ToolExecutionContext, ToolRegistry
 from agent_lab.domain.events import ToolCallEvent
 from agent_lab.domain.events import ToolCallEvent
@@ -291,17 +291,21 @@ async def test_event_agent_handle_many_starts_independent_handlers_concurrently(
         for index, name in enumerate(("event.first", "event.second"), start=1)
         for index, name in enumerate(("event.first", "event.second"), start=1)
     ]
     ]
 
 
-    replies = await asyncio.wait_for(
+    batch = await asyncio.wait_for(
         EventAgent(
         EventAgent(
             enabled_tools=[event.name for event in events],
             enabled_tools=[event.name for event in events],
             registry=registry,
             registry=registry,
+            params=EventAgentParams(max_parallel_events=2),
         ).handle_many(events, history=[]),
         ).handle_many(events, history=[]),
         timeout=0.5,
         timeout=0.5,
     )
     )
 
 
     assert started == ["event.first", "event.second"]
     assert started == ["event.first", "event.second"]
-    assert [reply.name for reply in replies] == ["event.first", "event.second"]
-    assert [json.loads(reply.content) for reply in replies] == [
+    assert [result.event_name for result in batch.results] == [
+        "event.first",
+        "event.second",
+    ]
+    assert [result.payload for result in batch.results] == [
         {"tool": "event.first"},
         {"tool": "event.first"},
         {"tool": "event.second"},
         {"tool": "event.second"},
     ]
     ]

+ 349 - 0
tests/test_event_batch.py

@@ -0,0 +1,349 @@
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Callable
+from typing import Any
+
+import pytest
+
+import agent_lab.application.events as events_module
+from agent_lab.application.events import (
+    EventDefinition,
+    EventKernel,
+    EventRegistry,
+    EventRequest,
+    EventStatus,
+)
+
+
+def _executor_type():
+    executor_type = getattr(events_module, "EventBatchExecutor", None)
+    assert executor_type is not None, "EventBatchExecutor is not implemented"
+    return executor_type
+
+
+def _definition(
+    name: str,
+    handler: Callable[[EventRequest], Any],
+    *,
+    conflict_keys: tuple[str, ...] = (),
+    timeout_seconds: float | None = None,
+    terminal: bool = False,
+) -> EventDefinition:
+    return EventDefinition(
+        name=name,
+        description=f"Execute {name}.",
+        parameters={"type": "object"},
+        handler=handler,
+        conflict_keys=conflict_keys,
+        timeout_seconds=timeout_seconds,
+        terminal=terminal,
+    )
+
+
+def _request(event_id: str, name: str, arguments: dict[str, Any] | None = None):
+    return EventRequest(
+        id=event_id,
+        name=name,
+        arguments=arguments or {},
+    )
+
+
+def _executor(
+    definitions: list[EventDefinition],
+    *,
+    max_parallel_events: int = 4,
+    batch_timeout_seconds: float = 1.0,
+):
+    return _executor_type()(
+        EventKernel(EventRegistry(definitions)),
+        max_parallel_events=max_parallel_events,
+        batch_timeout_seconds=batch_timeout_seconds,
+    )
+
+
+@pytest.mark.asyncio
+async def test_batch_executor_bounds_global_parallelism():
+    active = 0
+    max_active = 0
+
+    async def handler(request: EventRequest) -> dict[str, Any]:
+        nonlocal active, max_active
+        active += 1
+        max_active = max(max_active, active)
+        await asyncio.sleep(0.02)
+        active -= 1
+        return {"event_id": request.id}
+
+    executor = _executor(
+        [_definition("example.work", handler)],
+        max_parallel_events=2,
+    )
+
+    batch = await executor.execute(
+        [_request(str(index), "example.work") for index in range(4)],
+        scope="session-1:turn-1",
+    )
+
+    assert max_active == 2
+    assert [result.status for result in batch.results] == [
+        EventStatus.SUCCESS,
+    ] * 4
+
+
+@pytest.mark.asyncio
+async def test_batch_executor_preserves_input_order_when_completion_order_differs():
+    release_first = asyncio.Event()
+    second_finished = asyncio.Event()
+
+    async def handler(request: EventRequest) -> dict[str, Any]:
+        if request.id == "first":
+            await release_first.wait()
+        else:
+            second_finished.set()
+        return {"event_id": request.id}
+
+    executor = _executor([_definition("example.work", handler)])
+    task = asyncio.create_task(
+        executor.execute(
+            [
+                _request("first", "example.work"),
+                _request("second", "example.work"),
+            ],
+            scope="session-1:turn-1",
+        )
+    )
+    await asyncio.wait_for(second_finished.wait(), timeout=0.2)
+    release_first.set()
+
+    batch = await asyncio.wait_for(task, timeout=0.2)
+
+    assert [result.event_id for result in batch.results] == ["first", "second"]
+
+
+@pytest.mark.asyncio
+async def test_batch_executor_serializes_shared_conflict_keys():
+    active = 0
+    max_active = 0
+
+    async def handler(request: EventRequest) -> dict[str, Any]:
+        nonlocal active, max_active
+        active += 1
+        max_active = max(max_active, active)
+        await asyncio.sleep(0.02)
+        active -= 1
+        return {"event_id": request.id}
+
+    executor = _executor(
+        [
+            _definition(
+                "example.write",
+                handler,
+                conflict_keys=("shared-resource",),
+            )
+        ]
+    )
+
+    await executor.execute(
+        [
+            _request("first", "example.write"),
+            _request("second", "example.write"),
+        ],
+        scope="session-1:turn-1",
+    )
+
+    assert max_active == 1
+
+
+@pytest.mark.asyncio
+async def test_batch_executor_isolates_per_event_timeout_from_successful_sibling():
+    async def slow_handler(request: EventRequest) -> dict[str, Any]:
+        await asyncio.sleep(1)
+        return {"event_id": request.id}
+
+    executor = _executor(
+        [
+            _definition(
+                "example.slow",
+                slow_handler,
+                timeout_seconds=0.01,
+            ),
+            _definition(
+                "example.fast",
+                lambda request: {"event_id": request.id},
+            ),
+        ],
+        batch_timeout_seconds=0.2,
+    )
+
+    batch = await executor.execute(
+        [
+            _request("slow", "example.slow"),
+            _request("fast", "example.fast"),
+        ],
+        scope="session-1:turn-1",
+    )
+
+    assert [result.status for result in batch.results] == [
+        EventStatus.TIMEOUT,
+        EventStatus.SUCCESS,
+    ]
+    assert batch.deadline_exceeded is False
+
+
+@pytest.mark.asyncio
+async def test_batch_executor_applies_overall_deadline_to_waiting_work():
+    async def blocked_handler(request: EventRequest) -> dict[str, Any]:
+        await asyncio.Event().wait()
+        return {"event_id": request.id}
+
+    executor = _executor(
+        [_definition("example.blocked", blocked_handler)],
+        max_parallel_events=1,
+        batch_timeout_seconds=0.02,
+    )
+
+    batch = await executor.execute(
+        [
+            _request("first", "example.blocked"),
+            _request("second", "example.blocked"),
+        ],
+        scope="session-1:turn-1",
+    )
+
+    assert [result.status for result in batch.results] == [
+        EventStatus.TIMEOUT,
+        EventStatus.TIMEOUT,
+    ]
+    assert batch.deadline_exceeded is True
+
+
+@pytest.mark.asyncio
+async def test_batch_executor_coalesces_exact_duplicates_inside_one_batch():
+    calls = 0
+
+    def handler(request: EventRequest) -> dict[str, Any]:
+        nonlocal calls
+        calls += 1
+        return {"event_id": request.id, "arguments": request.arguments}
+
+    executor = _executor([_definition("example.write", handler)])
+
+    batch = await executor.execute(
+        [
+            _request("same", "example.write", {"a": 1, "b": 2}),
+            _request("same", "example.write", {"b": 2, "a": 1}),
+        ],
+        scope="session-1:turn-1",
+    )
+
+    assert calls == 1
+    assert batch.coalesced_count == 1
+    assert batch.results[0] == batch.results[1]
+
+
+@pytest.mark.asyncio
+async def test_batch_executor_replay_key_includes_scope_event_id_and_request():
+    calls = 0
+
+    def handler(request: EventRequest) -> dict[str, Any]:
+        nonlocal calls
+        calls += 1
+        return {"call": calls, "arguments": request.arguments}
+
+    executor = _executor([_definition("example.write", handler)])
+    original = _request("same", "example.write", {"a": 1, "b": 2})
+    canonical_replay = _request(
+        "same",
+        "example.write",
+        {"b": 2, "a": 1},
+    )
+
+    first = await executor.execute([original], scope="session-1:turn-1")
+    replay = await executor.execute(
+        [canonical_replay],
+        scope="session-1:turn-1",
+    )
+    changed_request = await executor.execute(
+        [_request("same", "example.write", {"a": 2})],
+        scope="session-1:turn-1",
+    )
+    changed_id = await executor.execute(
+        [_request("other", "example.write", {"a": 1, "b": 2})],
+        scope="session-1:turn-1",
+    )
+    changed_scope = await executor.execute(
+        [original],
+        scope="session-1:turn-2",
+    )
+
+    assert first.results[0] == replay.results[0]
+    assert replay.replayed_count == 1
+    assert changed_request.replayed_count == 0
+    assert changed_id.replayed_count == 0
+    assert changed_scope.replayed_count == 0
+    assert calls == 4
+
+
+@pytest.mark.asyncio
+async def test_batch_executor_runs_terminal_events_after_all_nonterminal_results():
+    observed: list[str] = []
+
+    async def nonterminal(request: EventRequest) -> dict[str, Any]:
+        observed.append(f"start:{request.id}")
+        await asyncio.sleep(0.01)
+        observed.append(f"finish:{request.id}")
+        return {"event_id": request.id}
+
+    def terminal(request: EventRequest) -> dict[str, Any]:
+        observed.append(f"terminal:{request.id}")
+        return {"event_id": request.id}
+
+    executor = _executor(
+        [
+            _definition("example.work", nonterminal),
+            _definition("example.terminate", terminal, terminal=True),
+        ]
+    )
+
+    batch = await executor.execute(
+        [
+            _request("terminate", "example.terminate"),
+            _request("first", "example.work"),
+            _request("second", "example.work"),
+        ],
+        scope="session-1:turn-1",
+    )
+
+    terminal_index = observed.index("terminal:terminate")
+    assert observed.index("finish:first") < terminal_index
+    assert observed.index("finish:second") < terminal_index
+    assert [result.event_id for result in batch.results] == [
+        "terminate",
+        "first",
+        "second",
+    ]
+
+
+@pytest.mark.asyncio
+async def test_batch_executor_does_not_swallow_external_cancellation():
+    started = asyncio.Event()
+
+    async def handler(request: EventRequest) -> dict[str, Any]:
+        started.set()
+        await asyncio.Event().wait()
+        return {"event_id": request.id}
+
+    executor = _executor([_definition("example.blocked", handler)])
+    task = asyncio.create_task(
+        executor.execute(
+            [_request("blocked", "example.blocked")],
+            scope="session-1:turn-1",
+        )
+    )
+    await asyncio.wait_for(started.wait(), timeout=0.2)
+
+    task.cancel()
+
+    with pytest.raises(asyncio.CancelledError):
+        await task

+ 14 - 0
tests/test_websocket_api.py

@@ -160,9 +160,23 @@ def test_agent_params_defaults_include_extra_body_and_event_agent_defaults_to_on
     }
     }
     assert event_params.extra_body == chat_params.extra_body
     assert event_params.extra_body == chat_params.extra_body
     assert event_params.max_event_loops == 1
     assert event_params.max_event_loops == 1
+    assert event_params.max_parallel_events == 4
+    assert event_params.batch_timeout_seconds == 15.0
     assert event_params.system_prompt == ""
     assert event_params.system_prompt == ""
 
 
 
 
+@pytest.mark.parametrize("value", [0, 17])
+def test_event_agent_params_rejects_out_of_range_parallelism(value: int):
+    with pytest.raises(ValidationError, match="max_parallel_events"):
+        EventAgentParams(max_parallel_events=value)
+
+
+@pytest.mark.parametrize("value", [0, -1, 121])
+def test_event_agent_params_rejects_invalid_batch_timeout(value: float):
+    with pytest.raises(ValidationError, match="batch_timeout_seconds"):
+        EventAgentParams(batch_timeout_seconds=value)
+
+
 def test_debug_run_request_defaults_to_dual_agent_tool_invocation_mode():
 def test_debug_run_request_defaults_to_dual_agent_tool_invocation_mode():
     request = DebugRunRequest.model_validate(_request_payload())
     request = DebugRunRequest.model_validate(_request_payload())