- 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 первым)
224 lines
7.9 KiB
Python
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)
|