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

405 lines
15 KiB
Python

"""Iterative-refinement plugin for Strands agents.
Validates the agent's response after each invocation; if it doesn't satisfy
the goal, feeds validator feedback back as a user message and re-enters the
agent loop via ``AfterInvocationEvent.resume``. Loops until validation passes,
``max_attempts`` is reached, or ``timeout`` elapses.
Example:
```python
from strands import Agent
from strands.vended_plugins.goal import GoalLoop
# Natural-language goal — judged by an internal Agent built from the host's model.
concise = GoalLoop(
goal="At most 3 sentences, accessible to a 10-year-old, no jargon.",
max_attempts=3,
)
agent = Agent(plugins=[concise])
agent("Explain how rainbows form.")
print(concise.last_result(agent))
```
Example:
```python
# Programmatic validator — pass a callable as `goal` to run your own check.
def word_count_validator(response, agent):
text = " ".join(
block["text"] for block in response["content"] if "text" in block
)
words = len(text.split())
if words <= 50:
return True
return {"passed": False, "feedback": f"Too long ({words} words). Cap at 50."}
word_count = GoalLoop(goal=word_count_validator, max_attempts=5, timeout=30.0)
```
"""
from __future__ import annotations
import inspect
import logging
import time
import warnings
import weakref
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Literal, Protocol, runtime_checkable
from ...hooks.events import AfterInvocationEvent, BeforeInvocationEvent, BeforeModelCallEvent
from ...plugins import Plugin
from ...types._snapshot import Snapshot
from ...types.content import ContentBlock, Message, Messages
from .judge import JUDGE_SYSTEM_PROMPT, JudgeOutcome, build_judge_prompt
if TYPE_CHECKING:
from ...agent.agent import Agent
from ...models.model import Model
logger = logging.getLogger(__name__)
@dataclass
class ValidationOutcome:
"""Outcome a validator returns."""
passed: bool
feedback: str | None = None
ValidatorReturn = bool | ValidationOutcome | dict[str, Any]
"""Return type for programmatic validators.
Booleans are shorthand: True -> pass, False -> fail with no feedback. Use
the dict form ``{"passed": bool, "feedback": str}`` or ``ValidationOutcome``
when you have actionable feedback for the next attempt.
"""
@runtime_checkable
class Validator(Protocol):
"""Programmatic validator callable.
Must return True, False, a ValidationOutcome, or a dict with ``passed`` and
optional ``feedback`` keys. May be sync or async.
The second argument is the host agent — read ``agent.messages`` for the full
transcript (the same view the built-in NL judge sees), or any other state
the validator needs.
"""
def __call__(self, response: Message, agent: Agent, **kwargs: Any) -> ValidatorReturn | Awaitable[ValidatorReturn]:
"""Validate the agent's response."""
...
GoalStopReason = Literal["satisfied", "max_attempts", "timeout"]
"""Why a goal run ended."""
@dataclass
class GoalAttempt:
"""Single attempt summary preserved on GoalResult."""
attempt: int
passed: bool
feedback: str | None = None
@dataclass
class GoalResult:
"""Aggregate result of a goal run, exposed via ``GoalLoop.last_result``."""
passed: bool
stop_reason: GoalStopReason
attempts: list[GoalAttempt] = field(default_factory=list)
@dataclass
class JudgeConfig:
"""Tuning for the auto-built judge used when ``goal`` is a natural-language string.
Harmlessly ignored when ``goal`` is a validator function — no judge is built in that case.
Attributes:
model: Model the judge agent uses. Defaults to the host agent's model.
system_prompt: System prompt for the judge agent. Defaults to JUDGE_SYSTEM_PROMPT.
"""
model: Model | None = None
system_prompt: str = JUDGE_SYSTEM_PROMPT
@dataclass
class _RunState:
"""Single source of truth for an in-progress or just-finished goal run."""
start_time: float
attempts: list[GoalAttempt] = field(default_factory=list)
result: GoalResult | None = None
resumed: bool = False
initial_snapshot: Snapshot | None = None
# Two GoalLoops on one agent both write event.resume in AfterInvocation — only the
# last callback's value survives, silently breaking the other's retry loop. Guard here.
_agents_with_goal_loop: weakref.WeakSet[Agent] = weakref.WeakSet()
def _default_resume_prompt(feedback: str | None) -> str:
"""Default template for the user message fed to the agent before each retry."""
if not feedback:
return (
"Your previous attempt did not satisfy the goal. Produce a new, corrected "
"response that fully satisfies it — do not restate or lightly edit the previous attempt."
)
return (
"Your previous attempt did not satisfy the goal.\n\n"
f"Feedback on what was wrong:\n{feedback}\n\n"
"Address every point above and produce a new, corrected response that fully satisfies "
"the goal. Do not restate or lightly edit the previous attempt — fix the specific problems called out."
)
def _last_assistant_message(messages: Messages) -> Message | None:
"""Find the last assistant message, or None if the model never replied."""
for message in reversed(messages):
if message["role"] == "assistant":
return message
return None
def _normalize_validator_return(result: ValidatorReturn) -> ValidationOutcome:
"""Coerce the various validator return shapes into a canonical ValidationOutcome."""
if isinstance(result, bool):
return ValidationOutcome(passed=result)
if isinstance(result, ValidationOutcome):
return result
# Must be dict — the only remaining type in the union
return ValidationOutcome(
passed=result.get("passed", False),
feedback=result.get("feedback"),
)
def _finish_run(run: _RunState, stop_reason: GoalStopReason) -> None:
"""Mark a run as complete."""
run.result = GoalResult(
passed=stop_reason == "satisfied",
stop_reason=stop_reason,
attempts=list(run.attempts),
)
run.resumed = False
class GoalLoop(Plugin):
"""Iterative-refinement plugin.
A single GoalLoop instance can be attached to multiple Agents; per-agent run
state is keyed off the agent, so concurrent runs on different agents don't
interfere. Only one GoalLoop is supported per individual agent.
Args:
goal: What "done" means for this loop. Either a natural-language goal (str)
judged by an internal Agent, or a programmatic validator callable.
judge: Tuning for the auto-built judge (ignored when goal is a callable).
max_attempts: Maximum number of attempts. Defaults to infinity.
timeout: Wall-clock budget for the whole run, in seconds. Defaults to infinity.
name: Plugin name. Defaults to 'strands:goal-loop'.
preserve_context: Whether to preserve conversation history across retries.
When True (default), the agent sees its own prior responses and feedback.
When False, each failed attempt restores the agent's session state to what
it was immediately before the first model call.
resume_prompt_template: Builds the user message fed before each retry.
Receives the trimmed validator feedback (or None). Override to localize
or retune the framing.
"""
_name: str
def __init__(
self,
goal: str | Validator,
*,
judge: JudgeConfig | None = None,
max_attempts: int | float = float("inf"),
timeout: float = float("inf"),
name: str = "strands:goal-loop",
preserve_context: bool = True,
resume_prompt_template: Callable[[str | None], str | list[ContentBlock]] | None = None,
) -> None:
"""Initialize the GoalLoop plugin.
Args:
goal: Natural-language goal string or programmatic validator callable.
judge: Tuning for the auto-built NL judge. Ignored when goal is callable.
max_attempts: Maximum number of attempts before stopping.
timeout: Wall-clock budget in seconds for the entire run.
name: Plugin name. Must be unique per agent.
preserve_context: Whether to keep conversation history across retries.
resume_prompt_template: Custom template for the retry user message.
"""
if goal is None:
raise ValueError("GoalLoop: `goal` is required (a natural-language string or a validator function)")
if max_attempts < 1:
raise ValueError(f"max_attempts=<{max_attempts}> | must be at least 1")
if timeout <= 0:
raise ValueError(f"timeout=<{timeout}> | must be positive")
self._name = name
if isinstance(goal, str):
self._goal: str | None = goal
self._validator: Validator | None = None
else:
self._goal = None
self._validator = goal
judge_config = judge or JudgeConfig()
self._judge_model = judge_config.model
self._judge_system_prompt = judge_config.system_prompt
self._max_attempts = max_attempts
self._timeout = timeout
self._preserve_context = preserve_context
self._resume_prompt_template = resume_prompt_template or _default_resume_prompt
# Per-agent run state — keyed by agent so one plugin instance can serve many.
# WeakKeyDictionary lets agents GC normally when the caller drops them.
self._runs: weakref.WeakKeyDictionary[Agent, _RunState] = weakref.WeakKeyDictionary()
if self._max_attempts == float("inf") and self._timeout == float("inf"):
warnings.warn(
f"{self._name} has no max_attempts or timeout; execution is unbounded",
stacklevel=2,
)
super().__init__()
@property
def name(self) -> str:
"""Plugin name."""
return self._name
def last_result(self, agent: Agent) -> GoalResult | None:
"""Result of the most recent completed run on ``agent``.
Returns None if no run has finished on that agent since this plugin was
constructed, or if a run is still in-flight.
"""
run = self._runs.get(agent)
if run is None:
return None
return run.result
def init_agent(self, agent: Agent) -> None:
"""Register hooks on the agent. Called by the plugin registry."""
if agent in _agents_with_goal_loop:
raise RuntimeError(
f"{self._name}: another GoalLoop is already attached to this agent; "
"only one GoalLoop is supported per agent"
)
_agents_with_goal_loop.add(agent)
validator = self._build_validator(agent)
def _before_invocation(event: BeforeInvocationEvent) -> None:
existing = self._runs.get(event.agent)
if existing and existing.resumed:
existing.resumed = False
return
self._runs[event.agent] = _RunState(start_time=time.monotonic())
agent.add_hook(_before_invocation, BeforeInvocationEvent)
if not self._preserve_context:
def _before_model_call(event: BeforeModelCallEvent) -> None:
run = self._runs.get(event.agent)
if run and run.initial_snapshot is None:
# Python's "session" preset includes conversation_manager_state (no TS equivalent),
# but lacks system_prompt — add it explicitly to match TS snapshot parity.
run.initial_snapshot = event.agent.take_snapshot(
preset="session", include=["system_prompt"], exclude=["state"]
)
agent.add_hook(_before_model_call, BeforeModelCallEvent)
async def _after_invocation(event: AfterInvocationEvent) -> None:
run = self._runs.get(event.agent)
if not run:
return
elapsed = time.monotonic() - run.start_time
if elapsed >= self._timeout:
_finish_run(run, "timeout")
return
response = _last_assistant_message(event.agent.messages)
if not response:
return
attempt_number = len(run.attempts) + 1
try:
outcome = await validator(response)
except Exception as e:
logger.warning("plugin=<%s>, error=<%s> | validator threw", self._name, e)
outcome = ValidationOutcome(passed=False, feedback=f"Validator error: {e}")
run.attempts.append(
GoalAttempt(
attempt=attempt_number,
passed=outcome.passed,
feedback=outcome.feedback,
)
)
if outcome.passed:
_finish_run(run, "satisfied")
return
if attempt_number >= self._max_attempts:
_finish_run(run, "max_attempts")
return
if run.initial_snapshot:
event.agent.load_snapshot(run.initial_snapshot)
event.resume = self._resume_prompt_template(outcome.feedback.strip() if outcome.feedback else None)
run.resumed = True
agent.add_hook(_after_invocation, AfterInvocationEvent)
def _build_validator(self, host_agent: Agent) -> Callable[[Message], Awaitable[ValidationOutcome]]:
"""Compile the configured goal into the canonical async validator shape."""
if self._validator:
validator_fn = self._validator
async def _fn_validator(response: Message) -> ValidationOutcome:
result = validator_fn(response, host_agent)
if inspect.isawaitable(result):
result = await result
return _normalize_validator_return(result)
return _fn_validator
goal_description = self._goal
assert goal_description is not None
async def _judge_validator(_response: Message) -> ValidationOutcome:
from ...agent.agent import Agent as _Agent
judge = _Agent(
model=self._judge_model or host_agent.model,
callback_handler=None,
system_prompt=self._judge_system_prompt,
structured_output_model=JudgeOutcome,
)
result = await judge.invoke_async(build_judge_prompt(goal_description, host_agent.messages))
if result.structured_output and isinstance(result.structured_output, JudgeOutcome):
return ValidationOutcome(
passed=result.structured_output.passed,
feedback=result.structured_output.feedback,
)
return ValidationOutcome(passed=False, feedback="Judge produced no structured outcome.")
return _judge_validator