Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ dependencies = [
[project.optional-dependencies]
# AgentCore-specific dependencies (for inference_api)
agentcore = [
"strands-agents==1.51.0",
"strands-agents==1.55.0",
"strands-agents-tools==0.8.8",

"aws-opentelemetry-distro==0.19.0",
Expand All @@ -71,7 +71,7 @@ agentcore = [

# Voice/BidiAgent dependencies (Nova Sonic speech-to-speech)
bidi = [
"strands-agents[bidi]==1.51.0",
"strands-agents[bidi]==1.55.0",
]

# Document ingestion pipeline dependencies (for Lambda deployment)
Expand Down
36 changes: 29 additions & 7 deletions backend/src/agents/main_agent/core/model_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ def to_bedrock_config(self) -> Dict[str, Any]:
# allows max 4; nothing else in this codebase adds one, see the
# position test in tests/agents/main_agent/core/test_bedrock_cache_points.py):
#
# 1. toolConfig tail — cache_tools="default" (_build_tools_cache_point)
# 1. toolConfig tail — CacheConfig(tools_ttl=True) (_build_tools_cache_point)
# 2. system tail — SystemContentBlock list built by
# AgentFactory.create_agent (the deprecated
# cache_prompt config key is NOT used)
Expand All @@ -402,9 +402,9 @@ def to_bedrock_config(self) -> Dict[str, Any]:
# ~28k-token static prefix still reads from cache on those turns.
#
# For a model whose id Strands doesn't recognize as cache-capable,
# auto strategy logs a warning and no-ops — but cache_tools and a
# system cachePoint are sent unconditionally once configured, so both
# are gated on bedrock_cache_points_supported() (the same predicate
# auto strategy logs a warning and no-ops — but the tools and system
# cachePoints are sent unconditionally once configured, so both are
# gated on bedrock_cache_points_supported() (the same predicate
# Strands' auto mode uses). Requires strands-agents>=1.48.0: a
# cachePoint trailing a non-PDF `document` attachment is rejected by
# Bedrock's Anthropic adapter with "ValidationException ...
Expand All @@ -415,11 +415,33 @@ def to_bedrock_config(self) -> Dict[str, Any]:
# document is the first content block. Cache hits are user-visible in
# the cost/context badge the moment this is on.
# See: https://github.com/strands-agents/sdk-python/issues/1966
# tools_ttl replaces the model-level cache_tools key, deprecated in
# strands-agents 1.55.0 (_warn_on_deprecated_cache_tools). The emitted
# block is byte-identical either way, which matters because it is the
# tail of the cached prefix: with cache_config.ttl unset,
# _build_tools_cache_point resolves ttl to None for tools_ttl=True
# exactly as _build_deprecated_cache_tools_point did for
# cache_tools="default", so both emit {"cachePoint": {"type": "default"}}
# with no ttl key. False (not None) on the unsupported branch pins the
# off state explicitly rather than falling back through the deprecated
# key. Since cache_config.ttl stays unset, _apply_system_cache_ttl is
# also a no-op — it only rewrites a TTL-less cache point when one is
# configured.
#
# system_prompt_ttl keeps its 1.55 default of True, which appends a
# system cachePoint via _should_cache_system. That is inert on every
# path here: the guard is `not any("cachePoint" in block ...)`, and
# AgentFactory.create_agent already appends its own whenever
# bedrock_cache_points_supported() — the same predicate, so the two
# can't disagree. It stays on as the safety net for a system prompt
# that reaches Bedrock without going through that factory.
if self.caching_enabled:
from strands.models import CacheConfig
config["cache_config"] = CacheConfig(strategy="auto")
if self.bedrock_cache_points_supported():
config["cache_tools"] = "default"
config["cache_config"] = CacheConfig(
strategy="auto",
system_prompt_ttl=True,
tools_ttl=self.bedrock_cache_points_supported(),
)

if self.retry_config:
from botocore.config import Config as BotocoreConfig
Expand Down
39 changes: 25 additions & 14 deletions backend/src/agents/main_agent/voice_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
real-time voice interaction. Shares session history with text ChatAgent
for voice-text continuity.

Requires: strands-agents[bidi] extra for BidiAgent and BidiNovaSonicModel.
Requires: strands-agents[bidi] extra for BidiAgent and BedrockNovaSonicModel.

Based on the voice agent pattern from:
https://github.com/aws-samples/sample-strands-agent-with-agentcore
Expand All @@ -23,10 +23,18 @@

logger = logging.getLogger(__name__)

# Optional imports — BidiAgent requires the strands bidi extra
# Optional imports — BidiAgent requires the strands bidi extra.
#
# strands-agents 1.55.0 renamed the provider: the module went
# ``models.nova_sonic`` -> ``models.bedrock`` and the class
# ``BidiNovaSonicModel`` -> ``BedrockNovaSonicModel``. That is an ImportError,
# which this block swallows into BIDI_AVAILABLE=False — so a stale import would
# not crash, it would silently turn voice off everywhere with one INFO line.
# Import the name explicitly rather than leaning on the package's lazy
# ``__getattr__``, so a future rename fails loudly here too.
try:
from strands.experimental.bidi import BidiAgent
from strands.experimental.bidi.models.nova_sonic import BidiNovaSonicModel
from strands.experimental.bidi.models.bedrock import BedrockNovaSonicModel
BIDI_AVAILABLE = True
except ImportError:
BIDI_AVAILABLE = False
Expand All @@ -45,7 +53,7 @@ class VoiceAgent(BaseAgent):
Bidirectional voice agent using AWS Nova Sonic 2.

Provides:
- Real-time speech-to-speech via BidiNovaSonicModel
- Real-time speech-to-speech via BedrockNovaSonicModel
- Voice-text continuity (loads previous text chat history)
- Separate agent_id ("voice") to avoid session state conflicts
- Configurable voice, sample rate, and model via environment variables
Expand Down Expand Up @@ -95,18 +103,21 @@ def _create_agent(self) -> None:
EnvVars.NOVA_SONIC_MODEL_ID, Defaults.NOVA_SONIC_MODEL_ID
)

model = BidiNovaSonicModel(
# 1.55.0 flattened the provider's constructor: the audio settings
# moved from provider_config["audio"] to the `audio` kwarg (an
# AudioConfig TypedDict with these same five keys), and the region
# moved from client_config["region"] to `region`. Both are
# keyword-only now.
model = BedrockNovaSonicModel(
model_id=model_id,
provider_config={
"audio": {
"voice": self._voice,
"input_rate": Defaults.NOVA_SONIC_INPUT_RATE,
"output_rate": Defaults.NOVA_SONIC_OUTPUT_RATE,
"channels": 1,
"format": "pcm",
},
audio={
"voice": self._voice,
"input_rate": Defaults.NOVA_SONIC_INPUT_RATE,
"output_rate": Defaults.NOVA_SONIC_OUTPUT_RATE,
"channels": 1,
"format": "pcm",
},
client_config={"region": os.environ.get(EnvVars.AWS_REGION, Defaults.AWS_REGION)},
region=os.environ.get(EnvVars.AWS_REGION, Defaults.AWS_REGION),
)

# Build voice-specific system prompt
Expand Down
61 changes: 53 additions & 8 deletions backend/tests/agents/main_agent/core/test_bedrock_cache_points.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
the system prompt keep the stable prefix readable from cache on those turns.

Contract under test (see ModelConfig.to_bedrock_config comment):
1. toolConfig.tools tail — via cache_tools="default"
1. toolConfig.tools tail — via CacheConfig(tools_ttl=True)
2. system tail — via SystemContentBlock list from AgentFactory
3. last user message tail — via CacheConfig(strategy="auto")
Bedrock allows max 4 cachePoints per request; nothing else may add one, so the
Expand Down Expand Up @@ -37,22 +37,41 @@ def _count_cache_points(node) -> int:


# ---------------------------------------------------------------------------
# ModelConfig: cache_tools + support predicate
# ModelConfig: tools caching + support predicate
# ---------------------------------------------------------------------------
class TestCacheToolsConfig:
def test_cache_tools_set_for_claude_with_caching(self):
def test_tools_ttl_set_for_claude_with_caching(self):
config = ModelConfig(model_id=CLAUDE_MODEL_ID, caching_enabled=True)
assert config.to_bedrock_config()["cache_tools"] == "default"
bedrock_config = config.to_bedrock_config()
assert bedrock_config["cache_config"].tools_ttl is True
# The model-level key was deprecated in strands-agents 1.55.0
# (_warn_on_deprecated_cache_tools); tools_ttl supersedes it.
assert "cache_tools" not in bedrock_config

def test_no_cache_tools_when_caching_disabled(self):
def test_no_cache_config_when_caching_disabled(self):
config = ModelConfig(model_id=CLAUDE_MODEL_ID, caching_enabled=False)
assert "cache_tools" not in config.to_bedrock_config()
bedrock_config = config.to_bedrock_config()
assert "cache_config" not in bedrock_config
assert "cache_tools" not in bedrock_config

def test_no_cache_tools_for_non_anthropic_bedrock_model(self):
def test_tools_ttl_off_for_non_anthropic_bedrock_model(self):
"""A model Strands' auto strategy would no-op on must not get explicit
cachePoints either — Bedrock would reject them with ValidationException."""
config = ModelConfig(model_id="amazon.nova-pro-v1:0", caching_enabled=True)
assert "cache_tools" not in config.to_bedrock_config()
bedrock_config = config.to_bedrock_config()
assert bedrock_config["cache_config"].tools_ttl is False
assert "cache_tools" not in bedrock_config

def test_no_ttl_configured_so_the_emitted_points_carry_none(self):
"""cache_config.ttl must stay unset.

It is what makes tools_ttl=True emit a bare ``{"type": "default"}``
(byte-identical to the old cache_tools="default"), and what keeps
_apply_system_cache_ttl from rewriting the TTL on the cache point
AgentFactory places. Both sit inside the cached prefix.
"""
config = ModelConfig(model_id=CLAUDE_MODEL_ID, caching_enabled=True)
assert config.to_bedrock_config()["cache_config"].ttl is None

def test_support_predicate_false_for_non_bedrock_provider(self):
config = ModelConfig(
Expand Down Expand Up @@ -187,6 +206,32 @@ def test_system_tail_is_cache_point(self, request_parts):
# system point must survive.
assert request_parts["system"][0] == {"text": "You are a helpful assistant."}

def test_system_blocks_hold_exactly_one_cache_point(self, request_parts):
"""1.55's _should_cache_system must not double the point we placed.

Its guard is ``not any("cachePoint" in block ...)``, so a second point
can only appear if AgentFactory stops placing ours — which would move
the system boundary and rewrite the cached prefix. A count of 2 would
also be a ValidationException (adjacent cache points).
"""
assert _count_cache_points(request_parts["system"]) == 1

def test_system_prompt_without_our_point_gets_exactly_one(self, model):
"""The 1.55 safety net, for any path that bypasses AgentFactory.

Pinned deliberately: cache_config.system_prompt_ttl stays at its
default True, so a system prompt reaching Bedrock without our
trailing cachePoint still ends the static prefix at the same place
rather than at the tools tail.
"""
request = model.format_request(
[{"role": "user", "content": [{"text": "hi"}]}],
None,
system_prompt_content=[{"text": "You are a helpful assistant."}],
)
assert _count_cache_points(request["system"]) == 1
assert request["system"][-1] == {"cachePoint": {"type": "default"}}

def test_last_user_message_tail_is_cache_point(self, request_parts):
last_user = [m for m in request_parts["messages"] if m["role"] == "user"][-1]
assert last_user["content"][-1] == {"cachePoint": {"type": "default"}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,32 @@ def test_agent_exposes_cancel(self):
assert callable(getattr(Agent, "cancel", None))

def test_sequential_executor_honors_the_cancel_signal(self):
"""Queued tools must be skipped once cancel is armed (new in 1.51.0)."""
"""Queued tools must be skipped once cancel is armed (new in 1.51.0).

1.51.0 read ``agent._cancel_signal`` inline; 1.55.0 moved the same read
behind ``Agent._observe_cancellation``. Accept either spelling — what
must not disappear is the per-tool check.
"""
from strands.tools.executors import sequential

assert "_cancel_signal" in inspect.getsource(sequential)
source = inspect.getsource(sequential)
assert "_cancel_signal" in source or "_observe_cancellation" in source

def test_observe_cancellation_reads_the_signal_we_clear(self):
"""``reset_cancellation_state`` clears ``agent._cancel_signal`` by name.

1.55.0's ``_observe_cancellation`` is the only reader the executor goes
through, and it also mirrors a caller-supplied ``_external_cancel_signal``
onto the internal one. We never pass ``cancel_signal`` to ``stream_async``,
so that stays None — but if a future change starts passing one, clearing
the internal signal alone would stop being enough and a cancelled turn
would wedge every turn after it.
"""
from strands import Agent

source = inspect.getsource(Agent._observe_cancellation)
assert "self._cancel_signal.is_set()" in source
assert "_external_cancel_signal" in source

def test_mcp_tool_forwards_the_cancel_signal(self):
"""The in-flight MCP call must see the signal (new in 1.51.0)."""
Expand Down
76 changes: 76 additions & 0 deletions backend/tests/agents/main_agent/test_voice_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,82 @@ def test_voice_agent_is_base_agent_subclass(self):
assert issubclass(VoiceAgent, BaseAgent)


class TestBidiProviderContract:
"""Bind the voice provider import against the pinned SDK.

``voice_agent`` imports the provider inside a ``try/except ImportError``
that degrades to ``BIDI_AVAILABLE = False`` and one INFO line. A rename
upstream therefore does not crash — it silently turns voice off. That is
exactly what strands-agents 1.55.0 did: ``models.nova_sonic``'s
``BidiNovaSonicModel`` became ``models.bedrock``'s
``BedrockNovaSonicModel``.

These assertions read the pinned SDK's *source*, not a live import, because
``tests.yml`` installs ``--extra agentcore --extra dev`` but not
``--extra bidi``: the provider module ships in the base wheel while its
runtime dependencies do not, so importing it here would fail on CI even
when the pin is correct.
"""

def _provider_source(self):
import importlib.util
import pathlib

spec = importlib.util.find_spec("strands.experimental.bidi")
assert spec is not None and spec.origin, "strands bidi package not found"
provider = pathlib.Path(spec.origin).parent / "models" / "bedrock.py"
assert provider.is_file(), (
f"{provider} is missing — the bidi provider module was renamed again; "
"update the import in agents/main_agent/voice_agent.py"
)
return provider.read_text()

def test_provider_module_defines_the_class_we_import(self):
assert "class BedrockNovaSonicModel" in self._provider_source()

def test_voice_agent_imports_the_current_provider_name(self):
"""Read the module's import statements, not its prose.

The comment above the import names the old symbol on purpose, so match
against the parsed AST rather than the raw text.
"""
import ast
import inspect

import agents.main_agent.voice_agent as va

imported = {
f"{node.module}.{alias.name}"
for node in ast.walk(ast.parse(inspect.getsource(va)))
if isinstance(node, ast.ImportFrom) and node.module
for alias in node.names
}
assert (
"strands.experimental.bidi.models.bedrock.BedrockNovaSonicModel" in imported
)
assert not any("BidiNovaSonicModel" in name for name in imported), (
f"stale 1.51 provider name still imported: {sorted(imported)}"
)

def test_provider_takes_flattened_audio_and_region_kwargs(self):
"""1.55.0 replaced provider_config/client_config with audio/region."""
source = self._provider_source()
assert "audio: AudioConfig | None = None" in source
assert "region: str | None = None" in source
assert "provider_config" not in source

def test_audio_config_still_carries_the_five_keys_we_send(self):
from strands.experimental.bidi.types.model import AudioConfig

assert {"voice", "input_rate", "output_rate", "channels", "format"} <= set(
AudioConfig.__annotations__
)

def test_nova_sonic_usage_is_still_cumulative(self):
"""VoiceAgent de-cumulates bidi_usage; a switch to deltas would double-count."""
assert "usage_is_cumulative = True" in self._provider_source()


class TestVoiceConstants:
"""Req VA-2: Voice configuration constants."""

Expand Down
Loading