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

573 lines
22 KiB
Python

"""Utilities for handling streaming responses from language models."""
import copy
import json
import logging
import threading
import time
import warnings
from collections.abc import AsyncGenerator, AsyncIterable
from typing import Any
from ..agent.agent_metadata import AgentMetadata
from ..models.model import Model
from ..tools import InvalidToolUseNameException
from ..tools.tools import validate_tool_use_name
from ..types._events import (
CitationStreamEvent,
ModelStopReason,
ModelStreamChunkEvent,
ModelStreamEvent,
ReasoningRedactedContentStreamEvent,
ReasoningSignatureStreamEvent,
ReasoningTextStreamEvent,
TextStreamEvent,
ToolUseStreamEvent,
TypedEvent,
)
from ..types.citations import CitationsContentBlock
from ..types.content import ContentBlock, Message, Messages, SystemContentBlock
from ..types.streaming import (
ContentBlockDeltaEvent,
ContentBlockStart,
ContentBlockStartEvent,
MessageStartEvent,
MessageStopEvent,
MetadataEvent,
Metrics,
RedactContentEvent,
StopReason,
StreamEvent,
Usage,
)
from ..types.tools import ToolSpec, ToolUse
logger = logging.getLogger(__name__)
def _normalize_messages(messages: Messages) -> Messages:
"""Remove or replace blank text in message content.
Args:
messages: Conversation messages to update.
Returns:
Updated messages.
"""
removed_blank_message_content_text = False
replaced_blank_message_content_text = False
replaced_tool_names = False
# Deep copy up front so downstream normalization can mutate freely without
# affecting the caller's message history.
messages = copy.deepcopy(messages)
for message in messages:
# only modify assistant messages
if "role" in message and message["role"] != "assistant":
continue
if "content" in message:
content = message["content"]
if len(content) == 0:
content.append({"text": "[blank text]"})
continue
has_tool_use = False
# Ensure the tool-uses always have valid names before sending
# https://github.com/strands-agents/harness-sdk/issues/1069
for item in content:
if "toolUse" in item:
has_tool_use = True
tool_use: ToolUse = item["toolUse"]
try:
validate_tool_use_name(tool_use)
except InvalidToolUseNameException:
tool_use["name"] = "INVALID_TOOL_NAME"
replaced_tool_names = True
if has_tool_use:
# Remove blank or None 'text' items for assistant messages
before_len = len(content)
content[:] = [
item
for item in content
if "text" not in item or (item["text"] is not None and item["text"].strip())
]
if not removed_blank_message_content_text and before_len != len(content):
removed_blank_message_content_text = True
else:
# Replace blank or None 'text' with '[blank text]' for assistant messages
for item in content:
if "text" in item and (item["text"] is None or not item["text"].strip()):
replaced_blank_message_content_text = True
item["text"] = "[blank text]"
if removed_blank_message_content_text:
logger.debug("removed blank message context text")
if replaced_blank_message_content_text:
logger.debug("replaced blank message context text")
if replaced_tool_names:
logger.debug("replaced invalid tool name")
return messages
def remove_blank_messages_content_text(messages: Messages) -> Messages:
"""Remove or replace blank text in message content.
!!deprecated!!
This function is deprecated and will be removed in a future version.
Args:
messages: Conversation messages to update.
Returns:
Updated messages.
"""
warnings.warn(
"remove_blank_messages_content_text is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
removed_blank_message_content_text = False
replaced_blank_message_content_text = False
for message in messages:
# only modify assistant messages
if "role" in message and message["role"] != "assistant":
continue
if "content" in message:
content = message["content"]
has_tool_use = any("toolUse" in item for item in content)
if len(content) == 0:
content.append({"text": "[blank text]"})
continue
if has_tool_use:
# Remove blank or None 'text' items for assistant messages
before_len = len(content)
content[:] = [
item
for item in content
if "text" not in item or (item["text"] is not None and item["text"].strip())
]
if not removed_blank_message_content_text and before_len != len(content):
removed_blank_message_content_text = True
else:
# Replace blank or None 'text' with '[blank text]' for assistant messages
for item in content:
if "text" in item and (item["text"] is None or not item["text"].strip()):
replaced_blank_message_content_text = True
item["text"] = "[blank text]"
if removed_blank_message_content_text:
logger.debug("removed blank message context text")
if replaced_blank_message_content_text:
logger.debug("replaced blank message context text")
return messages
def handle_message_start(event: MessageStartEvent, message: Message) -> Message:
"""Handles the start of a message by setting the role in the message dictionary.
Args:
event: A message start event.
message: The message dictionary being constructed.
Returns:
Updated message dictionary with the role set.
"""
message["role"] = event["role"]
return message
def handle_content_block_start(event: ContentBlockStartEvent) -> dict[str, Any]:
"""Handles the start of a content block by extracting tool usage information if any.
Args:
event: Start event.
Returns:
Dictionary with tool use id and name if tool use request, empty dictionary otherwise.
"""
start: ContentBlockStart = event["start"]
current_tool_use = {}
if "toolUse" in start and start["toolUse"]:
tool_use_data = start["toolUse"]
current_tool_use["toolUseId"] = tool_use_data["toolUseId"]
current_tool_use["name"] = tool_use_data["name"]
current_tool_use["input"] = ""
if "reasoningSignature" in tool_use_data:
current_tool_use["reasoningSignature"] = tool_use_data["reasoningSignature"]
return current_tool_use
def handle_content_block_delta(
event: ContentBlockDeltaEvent, state: dict[str, Any]
) -> tuple[dict[str, Any], ModelStreamEvent]:
"""Handles content block delta updates by appending text, tool input, or reasoning content to the state.
Args:
event: Delta event.
state: The current state of message processing.
Returns:
Updated state with appended text or tool input.
"""
delta_content = event["delta"]
typed_event: ModelStreamEvent = ModelStreamEvent({})
if "toolUse" in delta_content:
tool_use_delta = delta_content["toolUse"]
if "input" not in state["current_tool_use"]:
state["current_tool_use"]["input"] = ""
state["current_tool_use"]["input"] += tool_use_delta.get("input", "")
# Some models emit toolUseId/name in the delta instead of contentBlockStart; keep values already set.
for field in ("toolUseId", "name"):
if field not in state["current_tool_use"] and field in tool_use_delta:
state["current_tool_use"][field] = tool_use_delta[field]
typed_event = ToolUseStreamEvent(delta_content, state["current_tool_use"])
elif "text" in delta_content:
state["text"] += delta_content["text"]
typed_event = TextStreamEvent(text=delta_content["text"], delta=delta_content)
elif "citation" in delta_content:
if "citationsContent" not in state:
state["citationsContent"] = []
state["citationsContent"].append(delta_content["citation"])
typed_event = CitationStreamEvent(delta=delta_content, citation=delta_content["citation"])
elif "reasoningContent" in delta_content:
if "text" in delta_content["reasoningContent"]:
if "reasoningText" not in state:
state["reasoningText"] = ""
state["reasoningText"] += delta_content["reasoningContent"]["text"]
typed_event = ReasoningTextStreamEvent(
reasoning_text=delta_content["reasoningContent"]["text"],
delta=delta_content,
)
elif "signature" in delta_content["reasoningContent"]:
if "signature" not in state:
state["signature"] = ""
state["signature"] += delta_content["reasoningContent"]["signature"]
typed_event = ReasoningSignatureStreamEvent(
reasoning_signature=delta_content["reasoningContent"]["signature"],
delta=delta_content,
)
elif redacted_content := delta_content["reasoningContent"].get("redactedContent"):
state["redactedContent"] = state.get("redactedContent", b"") + redacted_content
typed_event = ReasoningRedactedContentStreamEvent(redacted_content=redacted_content, delta=delta_content)
return state, typed_event
def handle_content_block_stop(state: dict[str, Any]) -> dict[str, Any]:
"""Handles the end of a content block by finalizing tool usage, text content, or reasoning content.
Args:
state: The current state of message processing.
Returns:
Updated state with finalized content block.
"""
content: list[ContentBlock] = state["content"]
current_tool_use = state["current_tool_use"]
text = state["text"]
reasoning_text = state["reasoningText"]
citations_content = state["citationsContent"]
redacted_content = state.get("redactedContent")
if current_tool_use:
if "input" not in current_tool_use:
current_tool_use["input"] = ""
# Handle empty or whitespace-only input (common for zero-argument tools)
raw_input = current_tool_use["input"]
if isinstance(raw_input, str) and not raw_input.strip():
current_tool_use["input"] = {}
else:
try:
current_tool_use["input"] = json.loads(raw_input)
except ValueError:
logger.warning(
"tool_name=<%s>, raw_input=<%s> | failed to parse tool input json, defaulting to empty dict",
current_tool_use.get("name", "unknown"),
raw_input[:200] if isinstance(raw_input, str) else "",
)
current_tool_use["input"] = {}
tool_use_id = current_tool_use.get("toolUseId", "")
tool_use_name = current_tool_use.get("name", "")
if not tool_use_id or not tool_use_name:
# Skip, don't raise: an empty tool_uses list still appends a valid toolResult, so the loop continues.
logger.warning(
"tool_use_id=<%s>, tool_name=<%s> | incomplete tool use block, skipping content block "
"(model may be using a non-standard streaming format)",
tool_use_id,
tool_use_name,
)
state["current_tool_use"] = {}
return state
tool_use = ToolUse(
toolUseId=tool_use_id,
name=tool_use_name,
input=current_tool_use["input"],
)
if "reasoningSignature" in current_tool_use:
tool_use["reasoningSignature"] = current_tool_use["reasoningSignature"]
content.append({"toolUse": tool_use})
state["current_tool_use"] = {}
elif text:
if citations_content:
citations_block: CitationsContentBlock = {"citations": citations_content, "content": [{"text": text}]}
content.append({"citationsContent": citations_block})
state["citationsContent"] = []
else:
content.append({"text": text})
state["text"] = ""
elif reasoning_text or "signature" in state:
content_block: ContentBlock = {
"reasoningContent": {
"reasoningText": {
"text": state["reasoningText"],
}
}
}
# Consume the signature so it belongs to exactly this block and does not leak into the next one.
if (signature := state.pop("signature", None)) is not None:
content_block["reasoningContent"]["reasoningText"]["signature"] = signature
content.append(content_block)
state["reasoningText"] = ""
elif redacted_content:
content.append({"reasoningContent": {"redactedContent": redacted_content}})
state["redactedContent"] = b""
return state
def handle_message_stop(event: MessageStopEvent, content: list[dict[str, Any]]) -> StopReason:
"""Handles the end of a message by returning the stop reason.
Some models return "end_turn" even when tool calls are present, which prevents the event loop from processing
those tool calls. This function overrides to "tool_use" so tool execution proceeds correctly.
Args:
event: Stop event.
content: The message content blocks accumulated during streaming.
Returns:
The reason for stopping the stream.
"""
stop_reason = event["stopReason"]
if stop_reason == "end_turn" and any("toolUse" in item for item in content):
logger.warning(
"original_stop_reason=<%s>, new_stop_reason=<%s> | "
"overriding stop reason due to toolUse blocks in response",
"end_turn",
"tool_use",
)
stop_reason = "tool_use"
return stop_reason
def handle_redact_content(event: RedactContentEvent, state: dict[str, Any]) -> None:
"""Handles redacting content from the input or output.
Args:
event: Redact Content Event.
state: The current state of message processing.
"""
if event.get("redactAssistantContentMessage") is not None:
state["message"]["content"] = [{"text": event["redactAssistantContentMessage"]}]
def extract_usage_metrics(event: MetadataEvent, time_to_first_byte_ms: int | None = None) -> tuple[Usage, Metrics]:
"""Extracts usage metrics from the metadata chunk.
Args:
event: metadata.
time_to_first_byte_ms: time to get the first byte from the model in milliseconds
Returns:
The extracted usage metrics and latency.
"""
# MetadataEvent has total=False, making all fields optional, but Usage and Metrics types
# have Required fields. Provide defaults to handle cases where custom models don't
# provide usage/metrics (e.g., when latency info is unavailable).
usage = Usage(**{"inputTokens": 0, "outputTokens": 0, "totalTokens": 0, **event.get("usage", {})})
metrics = Metrics(**{"latencyMs": 0, **event.get("metrics", {})})
if time_to_first_byte_ms:
metrics["timeToFirstByteMs"] = time_to_first_byte_ms
return usage, metrics
async def process_stream(
chunks: AsyncIterable[StreamEvent],
start_time: float | None = None,
cancel_signal: threading.Event | None = None,
) -> AsyncGenerator[TypedEvent, None]:
"""Processes the response stream from the API, constructing the final message and extracting usage metrics.
Args:
chunks: The chunks of the response stream from the model.
start_time: Time when the model request is initiated
cancel_signal: Optional threading.Event to check for cancellation during streaming.
Yields:
The reason for stopping, the constructed message, and the usage metrics.
"""
stop_reason: StopReason = "end_turn"
saw_message_stop = False
first_byte_time = None
state: dict[str, Any] = {
"message": {"role": "assistant", "content": []},
"text": "",
"current_tool_use": {},
"reasoningText": "",
"citationsContent": [],
}
state["content"] = state["message"]["content"]
usage: Usage = Usage(inputTokens=0, outputTokens=0, totalTokens=0)
metrics: Metrics = Metrics(latencyMs=0, timeToFirstByteMs=0)
async for chunk in chunks:
# Check for cancellation during stream processing
if cancel_signal and cancel_signal.is_set():
logger.debug("cancellation detected during stream processing")
# Return cancelled stop reason with cancellation message
# The incomplete message in state["message"] is discarded and never added to agent.messages
yield ModelStopReason(
stop_reason="cancelled",
message={"role": "assistant", "content": [{"text": "Cancelled by user"}]},
usage=usage,
metrics=metrics,
)
return
# Track first byte time when we get first content
if first_byte_time is None and ("contentBlockDelta" in chunk or "contentBlockStart" in chunk):
first_byte_time = time.time()
yield ModelStreamChunkEvent(chunk=chunk)
if "messageStart" in chunk:
state["message"] = handle_message_start(chunk["messageStart"], state["message"])
elif "contentBlockStart" in chunk:
state["current_tool_use"] = handle_content_block_start(chunk["contentBlockStart"])
elif "contentBlockDelta" in chunk:
state, typed_event = handle_content_block_delta(chunk["contentBlockDelta"], state)
yield typed_event
elif "contentBlockStop" in chunk:
state = handle_content_block_stop(state)
elif "messageStop" in chunk:
saw_message_stop = True
stop_reason = handle_message_stop(chunk["messageStop"], state["message"].get("content", []))
elif "metadata" in chunk:
time_to_first_byte_ms = (
int(1000 * (first_byte_time - start_time)) if (start_time and first_byte_time) else None
)
usage, metrics = extract_usage_metrics(chunk["metadata"], time_to_first_byte_ms)
elif "redactContent" in chunk:
handle_redact_content(chunk["redactContent"], state)
# A provider that aborts in flight ends the stream without messageStop; the in-loop check
# above cannot see it, so report the cancellation here rather than a truncated end_turn.
if not saw_message_stop and cancel_signal and cancel_signal.is_set():
yield ModelStopReason(
stop_reason="cancelled",
message={"role": "assistant", "content": [{"text": "Cancelled by user"}]},
usage=usage,
metrics=metrics,
)
return
yield ModelStopReason(stop_reason=stop_reason, message=state["message"], usage=usage, metrics=metrics)
async def stream_messages(
model: Model,
system_prompt: str | None,
messages: Messages,
tool_specs: list[ToolSpec],
*,
tool_choice: Any | None = None,
system_prompt_content: list[SystemContentBlock] | None = None,
invocation_state: dict[str, Any] | None = None,
model_state: dict[str, Any] | None = None,
dynamic_trailing_blocks: int = 0,
cancel_signal: threading.Event | None = None,
agent_metadata: AgentMetadata | None = None,
**kwargs: Any,
) -> AsyncGenerator[TypedEvent, None]:
"""Streams messages to the model and processes the response.
Args:
model: Model provider.
system_prompt: The system prompt string, used for backwards compatibility with models that expect it.
messages: List of messages to send.
tool_specs: The list of tool specs.
tool_choice: Optional tool choice constraint for forcing specific tool usage.
system_prompt_content: The authoritative system prompt content blocks that always contains the
system prompt data.
invocation_state: Caller-provided state/context that was passed to the agent when it was invoked.
model_state: Runtime state for model providers (e.g., server-side response ids).
dynamic_trailing_blocks: How many trailing blocks of the last user message are rebuilt on every
call, so a provider placing cache points keeps its own ahead of them.
cancel_signal: Optional threading.Event to check for cancellation during streaming. Also
forwarded to the model so a provider can abort an in-flight request.
agent_metadata: Metadata of the invoking agent, forwarded to the model.
**kwargs: Additional keyword arguments for future extensibility.
Yields:
The reason for stopping, the final message, and the usage metrics
"""
logger.debug("model=<%s> | streaming messages", model)
messages = _normalize_messages(messages)
# Whitelist only role and content before sending to the model provider.
# This ensures metadata (and any future non-model fields) never leak to providers.
messages = [Message(role=msg["role"], content=msg["content"]) for msg in messages]
start_time = time.time()
chunks = model.stream(
messages,
tool_specs if tool_specs else None,
system_prompt,
tool_choice=tool_choice,
system_prompt_content=system_prompt_content,
invocation_state=invocation_state,
model_state=model_state,
cancel_signal=cancel_signal,
agent_metadata=agent_metadata,
# Omitted when zero, so an ordinary call's arguments are unchanged.
**({"dynamic_trailing_blocks": dynamic_trailing_blocks} if dynamic_trailing_blocks else {}),
)
async for event in process_stream(chunks, start_time, cancel_signal):
yield event