- harness/es.py: append-only event store + проекции (образец — кооп-портал) - harness/levels.py: уровни 1с/1м/1ч/1д, контекст ∝ масштабу - harness/scheduler.py: уровневые потоки, идемпотентные тики, absorbed-стабы - harness/isolated_runner.py + _llm_worker.py: LLM-тик в сабпроцессе (глобальный asyncio-рантайм Strands не переживает 2+ create_harness) - cli.py: init/task/tasks/status/events + requeue (написан самим минутным агентом) - 9router: отдельный ключ sk-simbiot, модель auto (glm-5.3 первым)
835 lines
35 KiB
Python
835 lines
35 KiB
Python
"""Agent loop.
|
|
|
|
The agent loop handles the events received from the model and executes tools when given a tool use request.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import time
|
|
import warnings
|
|
from collections.abc import AsyncGenerator
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING, Any, Literal, cast
|
|
|
|
from opentelemetry.trace import Span
|
|
|
|
from ....telemetry.tracer import get_tracer
|
|
from ....types._events import ToolInterruptEvent, ToolResultEvent, ToolResultMessageEvent, ToolUseStreamEvent
|
|
from ....types.content import ContentBlock, Message
|
|
from ....types.tools import ToolResult, ToolResultBlock, ToolUse
|
|
from .. import _telemetry
|
|
from .._async import _TaskPool, stop_all
|
|
from ..hooks.events import (
|
|
BidiAfterConnectionRestartEvent,
|
|
BidiAgentStopEvent,
|
|
BidiBeforeConnectionRestartEvent,
|
|
)
|
|
from ..hooks.events import (
|
|
BidiBargeInEvent as BidiBargeInHookEvent,
|
|
)
|
|
from ..hooks.events import (
|
|
BidiResponseStopEvent as BidiResponseStopHookEvent,
|
|
)
|
|
from ..models import ConnectionTimeoutError, Restartable
|
|
from ..types.content import BidiContentBlock, BidiContentDelta, BidiToolMetadata, BidiTranscriptMetadata
|
|
from ..types.events import (
|
|
BidiAudioDeltaEvent,
|
|
BidiBargeInEvent,
|
|
BidiConnectionRestartEvent,
|
|
BidiConnectionStopEvent,
|
|
BidiConnectionWarningEvent,
|
|
BidiOutputEvent,
|
|
BidiResponseStartEvent,
|
|
BidiResponseStopEvent,
|
|
BidiTranscriptStartEvent,
|
|
BidiTranscriptStopEvent,
|
|
BidiUsageEvent,
|
|
)
|
|
from ._reconnect_timer import _ReconnectTimer, resolve_deadline_s
|
|
|
|
if TYPE_CHECKING:
|
|
from .agent import BidiAgent
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Bound on awaiting a superseded reader after its stream is closed before cancelling it.
|
|
_MODEL_RESTART_STOP_TIMEOUT_S = 2
|
|
|
|
# Fixed advance notice, in seconds before a scheduled reconnect, for the warning event.
|
|
_MODEL_RESTART_WARNING_S = 10
|
|
|
|
# Max seconds a proactive reconnect waits for a turn boundary before forcing the swap.
|
|
_MODEL_RESTART_TURN_TIMEOUT_S = 10
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _ReaderError:
|
|
"""A model-reader error tagged with the connection generation it was raised on.
|
|
|
|
receive() uses the tag to drop the error if the connection was superseded before it was
|
|
consumed.
|
|
"""
|
|
|
|
generation: int
|
|
error: Exception
|
|
|
|
|
|
class _AgentLoop:
|
|
"""Agent loop.
|
|
|
|
Attributes:
|
|
_agent: BidiAgent instance to loop.
|
|
_started: Flag if agent loop has started.
|
|
_task_pool: Track active async tasks created in loop.
|
|
_event_queue: Queue output and connection lifecycle events for receiver.
|
|
_invocation_state: Optional context to pass to tools during execution.
|
|
This allows passing custom data (user_id, session_id, database connections, etc.)
|
|
that tools can access via their invocation_state parameter.
|
|
_send_gate: Gate the sending of events to the model.
|
|
Blocks while the agent is reconnecting the model connection.
|
|
"""
|
|
|
|
def __init__(self, agent: "BidiAgent") -> None:
|
|
"""Initialize members of the agent loop.
|
|
|
|
Note, before receiving events from the loop, the user must call `start`.
|
|
|
|
Args:
|
|
agent: Bidirectional agent to loop over.
|
|
"""
|
|
self._agent = agent
|
|
self._started = False
|
|
self._task_pool = _TaskPool()
|
|
self._event_queue: asyncio.Queue
|
|
self._invocation_state: dict[str, Any]
|
|
self._model_task: asyncio.Task | None = None
|
|
|
|
self._send_gate = asyncio.Event()
|
|
|
|
self._tracer = get_tracer()
|
|
self._session_span: Span | None = None
|
|
|
|
# Session totals = baseline (finished connections) + current (this connection).
|
|
self._current_input_tokens = 0
|
|
self._current_output_tokens = 0
|
|
self._current_total_tokens = 0
|
|
self._current_cache_read_tokens = 0
|
|
self._baseline_input_tokens = 0
|
|
self._baseline_output_tokens = 0
|
|
self._baseline_total_tokens = 0
|
|
self._baseline_cache_read_tokens = 0
|
|
|
|
self._reconnect_timer = _ReconnectTimer(
|
|
on_warning=self._on_reconnect_warning,
|
|
on_deadline=self._on_reconnect_deadline,
|
|
)
|
|
# Guards _restart_connection against concurrent reactive + proactive entry.
|
|
self._reconnecting = False
|
|
# Incremented per reconnect so a superseded reader's events (and its stream-close
|
|
# error) are dropped rather than forwarded after the swap.
|
|
self._generation = 0
|
|
|
|
# Turn-boundary tracking, so a proactive reconnect waits for the current turn to
|
|
# finish rather than cutting off a response or dropping an unanswered user turn.
|
|
# A provider that emits neither response nor transcript events never leaves the
|
|
# boundary state, so the aligned wait is a no-op (reconnect fires immediately).
|
|
self._response_active = False
|
|
self._awaiting_response = False
|
|
self._turn_complete = asyncio.Event()
|
|
self._turn_complete.set()
|
|
|
|
async def start(self, invocation_state: dict[str, Any] | None = None) -> None:
|
|
"""Start the agent loop.
|
|
|
|
The agent model is started as part of this call.
|
|
|
|
Args:
|
|
invocation_state: Optional context to pass to tools during execution.
|
|
This allows passing custom data (user_id, session_id, database connections, etc.)
|
|
that tools can access via their invocation_state parameter.
|
|
|
|
Raises:
|
|
RuntimeError: If loop already started.
|
|
"""
|
|
if self._started:
|
|
raise RuntimeError("loop already started | call stop before starting again")
|
|
|
|
logger.debug("agent loop starting")
|
|
model_id = getattr(self._agent.model, "model_id", None)
|
|
|
|
self._session_span = _telemetry.start_session_span(
|
|
self._tracer,
|
|
agent_name=self._agent.name,
|
|
model_id=model_id,
|
|
tools=self._agent.tool_names,
|
|
system_prompt=self._agent.system_prompt,
|
|
)
|
|
|
|
connection_span = _telemetry.start_connection_span(
|
|
self._tracer, parent_span=self._session_span, model_id=model_id
|
|
)
|
|
try:
|
|
await self._agent.model.start(
|
|
system_prompt=self._agent.system_prompt,
|
|
tools=self._agent.tool_registry.get_all_tool_specs(),
|
|
messages=self._agent.messages,
|
|
)
|
|
except Exception as error:
|
|
_telemetry.end_connection_span(self._tracer, connection_span, error=error)
|
|
_telemetry.end_session_span(self._tracer, self._session_span, error=error)
|
|
self._session_span = None
|
|
raise
|
|
_telemetry.end_connection_span(self._tracer, connection_span)
|
|
self._reset_token_tracking()
|
|
self._reset_turn_state()
|
|
|
|
self._event_queue = asyncio.Queue(maxsize=1)
|
|
|
|
self._task_pool = _TaskPool()
|
|
self._model_task = self._task_pool.create(self._run_model(self._generation))
|
|
|
|
self._invocation_state = invocation_state if invocation_state is not None else {}
|
|
self._send_gate.set()
|
|
self._started = True
|
|
|
|
self._arm_reconnect_timer()
|
|
|
|
async def stop(self) -> None:
|
|
"""Stop the agent loop."""
|
|
logger.debug("agent loop stopping")
|
|
|
|
self._started = False
|
|
self._send_gate.clear()
|
|
self._reconnect_timer.cancel()
|
|
# Unblock a deadline callback waiting on a turn boundary (it is past the timer's cancel);
|
|
# once released it re-checks _started and no-ops.
|
|
self._turn_complete.set()
|
|
self._invocation_state = {}
|
|
|
|
async def stop_tasks() -> None:
|
|
await self._task_pool.cancel()
|
|
|
|
async def stop_model() -> None:
|
|
await self._agent.model.stop()
|
|
|
|
try:
|
|
await stop_all(stop_tasks, stop_model)
|
|
finally:
|
|
if self._session_span:
|
|
_telemetry.end_session_span(
|
|
self._tracer,
|
|
self._session_span,
|
|
input_tokens=self._accumulated_input_tokens,
|
|
output_tokens=self._accumulated_output_tokens,
|
|
total_tokens=self._accumulated_total_tokens,
|
|
cache_read_input_tokens=self._accumulated_cache_read_tokens,
|
|
)
|
|
self._session_span = None
|
|
|
|
await self._agent.hooks.invoke_callbacks_async(BidiAgentStopEvent(agent=self._agent))
|
|
|
|
async def send(self, content: BidiContentBlock | BidiContentDelta | ToolResultBlock) -> None:
|
|
"""Send user input or a tool result to the model.
|
|
|
|
Complete content blocks are also added to conversation history.
|
|
|
|
Args:
|
|
content: User input or tool result to send.
|
|
|
|
Raises:
|
|
RuntimeError: If start has not been called.
|
|
"""
|
|
if not self._started:
|
|
raise RuntimeError("loop not started | call start before sending")
|
|
|
|
if not self._send_gate.is_set():
|
|
logger.debug("waiting for model send signal")
|
|
await self._send_gate.wait()
|
|
|
|
if isinstance(content, BidiContentBlock):
|
|
message: Message = {"role": "user", "content": [cast(ContentBlock, content.to_dict())]}
|
|
await self._agent._append_messages(message)
|
|
|
|
# Let scheduled reconnects wait for the response.
|
|
self._awaiting_response = True
|
|
self._update_turn_state()
|
|
|
|
await self._agent.model.send(content)
|
|
|
|
async def receive(self) -> AsyncGenerator[BidiOutputEvent, None]:
|
|
"""Receive model and tool call events.
|
|
|
|
Yields:
|
|
Model and tool call events.
|
|
|
|
Raises:
|
|
RuntimeError: If start has not been called.
|
|
"""
|
|
if not self._started:
|
|
raise RuntimeError("loop not started | call start before receiving")
|
|
|
|
while True:
|
|
event = await self._event_queue.get()
|
|
if isinstance(event, _ReaderError):
|
|
if event.generation != self._generation:
|
|
# Superseded reader: its connection was already replaced, so drop the error
|
|
# rather than surface or restart on it.
|
|
logger.debug("dropping stale reader error from a superseded connection")
|
|
continue
|
|
error = event.error
|
|
if isinstance(error, ConnectionTimeoutError):
|
|
logger.debug("model timeout error received")
|
|
if not self._auto_reconnect_enabled():
|
|
logger.debug("auto_reconnect disabled | surfacing timeout to caller")
|
|
raise error
|
|
restart_event = BidiConnectionRestartEvent(
|
|
reason="timeout",
|
|
timeout_error=error,
|
|
turn_interrupted=not self._turn_complete.is_set(),
|
|
)
|
|
try:
|
|
await self._restart_connection(
|
|
error,
|
|
event.generation,
|
|
restart_event=restart_event,
|
|
)
|
|
except Exception:
|
|
# The restart event was queued before the failing swap. Surface it before
|
|
# preserving the existing behavior of raising the restart failure.
|
|
yield self._event_queue.get_nowait()
|
|
raise
|
|
continue
|
|
raise error
|
|
|
|
if isinstance(event, Exception):
|
|
raise event
|
|
|
|
# Check for graceful shutdown event
|
|
if isinstance(event, BidiConnectionStopEvent) and event.reason == "user_request":
|
|
yield event
|
|
break
|
|
|
|
yield event
|
|
|
|
def _auto_reconnect_enabled(self) -> bool:
|
|
"""Whether the agent reconnects automatically.
|
|
|
|
Automatic reconnect is the default: a provider is opted in unless it explicitly
|
|
declares ``auto_reconnect: False`` in its connection config.
|
|
"""
|
|
return self._agent.model.get_connection_config().get("auto_reconnect", True)
|
|
|
|
def _arm_reconnect_timer(self) -> None:
|
|
"""Arm the proactive reconnect timer when the model opts in with a declared deadline.
|
|
|
|
Owns the arming policy (auto_reconnect + a declared ``restart_after_s``); the timer
|
|
itself is a pure mechanism. A no-op when reconnect is disabled or none is declared.
|
|
"""
|
|
if not self._auto_reconnect_enabled():
|
|
return
|
|
deadline_s = resolve_deadline_s(self._agent.model.get_connection_config())
|
|
if deadline_s is None:
|
|
return
|
|
self._reconnect_timer.arm(deadline_s, _MODEL_RESTART_WARNING_S)
|
|
|
|
async def _on_reconnect_warning(self, time_left_s: int) -> None:
|
|
"""Timer callback: surface an approaching-reconnect warning to the receiver."""
|
|
logger.debug("time_left_s=<%.1f> | emitting connection warning", time_left_s)
|
|
await self._event_queue.put(BidiConnectionWarningEvent(time_left_s=time_left_s))
|
|
|
|
async def _on_reconnect_deadline(self) -> None:
|
|
"""Timer callback: align to a turn boundary, then reconnect proactively.
|
|
|
|
Waits (bounded) for the current turn to finish so the swap does not cut off a
|
|
response or drop an unanswered user turn; surfaces any failure on the event queue.
|
|
"""
|
|
logger.debug("proactive reconnect deadline reached")
|
|
# Capture before the wait so _restart_connection can decline if the loop stopped or a
|
|
# reactive swap ran while we waited.
|
|
generation = self._generation
|
|
await self._await_turn_boundary()
|
|
# A forced swap (the wait timed out) leaves _turn_complete clear: an in-progress or owed
|
|
# turn is being cut and won't be answered on replay. Capture before the swap resets it.
|
|
turn_interrupted = not self._turn_complete.is_set()
|
|
restart_event = BidiConnectionRestartEvent(reason="scheduled", turn_interrupted=turn_interrupted)
|
|
try:
|
|
await self._restart_connection(None, generation, restart_event=restart_event)
|
|
except Exception as error:
|
|
await self._event_queue.put(error)
|
|
|
|
async def _await_turn_boundary(self) -> None:
|
|
"""Wait for the current turn to finish, bounded so the reconnect beats the limit.
|
|
|
|
Returns immediately at a turn boundary (including for a provider that emits no turn
|
|
events). Otherwise waits up to ``_MODEL_RESTART_TURN_TIMEOUT_S`` for the turn to complete, then
|
|
proceeds so the swap does not overrun the headroom below the provider limit.
|
|
"""
|
|
if self._turn_complete.is_set():
|
|
return
|
|
try:
|
|
await asyncio.wait_for(self._turn_complete.wait(), timeout=_MODEL_RESTART_TURN_TIMEOUT_S)
|
|
except asyncio.TimeoutError:
|
|
logger.debug(
|
|
"no turn boundary within %.1fs | forcing reconnect",
|
|
_MODEL_RESTART_TURN_TIMEOUT_S,
|
|
)
|
|
|
|
def _reset_turn_state(self) -> None:
|
|
"""Reset turn tracking to the idle boundary state."""
|
|
self._response_active = False
|
|
self._awaiting_response = False
|
|
self._turn_complete.set()
|
|
|
|
def _update_turn_state(self) -> None:
|
|
"""Mark the turn complete when idle, or in-progress while a response is owed/active."""
|
|
if self._response_active or self._awaiting_response:
|
|
self._turn_complete.clear()
|
|
else:
|
|
self._turn_complete.set()
|
|
|
|
async def _restart_connection(
|
|
self,
|
|
timeout_error: ConnectionTimeoutError | None,
|
|
generation: int,
|
|
*,
|
|
restart_event: BidiConnectionRestartEvent | None = None,
|
|
) -> bool:
|
|
"""Restart the model connection, reactively (after timeout) or proactively (timer).
|
|
|
|
The single guard point for both paths: declines when the loop has stopped, when the
|
|
connection was already swapped since the trigger, or when a restart is in flight. A
|
|
supplied restart event is emitted after these checks and before the connection is swapped.
|
|
|
|
Args:
|
|
timeout_error: Timeout error on the reactive path, or ``None`` when proactive.
|
|
generation: Connection generation the trigger was raised for; the restart is declined
|
|
as stale if the connection has since been swapped.
|
|
restart_event: Restart event to emit before an accepted swap.
|
|
|
|
Returns:
|
|
``True`` if this call performed the swap, ``False`` if it declined. Raises if the
|
|
swap itself fails.
|
|
"""
|
|
if not self._started:
|
|
logger.debug("loop stopped | ignoring reconnect trigger")
|
|
return False
|
|
if generation != self._generation:
|
|
logger.debug(
|
|
"trigger_generation=<%d>, current_generation=<%d> | connection already swapped | ignoring restart",
|
|
generation,
|
|
self._generation,
|
|
)
|
|
return False
|
|
if self._reconnecting:
|
|
logger.debug("reconnect already in progress | ignoring duplicate trigger")
|
|
return False
|
|
self._reconnecting = True
|
|
self._reconnect_timer.cancel()
|
|
|
|
reason: Literal["timeout", "scheduled"] = "timeout" if timeout_error is not None else "scheduled"
|
|
logger.debug("reason=<%s> | resetting model connection", reason)
|
|
|
|
try:
|
|
self._send_gate.clear()
|
|
if restart_event is not None:
|
|
await self._event_queue.put(restart_event)
|
|
if not self._started:
|
|
# stop() can run while the bounded queue put is suspended.
|
|
logger.debug( # type: ignore[unreachable]
|
|
"loop stopped while emitting restart event | ignoring restart trigger"
|
|
)
|
|
return False
|
|
self._fold_token_baseline()
|
|
|
|
# A raising before-restart hook propagates out with the send gate left closed.
|
|
await self._agent.hooks.invoke_callbacks_async(
|
|
BidiBeforeConnectionRestartEvent(self._agent, reason=reason, timeout_error=timeout_error)
|
|
)
|
|
await self._swap_connection(reason, timeout_error)
|
|
|
|
self._reset_turn_state()
|
|
self._arm_reconnect_timer()
|
|
self._send_gate.set()
|
|
finally:
|
|
self._reconnecting = False
|
|
|
|
return True
|
|
|
|
async def _swap_connection(
|
|
self, reason: Literal["timeout", "scheduled"], timeout_error: ConnectionTimeoutError | None
|
|
) -> None:
|
|
"""Swap to a new connection under a restart span, firing the after-restart hook.
|
|
|
|
Supersedes the current reader (generation bump) so its stream-close error is fenced
|
|
rather than forwarded, then reconnects and starts the new reader. A failed swap is
|
|
re-raised after telemetry and the after-restart hook report it, leaving the gate closed.
|
|
"""
|
|
restart_span = _telemetry.start_restart_span(
|
|
self._tracer,
|
|
parent_span=self._session_span,
|
|
reason=reason,
|
|
error_message=str(timeout_error) if timeout_error is not None else None,
|
|
)
|
|
|
|
restart_kwargs = timeout_error.restart_config if timeout_error is not None else {}
|
|
restart_exception: Exception | None = None
|
|
try:
|
|
previous_reader = self._model_task
|
|
self._generation += 1
|
|
await self._restart_model(restart_kwargs)
|
|
await self._wait_for_model_task(previous_reader)
|
|
self._model_task = self._task_pool.create(self._run_model(self._generation))
|
|
except Exception as exception:
|
|
restart_exception = exception
|
|
finally:
|
|
_telemetry.end_restart_span(self._tracer, restart_span, error=restart_exception)
|
|
await self._agent.hooks.invoke_callbacks_async(
|
|
BidiAfterConnectionRestartEvent(self._agent, reason=reason, exception=restart_exception)
|
|
)
|
|
|
|
if restart_exception is not None:
|
|
raise restart_exception
|
|
|
|
async def _restart_model(self, restart_kwargs: dict[str, Any]) -> None:
|
|
"""Restart through the provider when supported, otherwise use ``stop()`` then ``start()``."""
|
|
model = self._agent.model
|
|
system_prompt = self._agent.system_prompt
|
|
tools = self._agent.tool_registry.get_all_tool_specs()
|
|
messages = self._agent.messages
|
|
|
|
if isinstance(model, Restartable):
|
|
await model.restart(system_prompt, tools, messages, **restart_kwargs)
|
|
return
|
|
|
|
await model.stop()
|
|
await model.start(system_prompt, tools, messages, **restart_kwargs)
|
|
|
|
async def _wait_for_model_task(self, task: asyncio.Task | None) -> None:
|
|
"""Await a superseded reader after its stream is closed; cancel only as a backstop.
|
|
|
|
The reader is expected to fall out of receive() when its connection is closed. It is
|
|
awaited (not force-cancelled) so a live provider read is never interrupted; the
|
|
bounded cancel handles a provider whose receive() does not unblock on close.
|
|
"""
|
|
if task is None or task.done():
|
|
return
|
|
try:
|
|
await asyncio.wait_for(task, timeout=_MODEL_RESTART_STOP_TIMEOUT_S)
|
|
except Exception as error:
|
|
# Expected: the reader's stream-close error, or the reap timeout. Logged, not
|
|
# forwarded — the current reader is the only one that surfaces errors to the consumer.
|
|
logger.debug("error=<%s> | superseded reader reaped", error)
|
|
|
|
@property
|
|
def _accumulated_input_tokens(self) -> int:
|
|
return self._baseline_input_tokens + self._current_input_tokens
|
|
|
|
@property
|
|
def _accumulated_output_tokens(self) -> int:
|
|
return self._baseline_output_tokens + self._current_output_tokens
|
|
|
|
@property
|
|
def _accumulated_total_tokens(self) -> int:
|
|
return self._baseline_total_tokens + self._current_total_tokens
|
|
|
|
@property
|
|
def _accumulated_cache_read_tokens(self) -> int:
|
|
return self._baseline_cache_read_tokens + self._current_cache_read_tokens
|
|
|
|
def _reset_token_tracking(self) -> None:
|
|
"""Reset per-connection and baseline token tracking at session start."""
|
|
self._current_input_tokens = 0
|
|
self._current_output_tokens = 0
|
|
self._current_total_tokens = 0
|
|
self._current_cache_read_tokens = 0
|
|
self._baseline_input_tokens = 0
|
|
self._baseline_output_tokens = 0
|
|
self._baseline_total_tokens = 0
|
|
self._baseline_cache_read_tokens = 0
|
|
|
|
def _fold_token_baseline(self) -> None:
|
|
"""Fold the current connection's token totals into the baseline before reconnect."""
|
|
self._baseline_input_tokens += self._current_input_tokens
|
|
self._baseline_output_tokens += self._current_output_tokens
|
|
self._baseline_total_tokens += self._current_total_tokens
|
|
self._baseline_cache_read_tokens += self._current_cache_read_tokens
|
|
self._current_input_tokens = 0
|
|
self._current_output_tokens = 0
|
|
self._current_total_tokens = 0
|
|
self._current_cache_read_tokens = 0
|
|
|
|
def _record_usage(self, event: BidiUsageEvent) -> None:
|
|
"""Update the current connection's token counts from a usage event.
|
|
|
|
Cumulative providers report a running total (replace); delta providers report
|
|
per-response counts (add).
|
|
"""
|
|
cache_read = event.cache_read_input_tokens or 0
|
|
|
|
if getattr(self._agent.model, "usage_is_cumulative", False):
|
|
self._current_input_tokens = event.input_tokens
|
|
self._current_output_tokens = event.output_tokens
|
|
self._current_total_tokens = event.total_tokens
|
|
self._current_cache_read_tokens = cache_read
|
|
else:
|
|
self._current_input_tokens += event.input_tokens
|
|
self._current_output_tokens += event.output_tokens
|
|
self._current_total_tokens += event.total_tokens
|
|
self._current_cache_read_tokens += cache_read
|
|
|
|
async def _run_model(self, generation: int) -> None:
|
|
"""Task for running the model.
|
|
|
|
Events are streamed through the event queue. Once superseded by a reconnect
|
|
(``generation`` no longer current), the stream-close error and any further handling
|
|
are dropped, so a closed old connection cannot mutate the new connection's state.
|
|
"""
|
|
logger.debug("model task starting")
|
|
|
|
response_span: Span | None = None
|
|
response_start_time: float | None = None
|
|
time_to_first_audio_ms: int | None = None
|
|
model_error: Exception | None = None
|
|
transcripts: dict[str, Message] = {}
|
|
|
|
try:
|
|
async for event in self._agent.model.receive():
|
|
if generation != self._generation:
|
|
return
|
|
|
|
if isinstance(event, BidiResponseStartEvent):
|
|
if response_span:
|
|
_telemetry.end_response_span(
|
|
self._tracer,
|
|
response_span,
|
|
time_to_first_audio_ms=time_to_first_audio_ms,
|
|
)
|
|
response_span = _telemetry.start_response_span(
|
|
self._tracer, event.response_id, parent_span=self._session_span
|
|
)
|
|
response_start_time = time.perf_counter()
|
|
time_to_first_audio_ms = None
|
|
self._response_active = True
|
|
self._awaiting_response = False
|
|
self._update_turn_state()
|
|
|
|
elif isinstance(event, BidiTranscriptStartEvent):
|
|
if event.role == "user":
|
|
self._awaiting_response = True
|
|
self._update_turn_state()
|
|
|
|
message: Message = {
|
|
"role": event.role,
|
|
"content": [],
|
|
"metadata": {"custom": {"bidi": BidiTranscriptMetadata(kind="transcript", status="pending")}},
|
|
}
|
|
await self._agent._append_messages(message)
|
|
transcripts[event.content_id] = message
|
|
|
|
elif isinstance(event, BidiAudioDeltaEvent):
|
|
if response_start_time is not None and time_to_first_audio_ms is None:
|
|
time_to_first_audio_ms = int((time.perf_counter() - response_start_time) * 1000)
|
|
|
|
elif isinstance(event, BidiTranscriptStopEvent):
|
|
message = transcripts.pop(event.content_id)
|
|
await self._agent._update_message(
|
|
{
|
|
**message,
|
|
"content": [{"text": event.transcript}],
|
|
"metadata": {
|
|
"custom": {"bidi": BidiTranscriptMetadata(kind="transcript", status="complete")}
|
|
},
|
|
}
|
|
)
|
|
|
|
elif isinstance(event, ToolUseStreamEvent):
|
|
tool_use = event["current_tool_use"]
|
|
dispatch: ToolResult = {
|
|
"toolUseId": tool_use["toolUseId"],
|
|
"status": "success",
|
|
"content": [{"text": "Tool call started. Its result will follow in a separate tool exchange."}],
|
|
}
|
|
await self._agent._append_messages(
|
|
{"role": "assistant", "content": [{"toolUse": tool_use}]},
|
|
{
|
|
"role": "user",
|
|
"content": [{"toolResult": dispatch}],
|
|
"metadata": {
|
|
"custom": {
|
|
"bidi": BidiToolMetadata(kind="tool_dispatch", tool_use_id=tool_use["toolUseId"])
|
|
}
|
|
},
|
|
},
|
|
)
|
|
|
|
elif isinstance(event, BidiBargeInEvent):
|
|
if self._session_span:
|
|
_telemetry.add_barge_in_event(self._session_span, event["reason"])
|
|
|
|
# A barge-in ends the current response; the user's next turn owes a reply.
|
|
self._response_active = False
|
|
self._update_turn_state()
|
|
await self._agent.hooks.invoke_callbacks_async(BidiBargeInHookEvent(self._agent, event["reason"]))
|
|
|
|
elif isinstance(event, BidiResponseStopEvent):
|
|
if response_span:
|
|
_telemetry.end_response_span(
|
|
self._tracer,
|
|
response_span,
|
|
time_to_first_audio_ms=time_to_first_audio_ms,
|
|
)
|
|
response_span = None
|
|
self._response_active = False
|
|
# A completed reply satisfies the user turn: clear the latch so a lagging user
|
|
# transcript arriving after completion does not re-open the turn.
|
|
self._awaiting_response = False
|
|
self._update_turn_state()
|
|
await self._agent.hooks.invoke_callbacks_async(
|
|
BidiResponseStopHookEvent(self._agent, event.response_id)
|
|
)
|
|
|
|
elif isinstance(event, BidiUsageEvent):
|
|
self._record_usage(event)
|
|
|
|
if generation != self._generation:
|
|
return
|
|
await self._event_queue.put(event)
|
|
if generation != self._generation:
|
|
return
|
|
|
|
if isinstance(event, ToolUseStreamEvent):
|
|
self._task_pool.create(self._run_tool(event["current_tool_use"], generation))
|
|
|
|
except Exception as error:
|
|
model_error = error
|
|
# Tag with this reader's generation so receive() drops it if superseded. The put can
|
|
# suspend on a full queue across a swap, which the pre-put check alone can't fence.
|
|
if generation == self._generation:
|
|
await self._event_queue.put(_ReaderError(generation, error))
|
|
finally:
|
|
for message in transcripts.values():
|
|
await self._agent._update_message(
|
|
{
|
|
**message,
|
|
"content": [{"text": "[Transcript unavailable.]"}],
|
|
"metadata": {
|
|
"custom": {"bidi": BidiTranscriptMetadata(kind="transcript", status="incomplete")}
|
|
},
|
|
},
|
|
strict=False,
|
|
)
|
|
if response_span:
|
|
_telemetry.end_response_span(
|
|
self._tracer,
|
|
response_span,
|
|
time_to_first_audio_ms=time_to_first_audio_ms,
|
|
error=model_error,
|
|
)
|
|
response_span = None
|
|
|
|
async def _run_tool(self, tool_use: ToolUse, generation: int) -> None:
|
|
"""Task for running tool requested by the model using the tool executor.
|
|
|
|
Args:
|
|
tool_use: Tool use request from model.
|
|
generation: Connection generation that issued the tool use. If a reconnect
|
|
advances the generation before the tool finishes, the result is recorded
|
|
in history but not sent, since the new connection never issued this
|
|
tool_use_id and would reject the result.
|
|
"""
|
|
logger.debug("tool_name=<%s> | tool execution starting", tool_use["name"])
|
|
|
|
tool_results: list[ToolResult] = []
|
|
|
|
# Ensure request_state exists for tools like strands_tools.stop
|
|
invocation_state = self._invocation_state
|
|
if "request_state" not in invocation_state:
|
|
invocation_state["request_state"] = {}
|
|
|
|
tool_call_span = self._tracer.start_tool_call_span(tool_use, parent_span=self._session_span)
|
|
tool_result: ToolResult | None = None
|
|
tool_error: Exception | None = None
|
|
|
|
try:
|
|
tool_events = self._agent.tool_executor._stream(
|
|
self._agent,
|
|
tool_use,
|
|
tool_results,
|
|
invocation_state,
|
|
structured_output_context=None,
|
|
)
|
|
|
|
async for tool_event in tool_events:
|
|
if isinstance(tool_event, ToolInterruptEvent):
|
|
self._agent._interrupt_state.deactivate()
|
|
interrupt_names = [interrupt.name for interrupt in tool_event.interrupts]
|
|
raise RuntimeError(f"interrupts={interrupt_names} | tool interrupts are not supported in bidi")
|
|
|
|
await self._event_queue.put(tool_event)
|
|
|
|
# Normal flow for all tools (including stop_conversation)
|
|
tool_result_event = cast(ToolResultEvent, tool_event)
|
|
tool_result = tool_result_event.tool_result
|
|
|
|
tool_use_message: Message = {
|
|
"role": "assistant",
|
|
"content": [{"toolUse": tool_use}],
|
|
}
|
|
tool_result_message: Message = {
|
|
"role": "user",
|
|
"content": [{"toolResult": tool_result}],
|
|
"metadata": {
|
|
"custom": {"bidi": BidiToolMetadata(kind="tool_result", tool_use_id=tool_use["toolUseId"])}
|
|
},
|
|
}
|
|
await self._agent._append_messages(tool_use_message, tool_result_message)
|
|
|
|
await self._event_queue.put(ToolResultMessageEvent(tool_result_message))
|
|
|
|
# Check for stop_event_loop flag (set by strands_tools.stop, stop_conversation, or any custom tool)
|
|
request_state = invocation_state.get("request_state", {})
|
|
should_stop = request_state.get("stop_event_loop", False)
|
|
|
|
# Backward compatibility: also check for stop_conversation by name (deprecated)
|
|
if not should_stop and tool_use["name"] == "stop_conversation":
|
|
warnings.warn(
|
|
"Stopping the event loop by tool name 'stop_conversation' is deprecated. "
|
|
"Use request_state['stop_event_loop'] = True instead.",
|
|
DeprecationWarning,
|
|
stacklevel=2,
|
|
)
|
|
should_stop = True
|
|
|
|
if should_stop:
|
|
logger.info("stop_event_loop=<True> | stopping conversation")
|
|
connection_id = getattr(self._agent.model, "_connection_id", "unknown")
|
|
await self._event_queue.put(BidiConnectionStopEvent(connection_id=connection_id, reason="user_request"))
|
|
return # Skip sending result to model
|
|
|
|
# Wait out any in-flight reconnect (send() gates on the swap), then re-check: a tool
|
|
# that finished across a swap must not send its result to the new connection, which
|
|
# never issued this tool_use_id and would reject it. The exchange is already recorded
|
|
# in messages above for the provider's reconnect replay.
|
|
await self._send_gate.wait()
|
|
if generation != self._generation:
|
|
logger.warning(
|
|
"tool_use_id=<%s> | tool completed across reconnect | result recorded, not sent to new connection",
|
|
tool_use["toolUseId"],
|
|
)
|
|
return
|
|
|
|
# Send result to model
|
|
await self.send(
|
|
ToolResultBlock(
|
|
tool_use_id=tool_result["toolUseId"],
|
|
status=tool_result["status"],
|
|
content=tool_result["content"],
|
|
)
|
|
)
|
|
|
|
except Exception as error:
|
|
tool_error = error
|
|
await self._event_queue.put(error)
|
|
finally:
|
|
# Single end site ensures the span is closed even on cancellation.
|
|
self._tracer.end_tool_call_span(tool_call_span, tool_result=tool_result, error=tool_error)
|