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

1790 lines
82 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""AWS Bedrock model provider.
- Docs: https://aws.amazon.com/bedrock/
"""
import asyncio
import json
import logging
import os
import threading
import warnings
from collections.abc import AsyncGenerator, Callable, Iterable, ValuesView
from typing import Any, Literal, TypeVar, cast
import boto3
from botocore import UNSIGNED
from botocore.config import Config as BotocoreConfig
from botocore.exceptions import ClientError
from pydantic import BaseModel
from typing_extensions import Unpack, override
from strands.types.media import S3Location, SourceLocation
from .._exception_notes import add_exception_note
from ..event_loop import streaming
from ..tools import convert_pydantic_to_tool_spec
from ..tools._tool_helpers import noop_tool
from ..types.content import CachePoint, ContentBlock, Messages, SystemContentBlock
from ..types.exceptions import (
ContextWindowOverflowException,
ModelThrottledException,
ProviderTokenCountError,
)
from ..types.streaming import CitationsDelta, StreamEvent
from ..types.tools import ToolChoice, ToolSpec
from ._defaults import resolve_config_metadata
from ._strict_schema import ensure_strict_json_schema
from ._validation import _warn_on_deprecated_cache_tools, validate_config_keys
from .model import BaseModelConfig, CacheConfig, CacheToolsConfig, Model
logger = logging.getLogger(__name__)
# See: `BedrockModel._get_default_model_with_warning` for why we need both
DEFAULT_BEDROCK_MODEL_ID = "global.anthropic.claude-sonnet-4-6"
_DEFAULT_BEDROCK_MODEL_ID = "{}.anthropic.claude-sonnet-4-6"
DEFAULT_BEDROCK_REGION = "us-west-2"
_BEDROCK_VIDEO_FORMAT_ALIASES = {
"3gp": "three_gp",
"3g2": "three_gp",
"3gpp": "three_gp",
}
BEDROCK_CONTEXT_WINDOW_OVERFLOW_MESSAGES = [
"Input is too long for requested model",
"input length and `max_tokens` exceed context limit",
"too many total text bytes",
"prompt is too long",
"This model's maximum context length is",
]
# Bedrock reports this exact substring for the Converse incompatibility tracked in #1223.
_TOOL_RESULT_TURN_VALIDATION_MESSAGE = "Conversation blocks and tool result blocks cannot be provided in the same turn."
# Models that should include tool result status (include_tool_result_status = True)
_MODELS_INCLUDE_STATUS = [
"anthropic.claude",
]
# Models that hallucinate when receiving JSON content blocks in tool results
_MODELS_CONVERT_JSON_TO_TEXT = [
"amazon.nova",
]
# Cache of model IDs for which CountTokens API calls should be skipped.
_SKIP_COUNT_TOKENS_MODELS: set[str] = set()
def _clear_skip_count_tokens_cache() -> None:
"""Clear the cache of model IDs for which CountTokens API calls should be skipped."""
_SKIP_COUNT_TOKENS_MODELS.clear()
def _suppress_task_exception(task: "asyncio.Task[None]") -> None:
"""Consume exception from orphaned stream task to silence 'never retrieved' warning."""
if not task.cancelled():
task.exception()
async def _poll_cancel_signal(cancel_signal: threading.Event) -> None:
"""Complete once the cancellation signal is set."""
# threading.Event has no async notification hook. Poll on this loop rather than running
# Event.wait() in an executor: cancelling that await cannot stop a worker already blocked
# in Event.wait(), so completed streams could strand worker threads.
while not cancel_signal.is_set():
await asyncio.sleep(_CANCEL_POLL_INTERVAL)
async def _next_stream_event(
queue: "asyncio.Queue[StreamEvent | None]", cancel_poll: "asyncio.Future[None] | None"
) -> "StreamEvent | None":
"""Wait for the worker thread's next event, giving up if cancellation gets there first.
Args:
queue: Queue the worker thread publishes events to.
cancel_poll: Future that completes on cancellation, or None when the caller supplied no
cancellation signal.
Returns:
The next event, or None when the worker is done or cancellation won the race.
"""
if cancel_poll is None:
return await queue.get()
# Fast path: skip the race machinery whenever an event is already available.
try:
return queue.get_nowait()
except asyncio.QueueEmpty:
pass
get_event = asyncio.ensure_future(queue.get())
pending: set[asyncio.Future[Any]] = {get_event, cancel_poll}
try:
await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
except BaseException:
get_event.cancel()
raise
if get_event.done():
return get_event.result()
get_event.cancel()
return None
T = TypeVar("T", bound=BaseModel)
DEFAULT_READ_TIMEOUT = 120
# How often the consumer side checks a cancellation signal while waiting for the next event.
_CANCEL_POLL_INTERVAL = 0.05
class BedrockModel(Model):
"""AWS Bedrock model provider implementation.
The implementation handles Bedrock-specific features such as:
- Tool configuration for function calling
- Guardrails integration
- Caching points for system prompts and tools
- Streaming responses
- Context window overflow detection
"""
class BedrockConfig(BaseModelConfig, total=False):
"""Configuration options for Bedrock models.
Attributes:
additional_args: Any additional arguments to include in the request
additional_request_fields: Additional fields to include in the Bedrock request
additional_response_field_paths: Additional response field paths to extract
cache_prompt: Cache point type for the system prompt (deprecated, use cache_config)
cache_config: Configuration for prompt caching. Use CacheConfig(strategy="auto") for automatic caching.
cache_tools: Cache point type for tools (deprecated, use CacheConfig(tools_ttl=...)). Pass a string
(e.g. "default") to cache the tools with no explicit TTL, or a CacheToolsConfig instance to set
both type and TTL (e.g. "1h"). Inherits cache_config.ttl if specified, otherwise it takes the
Bedrock default. Superseded by an explicitly set cache_config.tools_ttl.
guardrail_id: ID of the guardrail to apply
guardrail_trace: Guardrail trace mode. Defaults to enabled.
guardrail_version: Version of the guardrail to apply
guardrail_stream_processing_mode: The guardrail processing mode
guardrail_redact_input: Flag to redact input if a guardrail is triggered. Defaults to True.
guardrail_redact_input_message: If a Bedrock Input guardrail triggers, replace the input with this message.
guardrail_redact_output: Flag to redact output if guardrail is triggered. Defaults to False.
guardrail_redact_output_message: If a Bedrock Output guardrail triggers, replace output with this message.
guardrail_latest_message: Flag to send only the lastest user message to guardrails.
Defaults to False.
max_tokens: Maximum number of tokens to generate in the response
model_id: The Bedrock model ID (e.g., "global.anthropic.claude-sonnet-4-6")
include_tool_result_status: Flag to include status field in tool results.
True includes status, False removes status, "auto" determines based on model_id. Defaults to "auto".
service_tier: Service tier for the request, controlling the trade-off between latency and cost.
Valid values: "default" (standard), "priority" (faster, premium), "flex" (cheaper, slower).
Please check https://docs.aws.amazon.com/bedrock/latest/userguide/service-tiers-inference.html for
supported service tiers, models, and regions
stop_sequences: List of sequences that will stop generation when encountered
streaming: Flag to enable/disable streaming. Defaults to True.
strict_tools: Flag to enable structured output enforcement on tool definitions.
When True, adds strict: true to each tool spec and automatically injects
"additionalProperties": false into all object types in tool input schemas.
Bedrock's strict mode compiles tool schemas into a constrained-decoding grammar and
restricts which JSON Schema features tool input schemas may use (for example, "oneOf"
is unsupported and optional parameters are capped across all tools in the request).
A schema that uses an unsupported feature fails at request time with a
ValidationException.
See https://docs.aws.amazon.com/bedrock/latest/userguide/structured-output.html
temperature: Controls randomness in generation (higher = more random)
top_p: Controls diversity via nucleus sampling (alternative to temperature)
use_native_token_count: Whether to use the native Bedrock CountTokens API.
When True, count_tokens() calls the Bedrock API for accurate counts.
When False (default), skips the API call and uses the local estimator.
"""
additional_args: dict[str, Any] | None
additional_request_fields: dict[str, Any] | None
additional_response_field_paths: list[str] | None
cache_prompt: str | None
cache_config: CacheConfig | None
cache_tools: str | CacheToolsConfig | None
guardrail_id: str | None
guardrail_trace: Literal["enabled", "disabled", "enabled_full"] | None
guardrail_stream_processing_mode: Literal["sync", "async"] | None
guardrail_version: str | None
guardrail_redact_input: bool | None
guardrail_redact_input_message: str | None
guardrail_redact_output: bool | None
guardrail_redact_output_message: str | None
guardrail_latest_message: bool | None
max_tokens: int | None
model_id: str
include_tool_result_status: Literal["auto"] | bool | None
service_tier: str | None
stop_sequences: list[str] | None
streaming: bool | None
strict_tools: bool | None
temperature: float | None
top_p: float | None
use_native_token_count: bool
def __init__(
self,
*,
boto_session: boto3.Session | None = None,
boto_client_config: BotocoreConfig | None = None,
region_name: str | None = None,
endpoint_url: str | None = None,
api_key: str | None = None,
**model_config: Unpack[BedrockConfig],
):
"""Initialize provider instance.
Args:
boto_session: Boto Session to use when calling the Bedrock Model.
boto_client_config: Configuration to use when creating the Bedrock-Runtime Boto Client.
region_name: AWS region to use for the Bedrock service.
Defaults to the AWS_REGION environment variable if set, or "us-west-2" if not set.
endpoint_url: Custom endpoint URL for VPC endpoints (PrivateLink)
api_key: Amazon Bedrock API key for bearer token authentication.
When provided, requests use the API key instead of SigV4 signing.
**model_config: Configuration options for the Bedrock model.
"""
if region_name and boto_session:
raise ValueError("Cannot specify both `region_name` and `boto_session`.")
session = boto_session or boto3.Session()
resolved_region = region_name or session.region_name or os.environ.get("AWS_REGION") or DEFAULT_BEDROCK_REGION
self.config = BedrockModel.BedrockConfig(
model_id=BedrockModel._get_default_model_with_warning(resolved_region, model_config),
include_tool_result_status="auto",
)
self._tool_result_turn_separation_model_id: str | None = None
self.update_config(**model_config)
logger.debug("config=<%s> | initializing", self.config)
# Add strands-agents to the request user agent
if boto_client_config:
existing_user_agent = getattr(boto_client_config, "user_agent_extra", None)
# Append 'strands-agents' to existing user_agent_extra or set it if not present
if existing_user_agent:
new_user_agent = f"{existing_user_agent} strands-agents"
else:
new_user_agent = "strands-agents"
client_config = boto_client_config.merge(
BotocoreConfig(
user_agent_extra=new_user_agent,
**({"signature_version": UNSIGNED} if api_key else {}),
)
)
else:
client_config = BotocoreConfig(
user_agent_extra="strands-agents",
read_timeout=DEFAULT_READ_TIMEOUT,
**({"signature_version": UNSIGNED} if api_key else {}),
)
self.client = session.client(
service_name="bedrock-runtime",
config=client_config,
endpoint_url=endpoint_url,
region_name=resolved_region,
)
if api_key:
def set_bearer_auth(request: Any, **_: Any) -> None:
request.headers["Authorization"] = f"Bearer {api_key}"
self.client.meta.events.register("before-send.bedrock-runtime.*", set_bearer_auth)
logger.debug("region=<%s> | bedrock client created", self.client.meta.region_name)
@property
def _cache_strategy(self) -> str | None:
"""The effective cache strategy for this request, or None when caching is off or unsupported.
Resolves ``cache_config.strategy``: an explicit strategy is returned as written, while ``"auto"``
maps to ``"anthropic"`` for Claude/Anthropic model IDs and None for models without automatic
caching support.
"""
cache_config = self.config.get("cache_config")
if not cache_config:
return None
if cache_config.strategy != "auto":
return cache_config.strategy
model_id = self.config.get("model_id", "").lower()
if "claude" in model_id or "anthropic" in model_id:
return "anthropic"
return None
@override
def update_config(self, **model_config: Unpack[BedrockConfig]) -> None: # type: ignore
"""Update the Bedrock Model configuration with the provided arguments.
Args:
**model_config: Configuration overrides.
"""
validate_config_keys(model_config, self.BedrockConfig)
# __init__ delegates here, so the caller sits at stacklevel 4 on the constructor path.
_warn_on_deprecated_cache_tools(model_config, stacklevel=4)
self.config.update(model_config)
@override
def get_config(self) -> BedrockConfig:
"""Get the current Bedrock Model configuration.
Returns:
The Bedrock model configuration.
"""
return resolve_config_metadata(self.config, self.config.get("model_id", ""))
def format_request(
self,
messages: Messages,
tool_specs: list[ToolSpec] | None = None,
system_prompt_content: list[SystemContentBlock] | None = None,
tool_choice: ToolChoice | None = None,
dynamic_trailing_blocks: int = 0,
**kwargs: Any,
) -> dict[str, Any]:
"""Format a Bedrock converse stream request.
Args:
messages: List of message objects to be processed by the model.
tool_specs: List of tool specifications to make available to the model.
tool_choice: Selection strategy for tool invocation.
system_prompt_content: System prompt content blocks to provide context to the model.
dynamic_trailing_blocks: How many trailing blocks of the last user message are rebuilt on every
call, so the cache point goes ahead of them.
**kwargs: Additional keyword arguments for future extensibility.
Returns:
A Bedrock converse stream request.
"""
if not tool_specs:
has_tool_content = any(
any("toolUse" in block or "toolResult" in block for block in msg.get("content", [])) for msg in messages
)
if has_tool_content:
tool_specs = [noop_tool.tool_spec]
# Use system_prompt_content directly (copy for mutability)
system_blocks: list[SystemContentBlock] = system_prompt_content.copy() if system_prompt_content else []
# Add cache point if configured (backwards compatibility)
if cache_prompt := self.config.get("cache_prompt"):
warnings.warn(
"cache_prompt is deprecated. Use SystemContentBlock with cachePoint instead.", UserWarning, stacklevel=3
)
system_blocks.append({"cachePoint": {"type": cache_prompt}})
# Built ahead of the request so the tools cache point is known to the system cache point behind it.
tools_cache_point = self._build_tools_cache_point() if tool_specs else []
if self._should_cache_system(system_blocks):
system_blocks.append({"cachePoint": {"type": "default"}})
formatted_messages = self._format_bedrock_messages(messages, dynamic_trailing_blocks)
if self._tool_result_turn_separation_model_id == self.config["model_id"]:
formatted_messages = self._separate_tool_result_turns(formatted_messages)
return {
"modelId": self.config["model_id"],
"messages": formatted_messages,
"system": self._apply_system_cache_ttl(system_blocks, tools_cache_point),
**({"serviceTier": {"type": self.config["service_tier"]}} if self.config.get("service_tier") else {}),
**(
{
"toolConfig": {
"tools": [
*[
{
"toolSpec": {
"name": tool_spec["name"],
"description": tool_spec["description"],
"inputSchema": (
{"json": ensure_strict_json_schema(tool_spec["inputSchema"]["json"])}
if self.config.get("strict_tools")
else tool_spec["inputSchema"]
),
**({"strict": True} if self.config.get("strict_tools") else {}),
}
}
for tool_spec in tool_specs
],
*tools_cache_point,
],
**({"toolChoice": tool_choice if tool_choice else {"auto": {}}}),
}
}
if tool_specs
else {}
),
**(self._get_additional_request_fields(tool_choice)),
**(
{"additionalModelResponseFieldPaths": self.config["additional_response_field_paths"]}
if self.config.get("additional_response_field_paths")
else {}
),
**(
{
"guardrailConfig": {
"guardrailIdentifier": self.config["guardrail_id"],
"guardrailVersion": self.config["guardrail_version"],
"trace": self.config.get("guardrail_trace", "enabled"),
**(
{"streamProcessingMode": self.config.get("guardrail_stream_processing_mode")}
if self.config.get("guardrail_stream_processing_mode")
else {}
),
}
}
if self.config.get("guardrail_id") and self.config.get("guardrail_version")
else {}
),
"inferenceConfig": {
key: value
for key, value in [
("maxTokens", self.config.get("max_tokens")),
("temperature", self.config.get("temperature")),
("topP", self.config.get("top_p")),
("stopSequences", self.config.get("stop_sequences")),
]
if value is not None
},
**(
self.config["additional_args"]
if "additional_args" in self.config and self.config["additional_args"] is not None
else {}
),
}
def _get_additional_request_fields(self, tool_choice: ToolChoice | None) -> dict[str, Any]:
"""Get additional request fields, removing thinking if tool_choice forces tool use.
Bedrock's API does not allow thinking mode when tool_choice forces tool use.
When forcing a tool (e.g., for structured_output retry), we temporarily disable thinking.
Args:
tool_choice: The tool choice configuration.
Returns:
A dict containing additionalModelRequestFields if configured, or empty dict.
"""
additional_fields = self.config.get("additional_request_fields")
if not additional_fields:
return {}
# Check if tool_choice is forcing tool use ("any" or specific "tool")
is_forcing_tool = tool_choice is not None and ("any" in tool_choice or "tool" in tool_choice)
if is_forcing_tool and "thinking" in additional_fields:
# Create a copy without the thinking key
fields_without_thinking = {k: v for k, v in additional_fields.items() if k != "thinking"}
if fields_without_thinking:
return {"additionalModelRequestFields": fields_without_thinking}
return {}
return {"additionalModelRequestFields": additional_fields}
def _apply_system_cache_ttl(
self, system_blocks: list[SystemContentBlock], tools_cache_point: list[dict[str, Any]]
) -> list[SystemContentBlock]:
"""Apply ``cache_config.ttl`` to a caller-placed system cache point that carries no TTL of its own.
Bedrock rejects a TTL that exceeds an earlier cache point's, in the order toolConfig, system,
messages. Filling the system point in keeps it from sitting at the default between two configured
points. A TTL the caller wrote on the point is left as written. An explicit ``cache_config.system_prompt_ttl``
string is honored as written.
Args:
system_blocks: System content blocks for the request.
tools_cache_point: The toolConfig cache point emitted for this request, if any.
Returns:
The blocks, with a cache point replaced rather than mutated since the caller owns the block.
"""
ttl: str | None = None
cache_config = self.config.get("cache_config")
if cache_config and self._cache_strategy == "anthropic":
system_prompt_ttl = cache_config.system_prompt_ttl
section_ttl = system_prompt_ttl if isinstance(system_prompt_ttl, str) else cache_config.ttl
if section_ttl:
tools_ttl_differs = bool(tools_cache_point) and (
tools_cache_point[0]["cachePoint"].get("ttl") != section_ttl
)
if isinstance(system_prompt_ttl, str) or not tools_ttl_differs:
ttl = section_ttl
normalized: list[SystemContentBlock] = []
for block in system_blocks:
cache_point = block.get("cachePoint")
# A TTL the caller wrote is theirs, so only a falsy one is rewritten.
if cache_point is None or cache_point.get("ttl"):
normalized.append(block)
continue
point: CachePoint = {**cache_point}
# pop() rather than a conditional write, exactly as the message path does, so both paths drop
# a falsy TTL by the same rule.
point.pop("ttl", None)
if ttl:
point["ttl"] = ttl
normalized.append(SystemContentBlock(**{**block, "cachePoint": point}))
return normalized
def _should_cache_system(self, system_blocks: list[SystemContentBlock]) -> bool:
"""Whether to auto-inject a cache point at the end of the system prompt.
Args:
system_blocks: The system content blocks that will be sent to Bedrock.
Returns:
True if a cache point should be appended.
"""
if self._cache_strategy != "anthropic":
return False
cache_config = self.config.get("cache_config")
if not cache_config or cache_config.system_prompt_ttl is False:
return False
if not system_blocks:
return False
return not any("cachePoint" in block for block in system_blocks)
def _build_tools_cache_point(self) -> list[dict[str, Any]]:
"""Build the cache point block appended to ``toolConfig.tools`` when tool caching is configured.
An explicitly set ``cache_config.tools_ttl`` drives the point, mirroring ``system_prompt_ttl`` - a TTL
string sets the tools section's own duration, True derives it from ``cache_config.ttl``, and False
disables it; a section that carries no TTL of its own inherits ``cache_config.ttl``. When ``tools_ttl``
is left unset (None), the deprecated model-level ``cache_tools`` drives the point instead so existing
configurations keep working unchanged.
Returns:
A single-element list containing the cache point block, or an empty list when tool caching is off.
"""
cache_config = self.config.get("cache_config")
if cache_config is None or cache_config.tools_ttl is None:
return self._build_deprecated_cache_tools_point(cache_config)
tools_ttl = cache_config.tools_ttl
if tools_ttl is False or self._cache_strategy != "anthropic":
return []
ttl = tools_ttl if isinstance(tools_ttl, str) else cache_config.ttl
cache_point: dict[str, Any] = {"type": "default"}
if ttl:
cache_point["ttl"] = ttl
return [{"cachePoint": cache_point}]
def _build_deprecated_cache_tools_point(self, cache_config: CacheConfig | None) -> list[dict[str, Any]]:
"""Build the tools cache point from the deprecated model-level ``cache_tools`` option.
Reached only when ``cache_config.tools_ttl`` is unset; an explicit ``tools_ttl`` supersedes this path.
Returns:
A single-element list containing the cache point block, or an empty list when ``cache_tools`` is off.
"""
cache_tools = self.config.get("cache_tools")
if not cache_tools:
return []
if cache_config is not None and self._cache_strategy != "anthropic":
return []
if isinstance(cache_tools, CacheToolsConfig):
cache_type, ttl = cache_tools.type, cache_tools.ttl
else:
cache_type, ttl = cache_tools, None
if not ttl and cache_config and cache_config.ttl and self._cache_strategy == "anthropic":
ttl = cache_config.ttl
cache_point: dict[str, Any] = {"type": cache_type}
if ttl:
cache_point["ttl"] = ttl
return [{"cachePoint": cache_point}]
def _honor_placed_cache_point(
self,
content: list[dict[str, Any]],
placed_idx: int,
msg_idx: int,
cache_config: CacheConfig | None,
) -> bool:
"""Keep a caller-placed cache point, taking the configured TTL and the document rule.
The caller's *position* is honored; their TTL is not. Bedrock processes cache points in the
order toolConfig, system, messages and rejects a longer TTL that follows a shorter one, so a
TTL written here can turn a working configuration into a rejected request - verified against
the API: tools at 5m with a hand-placed 1h on the message is rejected, the same shape at 5m is
accepted. Normalizing to ``cache_config.ttl`` is what makes the honored path TTL-identical to
the automatic one, which is the only behaviour this method should be changing.
Args:
content: Content blocks of the last user message (modified in place).
placed_idx: Index of the caller-placed cache point.
msg_idx: Index of the message, for logging.
cache_config: The configured cache settings, if any.
Returns:
True when the point was kept, possibly relocated. False when nothing cacheable precedes it,
so the point was removed: Bedrock rejects a cache point with no content ahead of it ("There
is nothing available to cache"). The caller then retries automatic placement, which lands a
point only if the remaining content allows one - for a leading document it declines too, so
the message ends up with no cache point at all.
"""
placed = content[placed_idx]
if placed_idx == 0:
del content[placed_idx]
logger.warning(
"msg_idx=<%s> | removed cache point with no content ahead of it, falling back to automatic placement",
msg_idx,
)
return False
cache_point = placed["cachePoint"]
# Dropping it unconditionally also handles a caller TTL of None or "", which botocore and
# Bedrock respectively reject before the request can even be judged on placement. The
# non-increasing rule itself is documented on CacheConfig.
cache_point.pop("ttl", None)
if cache_config and cache_config.ttl:
cache_point["ttl"] = cache_config.ttl
# Bedrock only rejects a cache point *directly* preceded by a non-PDF document, so step back
# over the adjacent run of them. Moving further would evict durable content - usually the
# document itself, the expensive part - from the cached prefix for no reason.
target_idx = placed_idx
while target_idx > 0:
previous = content[target_idx - 1]
if "document" not in previous or previous["document"].get("format", "") == "pdf":
break
target_idx -= 1
if target_idx != placed_idx:
del content[placed_idx]
if target_idx == 0:
# Nothing precedes the documents, so there is no prefix to cache. Automatic placement
# declines for the same reason, leaving the message with no cache point.
logger.warning("msg_idx=<%s> | dropped cache point ahead of a leading document", msg_idx)
return False
content.insert(target_idx, placed)
logger.debug("msg_idx=<%s>, block_idx=<%s> | relocated caller-placed cache point", msg_idx, target_idx)
return True
logger.debug("msg_idx=<%s>, block_idx=<%s> | honored caller-placed cache point", msg_idx, placed_idx)
return True
def _inject_cache_point(self, messages: list[dict[str, Any]], dynamic_trailing_blocks: int = 0) -> None:
"""Ensure the last user message carries exactly one cache point.
A cache point already present in the last user message is honored where it sits rather than
replaced: a caller places one to mark where its reusable prefix ends, ahead of content that is
rebuilt every call. Moving it to the end of the message would put that per-call content inside
the cached prefix, so every request would write a new entry and none would ever read one.
Cache points in earlier messages are still removed, so they cannot accumulate one per turn
against the provider's cache-point budget.
Args:
messages: List of messages to inject cache point into (modified in place).
dynamic_trailing_blocks: How many trailing blocks of the last user message are rebuilt on every call.
The point goes ahead of them, for the same reason a caller-placed one is honored.
"""
if not messages:
return
last_user_idx: int | None = None
for msg_idx, msg in enumerate(messages):
if msg.get("role") == "user":
last_user_idx = msg_idx
for msg_idx, msg in enumerate(messages):
if msg_idx == last_user_idx:
continue
content = msg.get("content", [])
for block_idx, block in reversed(list(enumerate(content))):
if "cachePoint" in block:
del content[block_idx]
logger.warning(
"msg_idx=<%s>, block_idx=<%s> | stripped existing cache point (auto mode manages cache points)",
msg_idx,
block_idx,
)
if last_user_idx is not None and messages[last_user_idx].get("content"):
cache_point: dict[str, Any] = {"type": "default"}
cache_config = self.config.get("cache_config")
if cache_config and cache_config.ttl:
cache_point["ttl"] = cache_config.ttl
content = messages[last_user_idx]["content"]
placed_idxs = [idx for idx, block in enumerate(content) if "cachePoint" in block]
if placed_idxs:
# One boundary per message, so this message contributes a single cache point to the budget.
# Extras are not worthless - a second point ahead of the per-call tail doubles the cached
# prefix - but Bedrock allows only four cache points per request and the budget is shared
# across toolConfig, system and messages. The SDK already spends up to two of them via
# cache_tools and cache_prompt, so honoring a caller's extras needs that arithmetic
# first, or three caller points become a hard rejection rather than weaker caching.
for extra_idx in reversed(placed_idxs[1:]):
del content[extra_idx]
logger.warning(
"msg_idx=<%s>, block_idx=<%s> | stripped existing cache point (auto mode manages cache points)",
last_user_idx,
extra_idx,
)
if self._honor_placed_cache_point(content, placed_idxs[0], last_user_idx, cache_config):
return
if not content:
# Removing the point emptied the message, so there is nothing left to cache and
# re-adding one would rebuild the request Bedrock just refused. Bedrock rejects an
# empty message too, exactly as the pre-existing strip path already did.
logger.debug("msg_idx=<%s> | no content left to cache", last_user_idx)
return
# The point could not legally stay, so fall through to automatic placement below.
if dynamic_trailing_blocks:
# Routed through the honor path so the document rule and configured TTL still apply.
boundary_idx = max(0, len(content) - dynamic_trailing_blocks)
if boundary_idx == 0:
logger.debug("msg_idx=<%s> | skipped cache point, no durable prefix", last_user_idx)
return
content.insert(boundary_idx, {"cachePoint": cache_point})
# A False here means a leading document left no prefix to cache; automatic placement
# declines for the same reason, so there is nothing to fall back to.
self._honor_placed_cache_point(content, boundary_idx, last_user_idx, cache_config)
return
# Insert before non-PDF document blocks to avoid Bedrock ValidationException
first_non_pdf_doc_idx: int | None = None
for i, block in enumerate(content):
if "document" in block and block["document"].get("format", "") != "pdf":
first_non_pdf_doc_idx = i
break
# Insert the cache point before the first non-PDF document so it is not directly
# preceded by that block, which Bedrock rejects with a ValidationException
if first_non_pdf_doc_idx is None:
content.append({"cachePoint": cache_point})
elif first_non_pdf_doc_idx > 0:
content.insert(first_non_pdf_doc_idx, {"cachePoint": cache_point})
else:
# A leading non-PDF document leaves no prefix to cache and Bedrock rejects it
logger.debug("msg_idx=<%s> | skipped cache point for leading non-PDF document", last_user_idx)
return
logger.debug("msg_idx=<%s> | added cache point to last user message", last_user_idx)
def _find_last_user_text_message_index(self, messages: Messages) -> int | None:
"""Find the index of the last user message containing text or image content.
This is used for guardrail_latest_message to ensure that guardContent wrapping
targets the correct message even when toolResult messages follow.
Args:
messages: List of messages to search
Returns:
Index of the last user message with text/image content, or None if not found
"""
for idx, msg in reversed(list(enumerate(messages))):
if msg["role"] == "user" and any("text" in cb or "image" in cb for cb in msg.get("content", [])):
return idx
return None
def _format_bedrock_messages(self, messages: Messages, dynamic_trailing_blocks: int = 0) -> list[dict[str, Any]]:
"""Format messages for Bedrock API compatibility.
This function ensures messages conform to Bedrock's expected format by:
- Filtering out SDK_UNKNOWN_MEMBER content blocks
- Eagerly filtering content blocks to only include Bedrock-supported fields
- Ensuring all message content blocks are properly formatted for the Bedrock API
- Optionally wrapping the last user message in guardrailConverseContent blocks
- Injecting cache points when cache_config is set with strategy="auto"
Args:
messages: List of messages to format
dynamic_trailing_blocks: How many trailing blocks of the last user message are rebuilt on every
call, so the cache point goes ahead of them.
Returns:
Messages formatted for Bedrock API compatibility
Note:
Unlike other APIs that ignore unknown fields, Bedrock only accepts a strict
subset of fields for each content block type and throws validation exceptions
when presented with unexpected fields. Therefore, we must eagerly filter all
content blocks to remove any additional fields before sending to Bedrock.
https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ContentBlock.html
"""
cleaned_messages: list[dict[str, Any]] = []
filtered_unknown_members = False
dropped_deepseek_reasoning_content = False
# Pre-compute the index of the last user message containing text or image content.
# This ensures guardContent wrapping is maintained across tool execution cycles, where
# the final message in the list is a toolResult (role=user) rather than text/image content.
last_user_text_idx = None
if self.config.get("guardrail_latest_message", False):
last_user_text_idx = self._find_last_user_text_message_index(messages)
for idx, message in enumerate(messages):
cleaned_content: list[dict[str, Any]] = []
for content_block in message["content"]:
# Filter out SDK_UNKNOWN_MEMBER content blocks
if "SDK_UNKNOWN_MEMBER" in content_block:
filtered_unknown_members = True
continue
# DeepSeek models have issues with reasoningContent
# TODO: Replace with systematic model configuration registry (https://github.com/strands-agents/harness-sdk/issues/780)
if "deepseek" in self.config["model_id"].lower() and "reasoningContent" in content_block:
dropped_deepseek_reasoning_content = True
continue
# Format content blocks for Bedrock API compatibility
formatted_content = self._format_request_message_content(content_block)
if formatted_content is None:
continue
# Wrap text or image content in guardContent if this is the last user text/image message.
# Bedrock guardContent supports a narrower set of image formats than image content.
if idx == last_user_text_idx and ("text" in formatted_content or "image" in formatted_content):
if "text" in formatted_content:
formatted_content = {"guardContent": {"text": {"text": formatted_content["text"]}}}
elif "image" in formatted_content:
image_format = formatted_content["image"].get("format", "")
supported_formats = self.client.meta.service_model.shape_for(
"GuardrailConverseImageFormat"
).enum
if image_format in supported_formats:
formatted_content = {"guardContent": {"image": formatted_content["image"]}}
else:
logger.warning(
"image_format=<%s> | format not supported by bedrock guardrails | "
"skipping guardContent wrap",
image_format,
)
cleaned_content.append(formatted_content)
# Create new message with cleaned content (skip if empty)
if cleaned_content:
cleaned_messages.append({"content": cleaned_content, "role": message["role"]})
if filtered_unknown_members:
logger.warning(
"Filtered out SDK_UNKNOWN_MEMBER content blocks from messages, consider upgrading boto3 version"
)
if dropped_deepseek_reasoning_content:
logger.debug(
"Filtered DeepSeek reasoningContent content blocks from messages - https://api-docs.deepseek.com/guides/reasoning_model#multi-round-conversation"
)
# Inject cache point into cleaned_messages (not original messages) if cache_config is set
cache_config = self.config.get("cache_config")
if cache_config:
cache_strategy = self._cache_strategy
if cache_config.strategy == "auto" and not cache_strategy:
logger.warning(
"model_id=<%s> | cache_config is enabled but this model does not support automatic caching",
self.config.get("model_id"),
)
if cache_strategy == "anthropic":
self._inject_cache_point(cleaned_messages, dynamic_trailing_blocks)
return cleaned_messages
@staticmethod
def _separate_tool_result_turns(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Separate adjacent user turns rejected by some Bedrock models.
A neutral assistant turn separates a tool-result-only user turn from the
next conversation turn described in #1223. The transformation is
request-local and idempotent, so persisted conversation history is not
mutated and repeated application does not add more separators.
"""
separated_messages: list[dict[str, Any]] = []
for message in messages:
if separated_messages and separated_messages[-1].get("role") == "user" and message.get("role") == "user":
previous_content = separated_messages[-1].get("content", [])
current_content = message.get("content", [])
if (
previous_content
and all("toolResult" in block for block in previous_content)
and current_content
and all("toolResult" not in block for block in current_content)
):
separated_messages.append({"role": "assistant", "content": [{"text": "Tool result received."}]})
separated_messages.append(message)
return separated_messages
def _should_include_tool_result_status(self) -> bool:
"""Determine whether to include tool result status based on current config."""
include_status = self.config.get("include_tool_result_status", "auto")
if include_status is True:
return True
elif include_status is False:
return False
else: # "auto"
return any(model in self.config["model_id"] for model in _MODELS_INCLUDE_STATUS)
def _should_convert_json_to_text(self) -> bool:
"""Determine whether JSON content blocks in tool results should be converted to text.
Some models (e.g., Amazon Nova) hallucinate when tool results contain JSON content
blocks. Converting them to their text representation avoids this issue.
"""
return any(model in self.config["model_id"] for model in _MODELS_CONVERT_JSON_TO_TEXT)
def _handle_location(self, location: SourceLocation) -> dict[str, Any] | None:
"""Convert location content block to Bedrock format if its an S3Location."""
if location["type"] == "s3":
s3_location = cast(S3Location, location)
formatted_document_s3: dict[str, Any] = {"uri": s3_location["uri"]}
if "bucketOwner" in s3_location:
formatted_document_s3["bucketOwner"] = s3_location["bucketOwner"]
return {"s3Location": formatted_document_s3}
else:
logger.warning("Non s3 location sources are not supported by Bedrock | skipping content block")
return None
def _format_request_message_content(self, content: ContentBlock) -> dict[str, Any] | None:
"""Format a Bedrock content block.
Bedrock strictly validates content blocks and throws exceptions for unknown fields.
This function extracts only the fields that Bedrock supports for each content type.
Args:
content: Content block to format.
Returns:
Bedrock formatted content block.
Raises:
TypeError: If the content block type is not supported by Bedrock.
"""
# https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_CachePointBlock.html
if "cachePoint" in content:
cache_point = content["cachePoint"]
result: dict[str, Any] = {"type": cache_point["type"]}
if cache_point.get("ttl"):
result["ttl"] = cache_point["ttl"]
return {"cachePoint": result}
# https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_AudioBlock.html
if "audio" in content:
audio = content["audio"]
source = audio["source"]
formatted_audio_source: dict[str, Any] | None
if "location" in source:
formatted_audio_source = self._handle_location(source["location"])
if formatted_audio_source is None:
return None
elif "bytes" in source:
formatted_audio_source = {"bytes": source["bytes"]}
result = {"format": audio["format"], "source": formatted_audio_source}
return {"audio": result}
# https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_DocumentBlock.html
if "document" in content:
document = content["document"]
result = {}
# Handle required fields (all optional due to total=False)
if "name" in document:
result["name"] = document["name"]
if "format" in document:
result["format"] = document["format"]
# Handle source - supports bytes or location
if "source" in document:
source = document["source"]
formatted_document_source: dict[str, Any] | None
if "location" in source:
formatted_document_source = self._handle_location(source["location"])
if formatted_document_source is None:
return None
elif "bytes" in source:
formatted_document_source = {"bytes": source["bytes"]}
result["source"] = formatted_document_source
# Handle optional fields
if "citations" in document and document["citations"] is not None:
result["citations"] = {"enabled": document["citations"]["enabled"]}
if "context" in document:
result["context"] = document["context"]
return {"document": result}
# https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_GuardrailConverseContentBlock.html
if "guardContent" in content:
guard = content["guardContent"]
guard_text = guard["text"]
text_block: dict[str, Any] = {"text": guard_text["text"]}
if "qualifiers" in guard_text:
text_block["qualifiers"] = guard_text["qualifiers"]
return {"guardContent": {"text": text_block}}
# https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ImageBlock.html
if "image" in content:
image = content["image"]
source = image["source"]
formatted_image_source: dict[str, Any] | None
if "location" in source:
formatted_image_source = self._handle_location(source["location"])
if formatted_image_source is None:
return None
elif "bytes" in source:
formatted_image_source = {"bytes": source["bytes"]}
result = {"format": image["format"], "source": formatted_image_source}
return {"image": result}
# https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ReasoningContentBlock.html
if "reasoningContent" in content:
reasoning = content["reasoningContent"]
result = {}
if "reasoningText" in reasoning:
reasoning_text = reasoning["reasoningText"]
result["reasoningText"] = {}
if "text" in reasoning_text:
result["reasoningText"]["text"] = reasoning_text["text"]
# Only include signature if truthy (avoid empty strings)
if reasoning_text.get("signature"):
result["reasoningText"]["signature"] = reasoning_text["signature"]
if "redactedContent" in reasoning:
result["redactedContent"] = reasoning["redactedContent"]
return {"reasoningContent": result}
# Pass through text and other simple content types
if "text" in content:
return {"text": content["text"]}
# https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ToolResultBlock.html
if "toolResult" in content:
tool_result = content["toolResult"]
# Normalize empty toolResult content arrays.
# Some model providers (e.g., Nemotron) reject toolResult blocks with
# content: [] via the Converse API, while others (e.g., Claude) accept
# them. Replace empty content with a minimal text block to ensure
# cross-model compatibility. This follows the same pattern as the
# TypeScript SDK's _formatMessages in bedrock.ts.
tool_result_content_list = tool_result.get("content") or [{"text": ""}]
formatted_content: list[dict[str, Any]] = []
for tool_result_content in tool_result_content_list:
if "json" in tool_result_content:
if self._should_convert_json_to_text():
formatted_content.append({"text": json.dumps(tool_result_content["json"])})
else:
# Handle json field since not in ContentBlock but valid in ToolResultContent
formatted_content.append({"json": tool_result_content["json"]})
else:
formatted_message_content = self._format_request_message_content(
cast(ContentBlock, tool_result_content)
)
if formatted_message_content is None:
continue
formatted_content.append(formatted_message_content)
result = {
"content": formatted_content,
"toolUseId": tool_result["toolUseId"],
}
if "status" in tool_result and self._should_include_tool_result_status():
result["status"] = tool_result["status"]
return {"toolResult": result}
# https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ToolUseBlock.html
if "toolUse" in content:
tool_use = content["toolUse"]
return {
"toolUse": {
"input": tool_use["input"],
"name": tool_use["name"],
"toolUseId": tool_use["toolUseId"],
}
}
# https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_VideoBlock.html
if "video" in content:
video = content["video"]
source = video["source"]
formatted_video_source: dict[str, Any] | None
if "location" in source:
formatted_video_source = self._handle_location(source["location"])
if formatted_video_source is None:
return None
elif "bytes" in source:
formatted_video_source = {"bytes": source["bytes"]}
video_format = _BEDROCK_VIDEO_FORMAT_ALIASES.get(video["format"], video["format"])
result = {"format": video_format, "source": formatted_video_source}
return {"video": result}
# https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_CitationsContentBlock.html
if "citationsContent" in content:
citations = content["citationsContent"]
result = {}
if "citations" in citations:
result["citations"] = []
for citation in citations["citations"]:
filtered_citation: dict[str, Any] = {}
if "location" in citation:
filtered_citation["location"] = citation["location"]
if "sourceContent" in citation:
filtered_source_content: list[dict[str, Any]] = []
for source_content in citation["sourceContent"]:
if "text" in source_content:
filtered_source_content.append({"text": source_content["text"]})
if filtered_source_content:
filtered_citation["sourceContent"] = filtered_source_content
if "title" in citation:
filtered_citation["title"] = citation["title"]
result["citations"].append(filtered_citation)
if "content" in citations:
filtered_content: list[dict[str, Any]] = []
for generated_content in citations["content"]:
if "text" in generated_content:
filtered_content.append({"text": generated_content["text"]})
if filtered_content:
result["content"] = filtered_content
return {"citationsContent": result}
content_type = next(iter(content), None)
raise TypeError(f"content_type=<{content_type}> | unsupported type")
def _should_redact_guardrail_content(self, guardrail_data: dict[str, Any] | None, stop_reason: str | None) -> bool:
"""Check whether a guardrail blocked content and redaction should occur.
Bedrock reports stop_reason=guardrail_intervened for both blocked and masked content, so the
stop reason alone cannot distinguish the two. When Bedrock carries a trace we defer to it:
redact only when a policy's action is BLOCKED. ANONYMIZED (masked) spans are already
substituted in place server-side and the surrounding message must be preserved. When the
trace is absent (typically guardrail_trace='disabled'), we fall back to the stop reason.
Args:
guardrail_data: Guardrail assessment from trace information, if any.
stop_reason: The stop reason reported by Bedrock, if any.
Returns:
True if blocked content should be redacted, False otherwise.
"""
if guardrail_data is not None:
input_assessment = guardrail_data.get("inputAssessment", {})
output_assessments = guardrail_data.get("outputAssessments", {})
if any(self._find_detected_and_blocked_policy(assessment) for assessment in input_assessment.values()):
return True
if any(self._find_detected_and_blocked_policy(assessment) for assessment in output_assessments.values()):
return True
return False
return stop_reason == "guardrail_intervened"
def _generate_redaction_events(self) -> list[StreamEvent]:
"""Generate redaction events based on configuration.
Returns:
List of redaction events to yield.
"""
events: list[StreamEvent] = []
if self.config.get("guardrail_redact_input", True):
logger.debug("Redacting user input due to guardrail.")
events.append(
{
"redactContent": {
"redactUserContentMessage": self.config.get(
"guardrail_redact_input_message", "[User input redacted.]"
)
}
}
)
if self.config.get("guardrail_redact_output", False):
logger.debug("Redacting assistant output due to guardrail.")
events.append(
{
"redactContent": {
"redactAssistantContentMessage": self.config.get(
"guardrail_redact_output_message",
"[Assistant output redacted.]",
)
}
}
)
return events
@override
async def count_tokens(
self,
messages: Messages,
tool_specs: list[ToolSpec] | None = None,
system_prompt: str | None = None,
system_prompt_content: list[SystemContentBlock] | None = None,
) -> int:
"""Count tokens using Bedrock's native CountTokens API.
Uses the same message format as the Converse API to get accurate token counts
directly from the Bedrock service.
Args:
messages: List of message objects to count tokens for.
tool_specs: List of tool specifications to include in the count.
system_prompt: Plain string system prompt. Ignored if system_prompt_content is provided.
system_prompt_content: Structured system prompt content blocks.
Returns:
Total input token count.
"""
if self.config.get("use_native_token_count") is not True:
return await super().count_tokens(messages, tool_specs, system_prompt, system_prompt_content)
model_id: str = self.config["model_id"]
if model_id in _SKIP_COUNT_TOKENS_MODELS:
return await super().count_tokens(messages, tool_specs, system_prompt, system_prompt_content)
try:
if system_prompt and system_prompt_content is None:
system_prompt_content = [{"text": system_prompt}]
request = self.format_request(messages, tool_specs, system_prompt_content)
converse_input: dict[str, Any] = {}
if "messages" in request:
converse_input["messages"] = request["messages"]
if "system" in request:
converse_input["system"] = request["system"]
if "toolConfig" in request:
converse_input["toolConfig"] = request["toolConfig"]
response = await asyncio.to_thread(
self.client.count_tokens,
modelId=self.config["model_id"],
input={"converse": converse_input},
)
input_tokens = response.get("inputTokens")
if input_tokens is None:
raise ProviderTokenCountError("Bedrock count_tokens returned None for inputTokens")
total_tokens: int = input_tokens
logger.debug("model_id=<%s>, total_tokens=<%d> | native token count", self.config["model_id"], total_tokens)
return total_tokens
except Exception as e:
if isinstance(e, ClientError) and e.response.get("Error", {}).get("Code") == "AccessDeniedException":
logger.warning(
"model_id=<%s> | bedrock:CountTokens permission denied, falling back to heuristic estimation: %s",
model_id,
e,
)
_SKIP_COUNT_TOKENS_MODELS.add(model_id)
elif (
isinstance(e, ClientError)
and e.response.get("Error", {}).get("Code") == "ValidationException"
and "doesn't support counting tokens" in str(e)
):
logger.debug(
"model_id=<%s> | model does not support CountTokens, caching for future calls,"
" falling back to estimation",
model_id,
)
_SKIP_COUNT_TOKENS_MODELS.add(model_id)
else:
logger.debug(
"model_id=<%s>, error=<%s> | native token counting failed, falling back to estimation",
model_id,
e,
)
return await super().count_tokens(messages, tool_specs, system_prompt, system_prompt_content)
@override
async def stream(
self,
messages: Messages,
tool_specs: list[ToolSpec] | None = None,
system_prompt: str | None = None,
*,
tool_choice: ToolChoice | None = None,
system_prompt_content: list[SystemContentBlock] | None = None,
cancel_signal: threading.Event | None = None,
**kwargs: Any,
) -> AsyncGenerator[StreamEvent, None]:
"""Stream conversation with the Bedrock model.
This method calls either the Bedrock converse_stream API or the converse API
based on the streaming parameter in the configuration.
Args:
messages: List of message objects to be processed by the model.
tool_specs: List of tool specifications to make available to the model.
system_prompt: System prompt to provide context to the model.
tool_choice: Selection strategy for tool invocation.
system_prompt_content: System prompt content blocks to provide context to the model.
cancel_signal: Event that aborts an in-flight streaming request. The caller stops
receiving events as soon as it is set, and the HTTP response is closed at the next
chunk boundary. A non-streaming request (``streaming=False``) is not abortable.
**kwargs: Additional keyword arguments for future extensibility.
Yields:
Model events.
Raises:
ContextWindowOverflowException: If the input exceeds the model's context window.
ModelThrottledException: If the model service is throttling requests.
"""
def callback(event: StreamEvent | None = None) -> None:
loop.call_soon_threadsafe(queue.put_nowait, event)
if event is None:
return
loop = asyncio.get_event_loop()
queue: asyncio.Queue[StreamEvent | None] = asyncio.Queue()
# Handle backward compatibility: if system_prompt is provided but system_prompt_content is None
if system_prompt and system_prompt_content is None:
system_prompt_content = [{"text": system_prompt}]
thread = asyncio.to_thread(
self._stream,
callback,
messages,
tool_specs,
system_prompt_content,
tool_choice,
kwargs.get("dynamic_trailing_blocks", 0),
cancel_signal,
)
task = asyncio.create_task(thread)
cancel_poll = asyncio.ensure_future(_poll_cancel_signal(cancel_signal)) if cancel_signal else None
try:
while True:
event = await _next_stream_event(queue, cancel_poll)
if event is None:
break
yield event
if cancel_poll is not None and cancel_poll.done():
# The worker thread owns the event stream and closes it at its next chunk boundary.
# Detaching it rather than awaiting keeps a stalled read from delaying the caller.
task.add_done_callback(_suppress_task_exception)
return
await task
except BaseException:
task.add_done_callback(_suppress_task_exception)
raise
finally:
if cancel_poll is not None:
cancel_poll.cancel()
def _stream(
self,
callback: Callable[..., None],
messages: Messages,
tool_specs: list[ToolSpec] | None = None,
system_prompt_content: list[SystemContentBlock] | None = None,
tool_choice: ToolChoice | None = None,
dynamic_trailing_blocks: int = 0,
cancel_signal: threading.Event | None = None,
) -> None:
"""Stream conversation with the Bedrock model.
This method operates in a separate thread to avoid blocking the async event loop with the call to
Bedrock's converse_stream.
Args:
callback: Function to send events to the main thread.
messages: List of message objects to be processed by the model.
tool_specs: List of tool specifications to make available to the model.
system_prompt_content: System prompt content blocks to provide context to the model.
tool_choice: Selection strategy for tool invocation.
dynamic_trailing_blocks: How many trailing blocks of the last user message are rebuilt on every
call, so the cache point goes ahead of them.
cancel_signal: Event that stops the transfer at the next chunk boundary.
Raises:
ContextWindowOverflowException: If the input exceeds the model's context window.
ModelThrottledException: If the model service is throttling requests.
"""
try:
logger.debug("formatting request")
request = self.format_request(
messages, tool_specs, system_prompt_content, tool_choice, dynamic_trailing_blocks
)
model_id = request["modelId"]
logger.debug("request=<%s>", request)
logger.debug("invoking model")
streaming = self.config.get("streaming", True)
converse_method = self.client.converse_stream if streaming else self.client.converse
try:
response = converse_method(**request)
except ClientError as error:
error_details = error.response.get("Error", {})
if error_details.get(
"Code"
) != "ValidationException" or _TOOL_RESULT_TURN_VALIDATION_MESSAGE not in error_details.get(
"Message", ""
):
raise
separated_messages = self._separate_tool_result_turns(request["messages"])
if separated_messages == request["messages"]:
raise
logger.debug("model_id=<%s> | separating tool result and conversation turns", model_id)
request = {**request, "messages": separated_messages}
response = converse_method(**request)
self._tool_result_turn_separation_model_id = model_id
logger.debug("got response from model")
if streaming:
redaction_emitted = False
saw_guardrail_trace = False
last_stop_reason: str | None = None
for chunk in response["stream"]:
if cancel_signal is not None and cancel_signal.is_set():
# Closing from this thread only: botocore's teardown is not safe against a
# read in flight, and this thread is the one reading.
response["stream"].close()
break
if "messageStop" in chunk:
last_stop_reason = chunk["messageStop"].get("stopReason")
# Wait for a metadata chunk before deciding: the guardrail trace arrives there
# and is authoritative for BLOCKED vs ANONYMIZED (masking).
if not redaction_emitted and "metadata" in chunk:
guardrail_data = chunk["metadata"].get("trace", {}).get("guardrail")
if guardrail_data is not None:
saw_guardrail_trace = True
if self._should_redact_guardrail_content(guardrail_data, last_stop_reason):
for event in self._generate_redaction_events():
callback(event)
redaction_emitted = True
callback(chunk)
# Safety net: guardrail_intervened but no metadata chunk arrived.
if not redaction_emitted and not saw_guardrail_trace and last_stop_reason == "guardrail_intervened":
for event in self._generate_redaction_events():
callback(event)
else:
for event in self.convert_non_streaming_to_streaming(response):
callback(event)
guardrail_data = response.get("trace", {}).get("guardrail")
if self._should_redact_guardrail_content(guardrail_data, response.get("stopReason")):
for event in self._generate_redaction_events():
callback(event)
except ClientError as e:
error_message = str(e)
if (
e.response["Error"]["Code"] == "ThrottlingException"
or e.response["Error"]["Code"] == "throttlingException"
):
raise ModelThrottledException(error_message) from e
if any(overflow_message in error_message for overflow_message in BEDROCK_CONTEXT_WINDOW_OVERFLOW_MESSAGES):
logger.warning("bedrock threw context window overflow error")
raise ContextWindowOverflowException(e) from e
region = self.client.meta.region_name
# Aid in debugging by adding more information
add_exception_note(e, f"└ Bedrock region: {region}")
add_exception_note(e, f"└ Model id: {self.config.get('model_id')}")
if (
e.response["Error"]["Code"] == "AccessDeniedException"
and "You don't have access to the model" in error_message
):
add_exception_note(
e,
"└ For more information see "
"https://strandsagents.com/docs/user-guide/concepts/model-providers/amazon-bedrock/#required-iam-permissions",
)
if (
e.response["Error"]["Code"] == "ValidationException"
and "The provided model identifier is invalid" in error_message
):
add_exception_note(
e,
"└ For more information see "
"https://strandsagents.com/docs/user-guide/concepts/model-providers/amazon-bedrock/#model-identifier-is-invalid",
)
if (
e.response["Error"]["Code"] == "ValidationException"
and "with on-demand throughput isn’t supported" in error_message
):
add_exception_note(
e,
"└ For more information see "
"https://strandsagents.com/docs/user-guide/concepts/model-providers/amazon-bedrock/#on-demand-throughput-isnt-supported",
)
raise e
finally:
callback()
logger.debug("finished streaming response from model")
def convert_non_streaming_to_streaming(self, response: dict[str, Any], **kwargs: Any) -> Iterable[StreamEvent]:
"""Convert a non-streaming response to the streaming format.
Args:
response: The non-streaming response from the Bedrock model.
**kwargs: Additional keyword arguments for future extensibility.
Returns:
An iterable of response events in the streaming format.
"""
# Yield messageStart event
yield {"messageStart": {"role": response["output"]["message"]["role"]}}
# Process content blocks
for content in cast(list[ContentBlock], response["output"]["message"]["content"]):
# Yield contentBlockStart event if needed
if "toolUse" in content:
yield {
"contentBlockStart": {
"start": {
"toolUse": {
"toolUseId": content["toolUse"]["toolUseId"],
"name": content["toolUse"]["name"],
}
},
}
}
# For tool use, we need to yield the input as a delta
input_value = json.dumps(content["toolUse"]["input"])
yield {"contentBlockDelta": {"delta": {"toolUse": {"input": input_value}}}}
elif "text" in content:
# Then yield the text as a delta
yield {
"contentBlockDelta": {
"delta": {"text": content["text"]},
}
}
elif "reasoningContent" in content:
reasoning = content["reasoningContent"]
if "reasoningText" in reasoning:
reasoning_text = reasoning["reasoningText"]
if "text" in reasoning_text:
yield {"contentBlockDelta": {"delta": {"reasoningContent": {"text": reasoning_text["text"]}}}}
if reasoning_text.get("signature"):
yield {
"contentBlockDelta": {
"delta": {"reasoningContent": {"signature": reasoning_text["signature"]}}
}
}
if "redactedContent" in reasoning:
yield {
"contentBlockDelta": {
"delta": {"reasoningContent": {"redactedContent": reasoning["redactedContent"]}}
}
}
elif "citationsContent" in content:
# For non-streaming citations, emit text and metadata deltas in sequence
# to match streaming behavior where they flow naturally
if "content" in content["citationsContent"]:
text_content = "".join([content["text"] for content in content["citationsContent"]["content"]])
yield {
"contentBlockDelta": {"delta": {"text": text_content}},
}
for citation in content["citationsContent"]["citations"]:
# Emit citation metadata, only including fields that are present
# Nova grounding may omit title/sourceContent
citation_metadata: CitationsDelta = {}
if "title" in citation:
citation_metadata["title"] = citation["title"]
if "location" in citation:
citation_metadata["location"] = citation["location"]
if "sourceContent" in citation:
citation_metadata["sourceContent"] = citation["sourceContent"]
yield {"contentBlockDelta": {"delta": {"citation": citation_metadata}}}
# Yield contentBlockStop event
yield {"contentBlockStop": {}}
# Yield messageStop event
yield {
"messageStop": {
"stopReason": response["stopReason"],
"additionalModelResponseFields": response.get("additionalModelResponseFields"),
}
}
# Yield metadata event
if "usage" in response or "metrics" in response or "trace" in response:
metadata: StreamEvent = {"metadata": {}}
if "usage" in response:
metadata["metadata"]["usage"] = response["usage"]
if "metrics" in response:
metadata["metadata"]["metrics"] = response["metrics"]
if "trace" in response:
metadata["metadata"]["trace"] = response["trace"]
yield metadata
def _find_detected_and_blocked_policy(self, input: Any) -> bool:
"""Recursively checks if the assessment contains a detected and blocked guardrail.
Args:
input: The assessment to check.
Returns:
True if the input contains a detected and blocked guardrail, False otherwise.
"""
# Check if input is a dictionary
if isinstance(input, dict):
# Check if current dictionary has action: BLOCKED and detected: true
if input.get("action") == "BLOCKED" and input.get("detected") and isinstance(input.get("detected"), bool):
return True
# Otherwise, recursively check all values in the dictionary
return self._find_detected_and_blocked_policy(input.values())
elif isinstance(input, (list, ValuesView)):
# Handle case where input is a list or dict_values
return any(self._find_detected_and_blocked_policy(item) for item in input)
# Otherwise return False
return False
@override
async def structured_output(
self,
output_model: type[T],
prompt: Messages,
system_prompt: str | None = None,
**kwargs: Any,
) -> AsyncGenerator[dict[str, T | Any], None]:
"""Get structured output from the model.
Args:
output_model: The output model to use for the agent.
prompt: The prompt messages to use for the agent.
system_prompt: System prompt to provide context to the model.
**kwargs: Additional keyword arguments for future extensibility.
Yields:
Model events with the last being the structured output.
"""
tool_spec = convert_pydantic_to_tool_spec(output_model)
response = self.stream(
messages=prompt,
tool_specs=[tool_spec],
system_prompt=system_prompt,
tool_choice=cast(ToolChoice, {"any": {}}),
**kwargs,
)
async for event in streaming.process_stream(response):
yield event
stop_reason, messages, _, _ = event["stop"]
if stop_reason != "tool_use":
raise ValueError(f'Model returned stop_reason: {stop_reason} instead of "tool_use".')
content = messages["content"]
output_response: dict[str, Any] | None = None
for block in content:
# if the tool use name doesn't match the tool spec name, skip, and if the block is not a tool use, skip.
# if the tool use name never matches, raise an error.
if block.get("toolUse") and block["toolUse"]["name"] == tool_spec["name"]:
output_response = block["toolUse"]["input"]
else:
continue
if output_response is None:
raise ValueError("No valid tool use or tool use input was found in the Bedrock response.")
yield {"output": output_model(**output_response)}
@staticmethod
def _get_default_model_with_warning(region_name: str, model_config: BedrockConfig | None = None) -> str:
"""Get the default Bedrock modelId based on region.
If the region is not **known** to support inference then we show a helpful warning
that compliments the exception that Bedrock will throw.
If the customer provided a model_id in their config or they overrode the `DEFAULT_BEDROCK_MODEL_ID`
then we should not process further.
Args:
region_name (str): region for bedrock model
model_config (Optional[dict[str, Any]]): Model Config that caller passes in on init
"""
model_config = model_config or {}
if model_config.get("model_id"):
return model_config["model_id"]
if DEFAULT_BEDROCK_MODEL_ID != _DEFAULT_BEDROCK_MODEL_ID.format("us"):
return DEFAULT_BEDROCK_MODEL_ID
prefix_inference_map = {"ap": "apac"} # some inference endpoints can be a bit different than the region prefix
prefix = "-".join(region_name.split("-")[:-2]).lower() # handles `us-east-1` or `us-gov-east-1`
if prefix not in {"us", "eu", "ap", "us-gov"}:
warnings.warn(
f"""
================== WARNING ==================
This region {region_name} does not support
our default inference endpoint: {_DEFAULT_BEDROCK_MODEL_ID.format(prefix)}.
Update the agent to pass in a 'model_id' like so:
```
Agent(..., model='valid_model_id', ...)
````
Documentation: https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html
==================================================
""",
stacklevel=2,
)
default_model_id = _DEFAULT_BEDROCK_MODEL_ID.format(prefix_inference_map.get(prefix, prefix))
warnings.warn(
f"You're using default model '{default_model_id}', which is subject to change. "
"Specify a model explicitly to pin the model target.",
stacklevel=2,
)
return default_model_id