- 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 первым)
240 lines
9.1 KiB
Python
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,
|
|
)
|