simbiot-harness/venv/lib/python3.12/site-packages/strands/agent/_continuation.py
Simbiot Harness b60439152b Simbiot Harness v0.1: тактовая лестница на Strands + CQRS/ES-спина
- 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 первым)
2026-09-29 15:28:50 +00:00

224 lines
7.9 KiB
Python

"""Internal continuation handling for agent invocations.
Coordinates continuation inputs contributed by hooks, including preparation,
interrupt deferral, input combination, and append or abandonment callbacks.
"""
import inspect
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import cast
from ..hooks.events import AfterInvocationEvent, BeforeModelCallEvent
from ..types.agent import AgentInput
from ..types.content import Messages
logger = logging.getLogger(__name__)
_ContinuationEvent = AfterInvocationEvent | BeforeModelCallEvent
_STATE_ATTRIBUTE = "_continuation_state"
_DEFERRED_INPUTS_ATTRIBUTE = "_deferred_continuation_inputs"
@dataclass(frozen=True)
class _ContinuationInput:
"""One internal input contribution to an agent or model invocation.
When both callbacks are supplied, exactly one runs once. Callback failures
are logged and do not change the agent result.
Attributes:
args: Input that normalizes to one or more complete messages.
on_appended: Runs after the input is incorporated into agent history.
on_abandoned: Runs when the input cannot be incorporated into agent history.
"""
args: AgentInput
on_appended: Callable[[], Awaitable[None] | None] | None = None
on_abandoned: Callable[[object], Awaitable[None] | None] | None = None
@dataclass
class _ContinuationState:
inputs: list[_ContinuationInput] = field(default_factory=list)
messages: Messages | None = None
def add_input(event: _ContinuationEvent, input_: _ContinuationInput) -> None:
"""Add an input contribution for an invocation event.
Args:
event: Event that owns the continuation input.
input_: Input and optional settlement callbacks to register.
"""
state = _get_state(event) or _ContinuationState()
state.inputs.append(input_)
_set_state(event, state)
async def prepare(
event: _ContinuationEvent,
normalize_input: Callable[[AgentInput], Awaitable[Messages]],
stop_reason: str | None = None,
) -> Messages | None:
"""Normalize registered inputs and prepare complete message sequences.
Args:
event: Event whose continuation inputs should be prepared.
normalize_input: Converts invocation input into messages.
stop_reason: Stop reason that determines whether inputs continue now or are deferred.
Returns:
The prepared messages, or ``None`` when no continuation is ready.
"""
if stop_reason == "interrupt":
inputs = _consume_inputs(event)
if inputs:
stored_inputs = cast(list[_ContinuationInput], getattr(event.agent, _DEFERRED_INPUTS_ATTRIBUTE, []))
setattr(event.agent, _DEFERRED_INPUTS_ATTRIBUTE, [*stored_inputs, *inputs])
return None
if stop_reason is not None and stop_reason not in ("end_turn", "stop_sequence"):
return None
deferred_inputs: list[_ContinuationInput] = []
if isinstance(event, AfterInvocationEvent):
deferred_inputs = cast(
list[_ContinuationInput],
getattr(event.agent, _DEFERRED_INPUTS_ATTRIBUTE, []),
)
if hasattr(event.agent, _DEFERRED_INPUTS_ATTRIBUTE):
delattr(event.agent, _DEFERRED_INPUTS_ATTRIBUTE)
inputs = [*deferred_inputs, *_consume_inputs(event)]
accepted_inputs: list[_ContinuationInput] = []
messages: Messages = []
preparing_state = _ContinuationState(inputs=inputs)
_set_state(event, preparing_state)
for index, input_ in enumerate(inputs):
try:
normalized = await normalize_input(input_.args)
if not _is_complete_message_input(normalized):
raise TypeError("Continuation input must contain a complete message sequence")
messages.extend(normalized)
accepted_inputs.append(input_)
except Exception as error:
preparing_state.inputs = [*accepted_inputs, *inputs[index + 1 :]]
await _notify_abandoned(input_, error)
if not accepted_inputs:
_clear_state(event)
return None
_set_state(event, _ContinuationState(inputs=accepted_inputs, messages=messages))
return messages
def combine(event: _ContinuationEvent | None, messages: Messages) -> Messages:
"""Prepend prepared continuation messages to invocation messages.
Args:
event: Event whose prepared messages should be applied.
messages: Invocation messages to combine with the prepared messages.
Returns:
The combined messages, or the original messages when no continuation is ready.
"""
state = _get_state(event) if event else None
if state is None or state.messages is None:
return messages
return [*state.messages, *messages]
async def mark_appended(event: _ContinuationEvent | None) -> None:
"""Mark prepared inputs as incorporated into agent history.
Args:
event: Event whose prepared inputs were appended.
"""
if event is None:
return
state = _get_state(event)
if state is None or state.messages is None:
return
for input_ in _consume_inputs(event):
try:
if input_.on_appended is not None:
result = input_.on_appended()
if inspect.isawaitable(result):
await result
except Exception as error:
logger.warning("error=<%s> | continuation append callback failed", error)
async def abandon(event: _ContinuationEvent | None, reason: object) -> None:
"""Abandon the inputs registered for an event.
Args:
event: Event whose inputs should be abandoned.
reason: Reason the inputs could not be incorporated.
"""
if event is None:
return
for input_ in _consume_inputs(event):
await _notify_abandoned(input_, reason)
# Hook events are unhashable and write-guarded, so continuation state uses private object attributes.
def _get_state(event: _ContinuationEvent) -> _ContinuationState | None:
return getattr(event, _STATE_ATTRIBUTE, None)
def _clear_state(event: _ContinuationEvent) -> None:
if hasattr(event, _STATE_ATTRIBUTE):
object.__delattr__(event, _STATE_ATTRIBUTE)
def _set_state(event: _ContinuationEvent, state: _ContinuationState) -> None:
object.__setattr__(event, _STATE_ATTRIBUTE, state)
def _consume_inputs(event: _ContinuationEvent) -> list[_ContinuationInput]:
state = _get_state(event)
_clear_state(event)
return state.inputs if state else []
def _is_complete_message_input(messages: Messages) -> bool:
if not messages or messages[-1]["role"] != "user":
return False
pending_tool_use_ids: set[str] = set()
for message in messages:
if pending_tool_use_ids and message["role"] != "user":
return False
next_tool_use_ids: set[str] = set()
for block in message["content"]:
if "toolUse" in block:
tool_use_id = block["toolUse"]["toolUseId"]
if message["role"] != "assistant" or tool_use_id in next_tool_use_ids:
return False
next_tool_use_ids.add(tool_use_id)
elif "toolResult" in block:
tool_use_id = block["toolResult"]["toolUseId"]
if message["role"] != "user" or tool_use_id not in pending_tool_use_ids:
return False
pending_tool_use_ids.remove(tool_use_id)
if message["role"] == "user" and pending_tool_use_ids:
return False
pending_tool_use_ids = next_tool_use_ids
return not pending_tool_use_ids
async def _notify_abandoned(input_: _ContinuationInput, reason: object) -> None:
try:
if input_.on_abandoned is not None:
result = input_.on_abandoned(reason)
if inspect.isawaitable(result):
await result
except Exception as error:
logger.warning("error=<%s> | continuation abandon callback failed", error)