simbiot-harness/venv/lib/python3.12/site-packages/strands/interrupt.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

240 lines
9.1 KiB
Python

"""Human-in-the-loop interrupt system for agent workflows."""
from dataclasses import asdict, dataclass, field
from typing import TYPE_CHECKING, Any, cast
if TYPE_CHECKING:
from .types.agent import AgentInput
from .types.content import Message
from .types.interrupt import InterruptResponseContent
from .types.tools import ToolResult
_AGENT_STREAM_INTERRUPT_ID_PREFIX = "v1:middleware_agent_stream:"
"""Id prefix for interrupts scoped to a whole invocation pass rather than a single tool call."""
@dataclass
class Interrupt:
"""Represents an interrupt that can pause agent execution for human-in-the-loop workflows.
Attributes:
id: Unique identifier.
name: User defined name.
reason: User provided reason for raising the interrupt.
response: Human response provided when resuming the agent after an interrupt.
"""
id: str
name: str
reason: Any = None
response: Any = None
def to_dict(self) -> dict[str, Any]:
"""Serialize to dict for session management."""
return asdict(self)
class InterruptException(Exception):
"""Exception raised when human input is required."""
def __init__(self, interrupt: Interrupt) -> None:
"""Set the interrupt."""
self.interrupt = interrupt
@dataclass
class PendingToolExecution:
"""State required to resume tool execution without calling the model again.
Attributes:
assistant_message: Assistant message containing the pending tool uses.
completed_tool_results: Results completed or synthesized during the interrupted execution.
"""
assistant_message: "Message"
completed_tool_results: list["ToolResult"]
@dataclass
class _InterruptState:
"""Track the state of interrupt events raised by the user.
Note, unanswered interrupts are cleared after resuming; an answered invocation-scoped response
is retained for the rest of its interrupt cycle.
Attributes:
interrupts: Interrupts raised by the user. May be non-empty even when ``activated`` is
False because retained responses persist until their cycle ends.
context: Additional context associated with an interrupt event.
activated: True if agent is in an interrupt state, False otherwise.
pending_tool_execution: State required to resume an interrupted tool execution.
"""
interrupts: dict[str, Interrupt] = field(default_factory=dict)
context: dict[str, Any] = field(default_factory=dict)
activated: bool = False
pending_tool_execution: PendingToolExecution | None = None
_version: int = field(default=0, compare=False, repr=False)
def activate(self) -> None:
"""Activate the interrupt state."""
self.activated = True
self._version += 1
def deactivate(self) -> None:
"""Deactivate the interrupt state.
Interrupts, context, and pending tool execution are cleared.
"""
self.interrupts = {}
self.context = {}
self.activated = False
self.pending_tool_execution = None
self._version += 1
def end_tool_cycle(self) -> None:
"""Clear a completed tool cycle's state, keeping answered invocation-scoped responses."""
self.interrupts = {
interrupt_id: interrupt
for interrupt_id, interrupt in self.interrupts.items()
if interrupt_id.startswith(_AGENT_STREAM_INTERRUPT_ID_PREFIX) and interrupt.response is not None
}
self.context = {}
self.activated = False
self.pending_tool_execution = None
self._version += 1
def end_interrupt_cycle(self) -> None:
"""Release invocation-scoped interrupts once their interrupt cycle is over."""
remaining = {
interrupt_id: interrupt
for interrupt_id, interrupt in self.interrupts.items()
if not interrupt_id.startswith(_AGENT_STREAM_INTERRUPT_ID_PREFIX)
}
if remaining == self.interrupts:
return
self.interrupts = remaining
self._version += 1
def resume(self, prompt: "AgentInput") -> None:
"""Configure the interrupt state if resuming from an interrupt event.
Args:
prompt: User responses if resuming from interrupt.
Raises:
TypeError: If in interrupt state but user did not provide responses.
"""
if not self.activated:
return
if not isinstance(prompt, list):
raise TypeError(f"prompt_type={type(prompt)} | must resume from interrupt with list of interruptResponse's")
invalid_types = [
content_type for content in prompt for content_type in content if content_type != "interruptResponse"
]
if invalid_types:
raise TypeError(
f"content_types=<{invalid_types}> | must resume from interrupt with list of interruptResponse's"
)
contents = cast(list["InterruptResponseContent"], prompt)
for content in contents:
interrupt_id = content["interruptResponse"]["interruptId"]
interrupt_response = content["interruptResponse"]["response"]
if interrupt_id not in self.interrupts:
raise KeyError(f"interrupt_id=<{interrupt_id}> | no interrupt found")
self.interrupts[interrupt_id].response = interrupt_response
self.context["responses"] = contents
self._version += 1
def set_pending_tool_results(self, completed_tool_results: list["ToolResult"]) -> None:
"""Update completed results for a pending tool execution."""
if self.pending_tool_execution is None:
return
self.pending_tool_execution.completed_tool_results = completed_tool_results
self._version += 1
def _get_version(self) -> int:
"""Get the current version number of the interrupt state.
The version is incremented each time the state is mutated — activate(), deactivate(),
resume(), set_pending_tool_results(), end_tool_cycle(), or end_interrupt_cycle().
Consumers can compare versions to detect changes without requiring
explicit dirty flag clearing.
Returns:
The current version number.
"""
return self._version
def to_dict(self) -> dict[str, Any]:
"""Serialize to dict for session management.
Exclude deactivated invocation-scoped responses — persisting them would
give a restored agent a standing approval.
"""
interrupts = self.interrupts
if not self.activated:
interrupts = {
interrupt_id: interrupt
for interrupt_id, interrupt in interrupts.items()
if not interrupt_id.startswith(_AGENT_STREAM_INTERRUPT_ID_PREFIX)
}
data: dict[str, Any] = {
"interrupts": {k: v.to_dict() for k, v in interrupts.items()},
"context": self.context,
"activated": self.activated,
}
if self.pending_tool_execution is not None:
data["pending_tool_execution"] = asdict(self.pending_tool_execution)
return data
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "_InterruptState":
"""Initialize interrupt state from serialized interrupt state.
Interrupt state can be serialized with the `to_dict` method. Legacy tool execution context
is migrated into the typed pending state.
"""
activated = data["activated"]
context: dict[str, Any] = data["context"].copy()
pending_tool_execution_data = data.get("pending_tool_execution")
legacy_tool_use_message = context.pop("tool_use_message", None)
legacy_tool_results = context.pop("tool_results", []) if legacy_tool_use_message is not None else []
pending_tool_execution: PendingToolExecution | None
if pending_tool_execution_data is not None:
try:
pending_tool_execution = PendingToolExecution(**pending_tool_execution_data)
except TypeError as error:
# Avoid importing the types package while this module is still initializing.
from .types.exceptions import SessionException
raise SessionException(f"Failed to restore pending tool execution state: {error}") from error
elif legacy_tool_use_message is not None:
pending_tool_execution = PendingToolExecution(
assistant_message=cast("Message", legacy_tool_use_message),
completed_tool_results=cast(list["ToolResult"], legacy_tool_results),
)
else:
pending_tool_execution = None
return cls(
interrupts={
interrupt_id: Interrupt(**interrupt_data)
for interrupt_id, interrupt_data in data["interrupts"].items()
# Mirror to_dict's filter — don't revive a stale response as a standing approval.
if activated or not interrupt_id.startswith(_AGENT_STREAM_INTERRUPT_ID_PREFIX)
},
context=context,
activated=activated,
pending_tool_execution=pending_tool_execution,
)