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

276 lines
10 KiB
Python

"""AgentDelegation plugin — enforces delegation semantics for tool routing.
When a tool is configured with ``delegate=True``, this plugin ensures:
1. The delegation tool is the only tool called in the turn (single-call constraint)
2. The agent loop exits immediately after a successful delegation (via end_turn)
3. The delegation tool's content blocks become the final assistant message
4. Streaming events from the delegate agent are surfaced natively in the parent stream
"""
from __future__ import annotations
import json as json_module
import logging
import weakref
from collections.abc import AsyncGenerator
from dataclasses import dataclass
from typing import TYPE_CHECKING, cast
from .._middleware.stages import ExecuteToolContext, ExecuteToolStage
from .._middleware.types import MiddlewareNext
from ..hooks import (
AfterToolCallEvent,
AfterToolsEvent,
BeforeModelCallEvent,
BeforeToolsEvent,
HookOrder,
)
from ..plugins import Plugin
from ..types._events import AgentAsToolStreamEvent, ToolResultEvent, TypedEvent
from ..types.content import ContentBlock
from ..types.tools import ToolResult
from ._agent_as_tool import _AgentAsTool
if TYPE_CHECKING:
from .agent import Agent
logger = logging.getLogger(__name__)
@dataclass
class _DelegationState:
"""Per-agent state tracked across the delegation lifecycle within a single invocation."""
tool_use_count: int = 0
"""Number of tool use blocks in the current batch."""
tool_use_id: str | None = None
"""Tool use ID of the delegation tool that succeeded."""
def _is_delegation_tool(agent: Agent, tool_name: str) -> bool:
"""Check whether a tool registered on the agent is a delegation AgentAsTool."""
tool = agent.tool_registry.registry.get(tool_name) or agent.tool_registry.dynamic_tools.get(tool_name)
return isinstance(tool, _AgentAsTool) and tool.delegate
def _to_content_blocks(tool_result: ToolResult) -> list[ContentBlock]:
"""Convert ToolResult content into ContentBlocks for an assistant message.
JSON blocks are serialized to text (matching TS's ``toContentBlocks``).
"""
raw_content = tool_result.get("content", [])
result: list[ContentBlock] = []
for block in raw_content:
if "json" in block:
result.append({"text": json_module.dumps(block["json"])})
else:
result.append(cast(ContentBlock, block))
return result
class AgentDelegation(Plugin):
"""Plugin that enforces delegation semantics for tool routing.
Automatically registered on every agent. Acts as a no-op when no delegation tools fire.
Implements single-call constraint, early loop exit, and result transformation.
Example:
```python
from strands import Agent
specialist = Agent(name="specialist", description="Handles billing")
orchestrator = Agent(
tools=[specialist.as_tool(delegate=True)],
# AgentDelegation is auto-registered — no manual setup needed
)
```
"""
name = "strands:agent-delegation"
def __init__(self) -> None:
super().__init__()
self._state: weakref.WeakKeyDictionary[Agent, _DelegationState] = weakref.WeakKeyDictionary()
def init_agent(self, agent: Agent) -> None:
"""Register hooks and middleware for delegation enforcement."""
# Delegation is incompatible with stateful models — early exit would leave
# an unclosed function call on the server.
if agent.model.stateful:
has_delegation_tool = any(
isinstance(tool, _AgentAsTool) and tool.delegate for tool in agent.tool_registry.registry.values()
)
if has_delegation_tool:
raise ValueError(
"Delegation tools (delegate=True) are not supported with stateful models. "
"Stateful models manage conversation state server-side, and delegation's early loop exit "
"would leave unclosed function calls on the server."
)
agent.add_hook(self._on_before_tools, BeforeToolsEvent)
agent.add_hook(self._on_after_tool_call, AfterToolCallEvent)
agent.add_hook(self._on_after_tools, AfterToolsEvent, order=HookOrder.SDK_LAST)
agent.add_hook(self._on_before_model_call, BeforeModelCallEvent)
agent._middleware_registry.add_middleware(ExecuteToolStage, self._handle_tool_execution)
# --- Hooks ---
def _on_before_tools(self, event: BeforeToolsEvent) -> None:
"""Initialize batch state; cancel if a delegate is mixed with other tools."""
agent = event.agent
if agent.model.stateful:
return
message = event.message
content = message.get("content", [])
tool_use_blocks = [block for block in content if isinstance(block, dict) and "toolUse" in block]
self._state[agent] = _DelegationState(tool_use_count=len(tool_use_blocks))
has_delegation = any(_is_delegation_tool(agent, block["toolUse"]["name"]) for block in tool_use_blocks)
if has_delegation and len(tool_use_blocks) > 1:
event.cancel = (
"This tool call was not executed. A delegation tool must be the only "
"tool called in a turn. Retry with a single delegation tool call or "
"use only non-delegation tools."
)
def _on_before_model_call(self, event: BeforeModelCallEvent) -> None:
"""Clear stale delegation state when the loop continues past a delegation batch."""
self._state.pop(event.agent, None)
def _on_after_tool_call(self, event: AfterToolCallEvent) -> None:
"""Mark tool_use_id on delegation success; clear on error or retry-swap."""
if event.agent.model.stateful:
return
if not isinstance(event.selected_tool, _AgentAsTool) or not event.selected_tool.delegate:
state = self._state.get(event.agent)
if state is not None and state.tool_use_id == event.tool_use.get("toolUseId"):
state.tool_use_id = None
return
state = self._state.get(event.agent)
if state is None:
return
if event.result.get("status") == "error":
state.tool_use_id = None
return
state.tool_use_id = event.tool_use.get("toolUseId")
def _on_after_tools(self, event: AfterToolsEvent) -> None:
"""Set end_turn to delegation content blocks when delegation succeeded with meaningful content.
This hook runs at ``HookOrder.SDK_LAST`` (100) so no hook can invalidate the tool
result after this hook commits end_turn; mutating a committed tool result's status
is not a supported pattern.
"""
if event.agent.model.stateful:
return
state = self._state.get(event.agent)
if state is None or not state.tool_use_id:
return
# Verify the tool result is still successful in the committed message.
message = event.message
content = message.get("content", [])
result_block = None
for block in content:
if not isinstance(block, dict) or "toolResult" not in block:
continue
tool_result = block["toolResult"]
if tool_result.get("toolUseId") == state.tool_use_id:
result_block = tool_result
break
if result_block is None or result_block.get("status") == "error":
state.tool_use_id = None
return
# Skip delegation when the parent expects structured output.
has_structured_output = any(
getattr(t, "tool_type", None) == "structured_output"
for t in event.agent.tool_registry.dynamic_tools.values()
)
if has_structured_output:
logger.debug(
"tool_use_id=<%s> | parent requires structured output, skipping delegation",
state.tool_use_id,
)
return
# Skip delegation when the tool result has no content
end_turn_content = _to_content_blocks(result_block)
if not end_turn_content or all(block.get("text", None) == "" for block in end_turn_content):
logger.debug(
"tool_use_id=<%s> | delegation produced blank content, skipping end_turn",
state.tool_use_id,
)
return
event.end_turn = end_turn_content
# --- Middleware ---
async def _handle_tool_execution(
self,
context: ExecuteToolContext,
next_fn: MiddlewareNext,
) -> AsyncGenerator[TypedEvent, None]:
"""ExecuteToolStage middleware: enforce delegation constraints and unwrap events."""
agent = cast("Agent", context.agent)
# Non-delegation tools pass through unchanged.
if not isinstance(context.tool, _AgentAsTool) or not context.tool.delegate:
async for event in next_fn(context):
yield event
return
# Stateful model: skip delegation, execute as a normal tool.
if agent.model.stateful:
logger.debug(
"tool_use_id=<%s> | stateful model, skipping delegation and running as a normal tool",
context.tool_use["toolUseId"],
)
async for event in next_fn(context):
yield event
return
# Enforce single-call constraint.
state = self._state.get(agent)
if state and state.tool_use_count > 1:
yield ToolResultEvent(
{
"toolUseId": context.tool_use["toolUseId"],
"status": "error",
"content": [
{
"text": (
"Delegation failed: a delegation tool must be the only tool "
"called in a turn. Retry with a single delegation tool call "
"or use only non-delegation tools."
)
}
],
}
)
return
# Stream the delegation tool, unwrapping inner agent events as native events.
async for event in next_fn(context):
if isinstance(event, ToolResultEvent):
yield event
elif isinstance(event, AgentAsToolStreamEvent):
inner_data = event.get("tool_stream_event", {}).get("data")
if inner_data is not None:
yield TypedEvent(inner_data)
else:
yield event
else:
yield event