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

361 lines
16 KiB
Python

"""Human-in-the-loop intervention handler.
Pauses agent execution before tool calls so a human can approve or deny them.
"""
import asyncio
import inspect
import json
import logging
import threading
from collections.abc import Awaitable
from typing import Any, Literal, Protocol, runtime_checkable
from ...hooks.events import BeforeToolCallEvent
from ...interventions.actions import Confirm, Deny, InterventionAction, Proceed, default_evaluate
from ...interventions.handler import InterventionHandler
from .classifier import ClassifierResult, HumanInTheLoopClassifier, LLMClassifierConfig, _create_llm_risk_classifier
logger = logging.getLogger(__name__)
_TRUST_RESPONSES = {"t", "trust"}
_TRUSTED_TOOLS_KEY = "hitl:trusted_tools"
@runtime_checkable
class AskCallback(Protocol):
"""Typed contract for an ``ask`` callback: prompt a human and return their response.
May be sync or async (an async impl lets the agent keep serving its event loop
while waiting). Documents the expected signature for type-checkers/IDEs; it is
intentionally not exported -- customers just pass a function.
"""
def __call__(self, prompt: str, **kwargs: Any) -> Any | Awaitable[Any]:
"""Prompt the human and return their response (directly or as an awaitable)."""
...
@runtime_checkable
class EvaluateCallback(Protocol):
"""Typed contract for an ``evaluate``/``evaluate_trust`` callback.
Decides whether a response approves (or trusts) a tool call. Documents the
expected signature for type-checkers/IDEs; intentionally not exported --
customers just pass a function.
"""
def __call__(self, response: Any, **kwargs: Any) -> bool:
"""Return whether the response approves (or trusts) the pending tool call."""
...
def _create_stdio_ask(include_trust: bool) -> AskCallback:
"""Build an ``ask`` callback that prompts the human on the terminal.
Each prompt is serialized behind a ``threading.Lock`` so concurrent tool calls
can't interleave their reads from a single stdin. The blocking ``input`` call is
offloaded to a worker thread so the event loop keeps running while waiting. A
plain ``threading.Lock`` (rather than ``asyncio.Lock``) is used deliberately: the
same handler instance may be reused across agent invocations that each spin up
their own event loop, and a threading lock is the only kind that spans them.
A worker thread blocked on ``input`` cannot be cancelled, so if the agent run is
cancelled or times out mid-prompt the thread stays parked until the user presses
enter and the process won't exit cleanly. ``ask="stdio"`` is therefore meant for
interactive CLI sessions, not for runs that may be cancelled out from under it.
Args:
include_trust: Show the trust option (``t``) in the prompt suffix when True.
Returns:
An async ``ask`` callback that reads a response from stdin.
"""
options = "(y/n/t)" if include_trust else "(y/n)"
lock = threading.Lock()
def _blocking_ask(prompt: str) -> str:
with lock:
return input(f"{prompt} {options}: ").strip()
async def ask(prompt: str, **kwargs: Any) -> Any:
return await asyncio.to_thread(_blocking_ask, prompt)
return ask
class HumanInTheLoop(InterventionHandler):
"""Human-in-the-loop intervention handler that pauses agent execution before tool calls.
By default, ALL tools require approval and the agent pauses via interrupt/resume.
Use ``allowed_tools`` to allow-list tools that run freely, and ``ask`` to provide
inline prompting (CLI, custom UI).
Example:
```python
from strands import Agent
from strands.vended_interventions.hitl import HumanInTheLoop
# All tools require approval, agent pauses via interrupt (default)
agent = Agent(interventions=[HumanInTheLoop()])
# read_file runs freely, everything else pauses for approval
agent = Agent(interventions=[HumanInTheLoop(allowed_tools=["read_file"])])
# LLM-driven risk classification — only risky calls get escalated
agent = Agent(interventions=[HumanInTheLoop(
allowed_tools=["read_file", "list_dir"],
classifier=True,
)])
# CLI mode - prompts in terminal inline
agent = Agent(interventions=[HumanInTheLoop(ask="stdio")])
# Custom UI - provide your own prompt function
async def slack_ask(prompt: str) -> str:
return await slack_dm(user_id, prompt)
agent = Agent(interventions=[HumanInTheLoop(ask=slack_ask)])
```
"""
name = "strands:human-in-the-loop"
def __init__(
self,
*,
allowed_tools: list[str] | None = None,
classifier: bool | LLMClassifierConfig | HumanInTheLoopClassifier | None = None,
enable_trust: bool = False,
evaluate_trust: EvaluateCallback | None = None,
evaluate: EvaluateCallback | None = None,
ask: AskCallback | Literal["stdio"] | None = None,
) -> None:
"""Initialize the handler.
Args:
allowed_tools: Tools that can execute WITHOUT human approval. All other
tools require approval. Use ``"*"`` to allow all tools. Prefix with
``!`` to exclude specific tools from ``"*"`` (they still require
approval). For example, ``["read_file", "list_dir"]`` lets only those
two run freely, while ``["*", "!delete_file"]`` lets everything run
freely except ``delete_file``.
classifier: Determines how approval decisions are made for tools not in
``allowed_tools``. Omitted/None: all non-allowed tools require approval.
``True``: uses the built-in LLM risk classifier with defaults.
``LLMClassifierConfig``: built-in LLM classifier with custom settings.
Custom callable: your own classification logic.
enable_trust: When True, trust responses approve the tool AND remember it
in ``agent.state`` for the rest of the session (won't ask again).
Works in both interrupt/resume and inline ``ask`` modes. Negated
tools (``!tool``) cannot be trusted. With a ``classifier``, trusting
a tool disables argument-level risk classification for that tool name
for the rest of the session. Defaults to False.
evaluate_trust: Custom trust response validator. Defaults to accepting
``"t"``/``"trust"`` (case-insensitive). When this returns True, the
tool is approved AND trusted for the session. Only evaluated when
``enable_trust`` is True.
evaluate: Custom approval response validator. Defaults to accepting
``True``, ``"y"``/``"yes"`` (case-insensitive).
ask: Controls how the human's response is collected. Omitted (default):
uses interrupt/resume - agent pauses, caller resumes with response.
``"stdio"``: prompts via CLI stdin. Agent blocks inline until the
human responds. Note that stdio mode runs a blocking ``input()`` in a
worker thread that cannot be cancelled, so it is intended for
interactive CLI use rather than runs that may be cancelled mid-prompt.
Custom callable: your own (optionally async) prompt logic (Slack, web
UI, etc.). Agent blocks inline. A custom ``ask`` should return a concrete
response; returning ``None`` (e.g. a dismissed dialog) is treated as an
explicit deny. If it raises, the exception propagates and aborts the run
(fail-closed) -- catch and return a deny value inside your callback if you
prefer to proceed on error.
Note:
``name`` is a fixed class attribute, so at most one ``HumanInTheLoop`` can be
registered per agent; layering two policies requires subclassing to rename.
"""
# A bare string is iterable, so ``set("read_file")`` would silently become a per-char set; reject it.
if isinstance(allowed_tools, str):
raise ValueError("allowed_tools must be a list of tool names, not a single string")
self._allowed_tools = set(allowed_tools or [])
self._classifier = self._resolve_classifier(classifier)
if self._classifier and "*" in self._allowed_tools:
logger.warning("classifier has no effect when allowed_tools contains '*' — all tools are already allowed")
self._classified_tool_use_ids: set[str] = set()
self._enable_trust = enable_trust
self._evaluate_trust = evaluate_trust if evaluate_trust is not None else self._is_trust_response
self._evaluate = evaluate if evaluate is not None else default_evaluate
self._ask = _create_stdio_ask(enable_trust) if ask == "stdio" else ask
# Implemented as async so the inline ``ask`` path can await a human's response.
# The base lifecycle methods are typed as ``_MaybeAwaitable[...]`` (since #2800),
# so an async override type-checks cleanly and the registry awaits it at runtime.
async def before_tool_call(self, event: BeforeToolCallEvent, **kwargs: Any) -> InterventionAction:
"""Request human approval before executing a tool that is not allow-listed or trusted.
Implemented as ``async`` so the inline ``ask`` path can await a human's
response (e.g. an HTTP round-trip to a Slack/web UI) without blocking the
agent's event loop. When no ``ask`` is configured this returns a ``Confirm``
that pauses the agent via interrupt instead.
Args:
event: The tool call event under evaluation.
**kwargs: Additional keyword arguments for future extensibility.
Returns:
Proceed if the tool is allow-listed, trusted, or approved inline;
otherwise a Confirm action (pausing via interrupt when no ``ask`` is set).
"""
tool_name = event.tool_use["name"]
classifier_result = await self._requires_approval(event)
if not classifier_result.requires_human_in_the_loop:
return Proceed()
reason = f" — {classifier_result.reason}" if classifier_result.reason else ""
input_str = json.dumps(event.tool_use["input"])
prompt = f'Approve "{tool_name}"{reason}?\n Input: {input_str}'
is_negated = f"!{tool_name}" in self._allowed_tools
# No ``ask`` configured: defer to interrupt/resume. The evaluate closure runs
# later when the caller resumes with a response, so trust must be recorded
# there rather than now.
if self._ask is None:
def evaluate(response: Any) -> bool:
if not is_negated and self._enable_trust and self._evaluate_trust(response):
self._trust_tool(event, tool_name)
return True
return self._evaluate(response)
return Confirm(prompt=prompt, evaluate=evaluate)
# Inline mode: collect the response now (awaiting async ``ask`` callbacks). A
# trust response short-circuits to Proceed; anything else is handed to the
# standard evaluator via Confirm so the normal approve/deny path applies.
response = self._ask(prompt)
if inspect.isawaitable(response):
response = await response
# A configured inline ``ask`` is expected to return a concrete response. ``None``
# (e.g. a dismissed dialog) is treated as a fail-closed deny rather than passed
# to ``Confirm``: ``Confirm`` reads ``response=None`` as "no preemptive value"
# and falls back to interrupt/resume, which a stateless inline caller (Slack/web)
# has no way to resume. Denying is the safe default for an approval gate.
if response is None:
return Deny(reason=f'Tool "{tool_name}" denied: approval callback returned no response.')
if not is_negated and self._enable_trust and self._evaluate_trust(response):
self._trust_tool(event, tool_name)
return Proceed()
return Confirm(prompt=prompt, response=response, evaluate=self._evaluate)
async def _requires_approval(self, event: BeforeToolCallEvent) -> ClassifierResult:
"""Decide whether the tool call needs human approval.
Precedence (first match wins):
1. Negated (``!tool``) -> always requires approval (cannot be trusted)
2. Trusted at runtime via trust response (stored in ``agent.state``) -> runs freely
3. Wildcard (``*``) -> runs freely
4. Explicitly listed -> runs freely
5. Classifier (if provided) -> its decision
6. Default (no classifier) -> requires approval
Args:
event: The tool call event under evaluation.
Returns:
ClassifierResult indicating whether approval is required.
"""
tool_name = event.tool_use["name"]
if f"!{tool_name}" in self._allowed_tools:
return ClassifierResult(requires_human_in_the_loop=True)
trusted = event.agent.state.get(_TRUSTED_TOOLS_KEY) or []
if tool_name in trusted:
return ClassifierResult(requires_human_in_the_loop=False)
if "*" in self._allowed_tools:
return ClassifierResult(requires_human_in_the_loop=False)
if tool_name in self._allowed_tools:
return ClassifierResult(requires_human_in_the_loop=False)
if self._classifier:
tool_use_id = event.tool_use["toolUseId"]
if tool_use_id in self._classified_tool_use_ids:
return ClassifierResult(requires_human_in_the_loop=True)
self._classified_tool_use_ids.add(tool_use_id)
try:
raw_result: Any = self._classifier(event)
if inspect.isawaitable(raw_result):
raw_result = await raw_result
if not isinstance(raw_result, ClassifierResult):
logger.warning(
"tool=<%s> | classifier returned malformed result, defaulting to approval required",
tool_name,
)
return ClassifierResult(requires_human_in_the_loop=True)
decision: Any = raw_result.requires_human_in_the_loop
if not isinstance(decision, bool):
logger.warning(
"tool=<%s> | classifier returned a non-boolean decision (%r), defaulting to approval required",
tool_name,
decision,
)
return ClassifierResult(requires_human_in_the_loop=True)
return raw_result
except Exception as error:
logger.warning(
"tool=<%s> | classifier failed, defaulting to approval required",
tool_name,
exc_info=error,
)
return ClassifierResult(requires_human_in_the_loop=True)
return ClassifierResult(requires_human_in_the_loop=True)
def _trust_tool(self, event: BeforeToolCallEvent, tool_name: str) -> None:
"""Remember a tool as trusted for the rest of the session.
Args:
event: The tool call event (provides access to the agent).
tool_name: Name of the tool to trust.
"""
trusted = event.agent.state.get(_TRUSTED_TOOLS_KEY) or []
if tool_name not in trusted:
event.agent.state.set(_TRUSTED_TOOLS_KEY, [*trusted, tool_name])
@staticmethod
def _is_trust_response(response: Any) -> bool:
"""Check whether a response is a trust response (``"t"``/``"trust"``, case-insensitive).
Args:
response: The human's response value.
Returns:
True if the response is a trust response.
"""
if isinstance(response, str):
return response.lower().strip() in _TRUST_RESPONSES
return False
@staticmethod
def _resolve_classifier(
classifier: bool | LLMClassifierConfig | HumanInTheLoopClassifier | None,
) -> HumanInTheLoopClassifier | None:
"""Resolve the classifier param into a callable or None.
Args:
classifier: The raw classifier param from the constructor.
Returns:
A classifier callable, or None if no classifier is configured.
"""
if classifier is None or classifier is False:
return None
if classifier is True:
return _create_llm_risk_classifier()
if isinstance(classifier, LLMClassifierConfig):
return _create_llm_risk_classifier(classifier)
return classifier