diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7956a0b39..979bc902b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "uvicorn[standard]==0.46.0", # AWS and cloud services - "boto3==1.43.68", + "boto3==1.43.103", # Utilities "python-dotenv==1.2.2", @@ -67,11 +67,11 @@ dependencies = [ [project.optional-dependencies] # AgentCore-specific dependencies (for inference_api) agentcore = [ - "strands-agents==1.55.0", + "strands-agents==1.57.1", "strands-agents-tools==0.8.8", "aws-opentelemetry-distro==0.19.0", - "bedrock-agentcore==1.21.0", + "bedrock-agentcore==1.23.1", # Multi-provider LLM support "openai==2.32.0", # For OpenAI models "google-genai==1.73.1", # For Google Gemini models @@ -82,7 +82,7 @@ agentcore = [ # Voice/BidiAgent dependencies (Nova Sonic speech-to-speech) bidi = [ - "strands-agents[bidi]==1.55.0", + "strands-agents[bidi]==1.57.1", ] # Document ingestion pipeline dependencies (for Lambda deployment) @@ -125,11 +125,11 @@ all = [ # Guard rail on a TRANSITIVE dependency — this is not a loosened pin. # # `mcp` arrives only via `strands-agents`; we do not depend on it directly and -# deliberately do not promote it to a direct dependency. `strands-agents==1.55.0` -# — the version pinned above, today — already declares `mcp>=1.23.0,<2.2`, so -# the resolver is free to cross into `mcp` 2.x whenever it gets the chance. +# deliberately do not promote it to a direct dependency. `strands-agents==1.57.1` +# — the version pinned above, today — declares `mcp>=1.23.0,<2.2`, so the +# resolver is free to cross into `mcp` 2.x whenever it gets the chance. # -# The ONLY thing holding us on the 1.x line right now is incidental: `mcp` 2.x +# Without this constraint the only thing holding us on 1.x is incidental: `mcp` 2.x # pulls `httpx2>=2.5.0`, which needs `idna>=3.18`, and we pin `idna==3.15` for # an unrelated Dependabot alert. A routine security bump of `idna` would quietly # unblock the major crossing — nobody would be choosing it, and nothing in the @@ -147,6 +147,7 @@ all = [ # A constraint, not a dependency: it bounds the version IF `mcp` is in the # resolution graph, without adding it to our own dependency metadata. Lift it # deliberately, together with a test pass over that `ClientSession` patch. +# Asserted by backend/tests/supply_chain/test_strands_agentcore_pairing.py. constraint-dependencies = ["mcp<2"] [tool.setuptools] diff --git a/backend/src/agents/main_agent/session/turn_based_session_manager.py b/backend/src/agents/main_agent/session/turn_based_session_manager.py index 450437542..255dac533 100644 --- a/backend/src/agents/main_agent/session/turn_based_session_manager.py +++ b/backend/src/agents/main_agent/session/turn_based_session_manager.py @@ -130,6 +130,22 @@ def _approx_tokens(text: Any) -> int: return 0 +def _is_bidi_agent(agent: Any) -> bool: + """True for the voice path's ``BidiAgent``. + + Since strands-agents 1.56 a ``BidiAgent`` drives the *ordinary* session + hooks (``AgentInitializedEvent`` -> ``initialize``, ``MessageAddedEvent`` + -> ``append_message`` / ``retrieve_customer_context``) instead of the + dedicated Bidi callbacks it used before, so every override here now sees + voice agents too. The text-only work in them must not run for voice. + """ + try: + from strands.experimental.bidi import BidiAgent + except ImportError: + return False + return isinstance(agent, BidiAgent) + + class TurnBasedSessionManager(AgentCoreMemorySessionManager): """ Session manager with token-based context compaction. @@ -322,6 +338,11 @@ def retrieve_customer_context(self, event: Any) -> None: ``register_hooks`` in both sync and async mode via ``self.retrieve_customer_context``, so the override is picked up. """ + # Mirrors bedrock-agentcore 1.23.1's own guard: voice transcripts are + # not queries, and LTM context spliced into a live Bidi history would + # be persisted into the voice agent's messages. + if _is_bidi_agent(event.agent): + return None messages = event.agent.messages if not messages or messages[-1].get("role") != "user": return None @@ -430,6 +451,16 @@ def initialize(self, agent: "Agent", **kwargs: Any) -> None: """ logger.info(f"TurnBasedSessionManager.initialize() called for agent_id={agent.agent_id}") + # A voice BidiAgent reaches here since strands-agents 1.56 (it used to + # take the SDK's separate initialize_bidi_agent path). Restore it the + # same way that path did and stop: everything below is text-chat + # history processing, and the compaction state it reads is keyed on the + # session, so applying the text agent's checkpoint to the voice + # agent's own message list would slice the wrong history. + if _is_bidi_agent(agent): + super().initialize(agent, **kwargs) + return + # Let the SDK handle all session restore logic: # - read/create agent in session repository # - restore agent state, internal state, conversation manager state diff --git a/backend/src/agents/main_agent/voice_agent.py b/backend/src/agents/main_agent/voice_agent.py index ca44b0aba..8521be986 100644 --- a/backend/src/agents/main_agent/voice_agent.py +++ b/backend/src/agents/main_agent/voice_agent.py @@ -12,11 +12,12 @@ """ import asyncio +import base64 import logging import os import sys import types -from typing import Any, AsyncGenerator, List, Optional +from typing import Any, AsyncGenerator, Dict, List, Optional from agents.main_agent.base_agent import BaseAgent from agents.main_agent.config.constants import EnvVars, Defaults @@ -48,6 +49,127 @@ sys.modules["pyaudio"] = _fake_pyaudio +class VoiceWireAdapter: + """Translate Strands >=1.57 Bidi output events into our voice WebSocket contract. + + strands-agents 1.57 renamed and re-shaped every Bidi output event (1.57.0 + #4444 "adopt production component names", 1.57.1 #4604 "remove response + stop reasons"). The SPA (``voice-chat.service.ts``) and our own turn and + usage accounting still speak the 1.55 wire names, so the translation lives + here, at the one seam we own, rather than in two packages at once. + + What changed, and how each is mapped: + + * ``bidi_audio_delta`` -> ``bidi_audio_stream`` (same fields). + * ``bidi_transcript_delta`` -> ``bidi_transcript_stream``. Nova used to + stream an assistant transcript twice (SPECULATIVE, then FINAL) and the + SPA commits text only when ``is_final`` is set. 1.57 streams it once, + from the SPECULATIVE stage, and drops FINAL entirely, so every delta is + now the only pass and is forwarded with ``is_final=True``. + * ``bidi_response_start`` now fires at the *user's* first content, before + any user speech has been transcribed. The SPA flushes the user's + transcript into a message when it sees a response start, so forwarding + that event as-is would file each user message one turn late. The legacy + ``bidi_response_start`` is therefore emitted when the *assistant* begins + (its transcript or its audio), which is when 1.55 emitted it too. + * ``bidi_barge_in`` -> ``bidi_interruption``. + * ``bidi_response_stop`` -> ``bidi_response_complete``; ``stop_reason`` is + no longer on the event, so it is derived from whether a barge-in was + seen in this response. + * ``bidi_connection_stop`` -> ``bidi_connection_close``. + * Start/stop bracketing events with no 1.55 equivalent + (``bidi_transcript_start``/``_stop``, ``bidi_audio_start``/``_stop``) + are dropped. Everything else passes through unchanged. + """ + + _DROPPED = frozenset({"bidi_transcript_start", "bidi_transcript_stop", "bidi_audio_start", "bidi_audio_stop"}) + + def __init__(self) -> None: + self._response_id: Optional[str] = None + self._assistant_started = False + self._interrupted = False + + def translate(self, event: Dict[str, Any]) -> List[Dict[str, Any]]: + """Return the wire events for one Strands Bidi event (zero or more).""" + event_type = event.get("type", "") + + if event_type in self._DROPPED: + if event_type == "bidi_transcript_start" and event.get("role") == "assistant": + return self._start_assistant() + if event_type == "bidi_audio_start": + return self._start_assistant() + return [] + + if event_type == "bidi_response_start": + self._response_id = event.get("response_id") + self._assistant_started = False + self._interrupted = False + return [] + + if event_type == "bidi_transcript_delta": + delta = event.get("delta", "") + return [ + { + "type": "bidi_transcript_stream", + "role": event.get("role"), + "delta": {"text": delta}, + "text": delta, + "is_final": True, + "current_transcript": None, + } + ] + + if event_type == "bidi_audio_delta": + return [ + { + "type": "bidi_audio_stream", + "audio": event.get("audio"), + "format": event.get("format"), + "sample_rate": event.get("sample_rate"), + "channels": event.get("channels"), + } + ] + + if event_type == "bidi_barge_in": + self._interrupted = True + return [{"type": "bidi_interruption", "reason": event.get("reason")}] + + if event_type == "bidi_response_stop": + # A response that ended before the assistant produced anything + # (tool-only, or barged in immediately) still owes the SPA a start, + # or the user's words stay buffered and the SPA re-files the + # previous turn's text as this turn's transcript entry. + events = self._start_assistant() + events.append( + { + "type": "bidi_response_complete", + "response_id": event.get("response_id") or self._response_id, + "stop_reason": "interrupted" if self._interrupted else "complete", + } + ) + self._response_id = None + self._assistant_started = False + self._interrupted = False + return events + + if event_type == "bidi_connection_stop": + return [ + { + "type": "bidi_connection_close", + "connection_id": event.get("connection_id"), + "reason": event.get("reason"), + } + ] + + return [event] + + def _start_assistant(self) -> List[Dict[str, Any]]: + if self._assistant_started: + return [] + self._assistant_started = True + return [{"type": "bidi_response_start", "response_id": self._response_id}] + + class VoiceAgent(BaseAgent): """ Bidirectional voice agent using AWS Nova Sonic 2. @@ -62,8 +184,8 @@ class VoiceAgent(BaseAgent): agent = VoiceAgent(session_id="sess-123", enabled_tools=[...]) await agent.start() await agent.send_audio(audio_base64, sample_rate=16000) - async for event in agent.stream_async(""): - # BidiOutputEvent, BidiAudioStreamEvent, etc. + async for event in agent.receive_events(): + # wire-contract dicts: bidi_audio_stream, bidi_transcript_stream, etc. """ def __init__(self, voice: Optional[str] = None, **kwargs): @@ -85,6 +207,7 @@ def __init__(self, voice: Optional[str] = None, **kwargs): self._per_turn_usage: List[dict] = [] # Snapshot of usage per completed turn self._turn_count: int = 0 # Completed turns (bidi_response_complete) self._response_start_count: int = 0 # Started turns (bidi_response_start) + self._wire = VoiceWireAdapter() super().__init__(**kwargs) def _create_agent(self) -> None: @@ -103,19 +226,16 @@ def _create_agent(self) -> None: EnvVars.NOVA_SONIC_MODEL_ID, Defaults.NOVA_SONIC_MODEL_ID ) - # 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. + # 1.57 split the provider's audio config: the voice is its own + # `voice` kwarg, and `audio` carries per-direction sample rates + # only (channels and format are fixed at mono PCM). Unknown keys + # are rejected, so the 1.55 five-key dict no longer fits. model = BedrockNovaSonicModel( model_id=model_id, + voice=self._voice, audio={ - "voice": self._voice, - "input_rate": Defaults.NOVA_SONIC_INPUT_RATE, - "output_rate": Defaults.NOVA_SONIC_OUTPUT_RATE, - "channels": 1, - "format": "pcm", + "input": {"sample_rate": Defaults.NOVA_SONIC_INPUT_RATE}, + "output": {"sample_rate": Defaults.NOVA_SONIC_OUTPUT_RATE}, }, region=os.environ.get(EnvVars.AWS_REGION, Defaults.AWS_REGION), ) @@ -213,12 +333,16 @@ async def send_audio(self, audio_base64: str, sample_rate: int = 16000) -> None: if not self._bidi_agent: raise RuntimeError("Voice agent not started") + # 1.57 takes raw bytes in an `audio_delta` block; the sample rate is + # fixed by the model's audio config rather than sent per chunk. + if sample_rate != Defaults.NOVA_SONIC_INPUT_RATE: + logger.debug( + "Voice audio sample_rate=%s differs from the configured input rate %s", + sample_rate, + Defaults.NOVA_SONIC_INPUT_RATE, + ) await self._bidi_agent.send({ - "type": "bidi_audio_input", - "audio": audio_base64, - "format": "pcm", - "sample_rate": sample_rate, - "channels": 1, + "audio_delta": {"format": "pcm", "source": {"bytes": base64.b64decode(audio_base64)}}, }) async def send_text(self, text: str) -> None: @@ -231,11 +355,7 @@ async def send_text(self, text: str) -> None: if not self._bidi_agent: raise RuntimeError("Voice agent not started") - await self._bidi_agent.send({ - "type": "bidi_text_input", - "text": text, - "role": "user", - }) + await self._bidi_agent.send({"text": text}) @property def accumulated_usage(self) -> dict: @@ -280,73 +400,78 @@ async def receive_events(self) -> AsyncGenerator[dict, None]: if not self._bidi_agent: raise RuntimeError("Voice agent not started") - # Lazy-loaded pricing for real-time cost calculation - pricing_dict: Optional[dict] = None - pricing_loaded = False + # Lazy-loaded pricing for real-time cost calculation, fetched once + # and cached for the session lifetime. + pricing_state: dict = {"loaded": False, "pricing": None} async for event in self._bidi_agent.receive(): if hasattr(event, "as_dict"): - event_dict = event.as_dict() + raw_dict = event.as_dict() + else: + raw_dict = {"type": "unknown", "data": str(event)} + + for event_dict in self._wire.translate(raw_dict): + yield await self._account_and_enrich(event_dict, pricing_state) + + async def _account_and_enrich(self, event_dict: dict, pricing_state: dict) -> dict: + """Apply turn and usage accounting to one wire event and return it.""" + event_type = event_dict.get("type", "") + + # Log non-audio event types for debugging (skip audio to avoid noise) + if event_type not in ("bidi_audio_stream",): + if any(k in event_dict for k in ("usage", "inputTokens", "outputTokens", "totalTokens")): + logger.info(f"Voice event: type={event_type}, keys={list(event_dict.keys())}") else: - event_dict = {"type": "unknown", "data": str(event)} - - event_type = event_dict.get("type", "") - - # Log non-audio event types for debugging (skip audio to avoid noise) - if event_type not in ("bidi_audio_stream",): - if any(k in event_dict for k in ("usage", "inputTokens", "outputTokens", "totalTokens")): - logger.info(f"Voice event: type={event_type}, keys={list(event_dict.keys())}") - else: - logger.info(f"Voice event: type={event_type}") - - # Track started turns (bidi_response_start) - if event_type == "bidi_response_start": - self._response_start_count += 1 - logger.info(f"Voice response started (count={self._response_start_count})") - - # Update cumulative token usage from bidi_usage events. - # Nova Sonic reports CUMULATIVE totals in each event (not deltas), - # so we replace rather than sum. - if event_type == "bidi_usage": - usage = event_dict.get("usage", event_dict) - for key in ("inputTokens", "outputTokens", "totalTokens"): - self._accumulated_usage[key] = usage.get(key, self._accumulated_usage[key]) - logger.info(f"Voice bidi_usage snapshot: {usage}") - logger.info(f"Voice usage current: {self._accumulated_usage}") - - # Calculate real-time cost and enrich the event for the client. - # Pricing is fetched once and cached for the session lifetime. - if not pricing_loaded: - pricing_loaded = True - try: - from apis.shared.costs.pricing_config import get_model_pricing - pricing_dict = await get_model_pricing(self.voice_model_id) - except Exception as e: - logger.debug(f"Voice pricing unavailable: {e}") - - if pricing_dict and self._accumulated_usage.get("totalTokens", 0) > 0: - try: - from apis.shared.costs.calculator import CostCalculator - total_cost, breakdown = CostCalculator.calculate_message_cost( - self._accumulated_usage, pricing_dict - ) - event_dict["cost"] = { - "total": total_cost, - "inputCost": breakdown.input_cost, - "outputCost": breakdown.output_cost, - "cacheReadCost": breakdown.cache_read_cost, - "cacheWriteCost": breakdown.cache_write_cost, - } - except Exception as e: - logger.debug(f"Voice cost calculation error: {e}") - - # Track completed assistant turns and snapshot cumulative usage at turn boundary - if event_type == "bidi_response_complete": - self._per_turn_usage.append(self._accumulated_usage.copy()) - self._turn_count += 1 - logger.info(f"Voice turn {self._turn_count} complete, cumulative usage: {self._accumulated_usage}") - - yield event_dict + logger.info(f"Voice event: type={event_type}") + + # Track started turns (bidi_response_start) + if event_type == "bidi_response_start": + self._response_start_count += 1 + logger.info(f"Voice response started (count={self._response_start_count})") + + # Update cumulative token usage from bidi_usage events. + # Nova Sonic reports CUMULATIVE totals in each event (not deltas), + # so we replace rather than sum. + if event_type == "bidi_usage": + usage = event_dict.get("usage", event_dict) + for key in ("inputTokens", "outputTokens", "totalTokens"): + self._accumulated_usage[key] = usage.get(key, self._accumulated_usage[key]) + logger.info(f"Voice bidi_usage snapshot: {usage}") + logger.info(f"Voice usage current: {self._accumulated_usage}") + + # Calculate real-time cost and enrich the event for the client. + if not pricing_state["loaded"]: + pricing_state["loaded"] = True + try: + from apis.shared.costs.pricing_config import get_model_pricing + pricing_state["pricing"] = await get_model_pricing(self.voice_model_id) + except Exception as e: + logger.debug(f"Voice pricing unavailable: {e}") + + pricing_dict = pricing_state["pricing"] + if pricing_dict and self._accumulated_usage.get("totalTokens", 0) > 0: + try: + from apis.shared.costs.calculator import CostCalculator + total_cost, breakdown = CostCalculator.calculate_message_cost( + self._accumulated_usage, pricing_dict + ) + event_dict["cost"] = { + "total": total_cost, + "inputCost": breakdown.input_cost, + "outputCost": breakdown.output_cost, + "cacheReadCost": breakdown.cache_read_cost, + "cacheWriteCost": breakdown.cache_write_cost, + } + except Exception as e: + logger.debug(f"Voice cost calculation error: {e}") + + # Track completed assistant turns and snapshot cumulative usage at turn boundary + if event_type == "bidi_response_complete": + self._per_turn_usage.append(self._accumulated_usage.copy()) + self._turn_count += 1 + logger.info(f"Voice turn {self._turn_count} complete, cumulative usage: {self._accumulated_usage}") + + return event_dict async def stream_async( self, @@ -386,20 +511,19 @@ async def drain_remaining_events(self, timeout: float = 3.0) -> None: try: async with asyncio.timeout(timeout): async for event in self._bidi_agent.receive(): - if hasattr(event, "as_dict"): - event_dict = event.as_dict() - else: + if not hasattr(event, "as_dict"): continue - event_type = event_dict.get("type", "") - if event_type == "bidi_usage": - usage = event_dict.get("usage", event_dict) - for key in ("inputTokens", "outputTokens", "totalTokens"): - self._accumulated_usage[key] = usage.get(key, self._accumulated_usage[key]) - logger.info(f"Drained bidi_usage: {usage}, current: {self._accumulated_usage}") - elif event_type == "bidi_response_complete": - self._per_turn_usage.append(self._accumulated_usage.copy()) - self._turn_count += 1 - logger.info(f"Drained turn {self._turn_count} complete") + for event_dict in self._wire.translate(event.as_dict()): + event_type = event_dict.get("type", "") + if event_type == "bidi_usage": + usage = event_dict.get("usage", event_dict) + for key in ("inputTokens", "outputTokens", "totalTokens"): + self._accumulated_usage[key] = usage.get(key, self._accumulated_usage[key]) + logger.info(f"Drained bidi_usage: {usage}, current: {self._accumulated_usage}") + elif event_type == "bidi_response_complete": + self._per_turn_usage.append(self._accumulated_usage.copy()) + self._turn_count += 1 + logger.info(f"Drained turn {self._turn_count} complete") except (TimeoutError, asyncio.CancelledError, StopAsyncIteration): pass except Exception as e: diff --git a/backend/src/apis/app_api/kb_migration/requirements.txt b/backend/src/apis/app_api/kb_migration/requirements.txt index 0f1a5cf32..51944aed6 100644 --- a/backend/src/apis/app_api/kb_migration/requirements.txt +++ b/backend/src/apis/app_api/kb_migration/requirements.txt @@ -17,8 +17,8 @@ # members, `embeddingDataType` enum is ['FLOAT32','BINARY'], and all four # document operations — Ingest/Get/List/DeleteKnowledgeBaseDocuments — exist. # No `AWS_DATA_PATH` side-load is required, and none must be relied on. -boto3==1.43.68 -botocore==1.43.68 +boto3==1.43.103 +botocore==1.43.103 # Nothing else. The four handlers' whole import closure is 16 first-party # modules plus these two, which is the point of `kb_backend` being its own diff --git a/backend/src/apis/app_api/kb_sync/requirements.txt b/backend/src/apis/app_api/kb_sync/requirements.txt index bf99f470b..88e25eecc 100644 --- a/backend/src/apis/app_api/kb_sync/requirements.txt +++ b/backend/src/apis/app_api/kb_sync/requirements.txt @@ -1,9 +1,9 @@ # kb-sync Lambda image dependencies (backend/Dockerfile.kb-sync). # Exact pins per repo policy; versions match backend/uv.lock. -boto3==1.43.68 +boto3==1.43.103 pydantic==2.12.5 httpx==0.28.1 -bedrock-agentcore==1.21.0 +bedrock-agentcore==1.23.1 # Web re-crawl path (crawler markdown extraction) beautifulsoup4==4.13.5 trafilatura==2.0.0 diff --git a/backend/src/apis/shared/models/context_window.py b/backend/src/apis/shared/models/context_window.py index 0d45f52ce..278b08107 100644 --- a/backend/src/apis/shared/models/context_window.py +++ b/backend/src/apis/shared/models/context_window.py @@ -48,10 +48,14 @@ def sdk_context_window(model_id: Optional[str]) -> Optional[int]: """Strands' built-in context-window table for ``model_id``, or ``None``. Covers Anthropic-on-Bedrock and handles the cross-region prefix strip - (``us.anthropic.claude-sonnet-4-6`` resolves via ``anthropic.…``). It is - **empty for every non-Anthropic id we curate** — Astra, the GPT-5.6 - family, Kimi K3 and DeepSeek all return ``None`` — so this is a partial - safety net, not a second source of truth. + (``us.anthropic.claude-sonnet-4-6`` resolves via ``anthropic.…``). Since + strands-agents 1.56 the strip also walks nested prefixes, so the hosted + OpenAI ids resolve too: ``us.openai.gpt-6-astra`` and the GPT-5.6 family + return their full 1,050,000 window. That is the real window but **not** + our pricing cap — the curated rows' ``maxInputTokens: 272_000`` still wins + in :func:`resolve_context_window`, and a row with the field *absent* would + now fall through to 1,050,000. Kimi K3 and DeepSeek still return ``None``, + so this remains a partial safety net, not a second source of truth. ⚠️ ``strands.models._defaults`` is a private module on an experimental surface. Import failure is treated as "no fallback available", never as diff --git a/backend/src/apis/shared/models/usage_normalization.py b/backend/src/apis/shared/models/usage_normalization.py index 8a4b766f1..06d036da0 100644 --- a/backend/src/apis/shared/models/usage_normalization.py +++ b/backend/src/apis/shared/models/usage_normalization.py @@ -34,9 +34,12 @@ Strands never reads it off the Responses usage object, so ``cacheWriteInputTokens`` is structurally 0 for GPT-5.6 — which pins ``wastedUsd`` at $0 and makes the 1.25x write premium invisible, the same -blind spot that let the compaction spiral run unnoticed. An upstream patch is -in flight; until it lands (and on any older pin) this mapping is the only -source of the field. +blind spot that let the compaction spiral run unnoticed. For Responses this +mapping is still the only source of the field. Chat Completions gained it +upstream in strands-agents 1.57 (#4361): ``OpenAIModel`` now reports +``cacheWriteInputTokens`` itself, beside an ``inputTokens`` that is still +inclusive, and :func:`normalize_usage` subtracts it once like any other +write bucket. ⚠️ :func:`normalize_usage` is **not idempotent** for the OpenAI family — it subtracts. Apply it exactly once per usage payload, at the model seam. Do not diff --git a/backend/src/lambdas/scheduled_runs_dispatcher/requirements.txt b/backend/src/lambdas/scheduled_runs_dispatcher/requirements.txt index 7d1d18e39..d9615b697 100644 --- a/backend/src/lambdas/scheduled_runs_dispatcher/requirements.txt +++ b/backend/src/lambdas/scheduled_runs_dispatcher/requirements.txt @@ -1,9 +1,9 @@ # scheduled-runs Lambda image dependencies (backend/Dockerfile.scheduled-runs). # Exact pins per repo policy; versions match backend/uv.lock. -boto3==1.43.68 +boto3==1.43.103 pydantic==2.12.5 httpx==0.28.1 -bedrock-agentcore==1.21.0 +bedrock-agentcore==1.23.1 # Worker-only transitive deps (both handlers share ONE image, and the # Dockerfile installs THIS file): the worker imports apis.shared.harness -> # apis.shared.sessions_bff, whose cookie.py needs cryptography (AESGCM) and diff --git a/backend/src/lambdas/scheduled_runs_worker/requirements.txt b/backend/src/lambdas/scheduled_runs_worker/requirements.txt index ffd05d98c..7b4ff80bc 100644 --- a/backend/src/lambdas/scheduled_runs_worker/requirements.txt +++ b/backend/src/lambdas/scheduled_runs_worker/requirements.txt @@ -1,9 +1,9 @@ # scheduled-runs Lambda image dependencies (backend/Dockerfile.scheduled-runs). # Exact pins per repo policy; versions match backend/uv.lock. -boto3==1.43.68 +boto3==1.43.103 pydantic==2.12.5 httpx==0.28.1 -bedrock-agentcore==1.21.0 +bedrock-agentcore==1.23.1 # Worker transitive deps: apis.shared.harness -> apis.shared.sessions_bff, # whose cookie.py needs cryptography (AESGCM) and cache.py needs cachetools # (TTLCache). Kept in sync with the dispatcher requirements.txt, which is the diff --git a/backend/tests/agents/main_agent/session/test_bidi_agent_guards.py b/backend/tests/agents/main_agent/session/test_bidi_agent_guards.py new file mode 100644 index 000000000..5ac7e299e --- /dev/null +++ b/backend/tests/agents/main_agent/session/test_bidi_agent_guards.py @@ -0,0 +1,60 @@ +"""TurnBasedSessionManager's text-only work must not run for the voice BidiAgent. + +Since strands-agents 1.56 a ``BidiAgent`` drives the ordinary session hooks +(``AgentInitializedEvent`` -> ``initialize``, ``MessageAddedEvent`` -> +``retrieve_customer_context``) rather than the dedicated Bidi callbacks it used +through 1.55. Without these guards the voice agent's history would be sliced by +the text agent's session-level compaction checkpoint, and every voice transcript +would trigger a long-term-memory retrieval spliced into the live Bidi history. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +from strands.experimental.bidi import BidiAgent + +from agents.main_agent.session import turn_based_session_manager as tbsm + + +def _bidi_agent(messages): + agent = BidiAgent.__new__(BidiAgent) + agent.messages = messages + agent.agent_id = "voice" + return agent + + +def test_bidi_detection(): + assert tbsm._is_bidi_agent(_bidi_agent([])) is True + assert tbsm._is_bidi_agent(SimpleNamespace(messages=[], agent_id="default")) is False + + +def test_retrieve_customer_context_skips_a_bidi_agent(make_session_manager): + mgr = make_session_manager() + mgr.config.retrieval_config = {"/ns": SimpleNamespace(top_k=5, relevance_score=0.1, strategy_id=None)} + client = MagicMock() + mgr._retrieval_client = client + message = {"role": "user", "content": [{"text": "what's my name?"}]} + + mgr.retrieve_customer_context(SimpleNamespace(agent=_bidi_agent([message]))) + + client.retrieve_memory_records.assert_not_called() + assert message == {"role": "user", "content": [{"text": "what's my name?"}]} + + +def test_initialize_restores_a_bidi_agent_without_text_processing(make_session_manager): + mgr = make_session_manager() + mgr.read_agent = MagicMock(return_value=SimpleNamespace()) + restored = [ + {"role": "user", "content": [{"text": "hi"}]}, + {"role": "assistant", "content": [{"text": "hello"}]}, + ] + mgr.list_messages = MagicMock(return_value=[MagicMock(to_message=lambda m=m: m) for m in restored]) + mgr._apply_compaction = MagicMock(side_effect=AssertionError("text compaction ran for voice")) + mgr._repair_restored_history = MagicMock(side_effect=AssertionError("text repair ran for voice")) + agent = _bidi_agent([]) + + mgr.initialize(agent) + + assert [m["role"] for m in agent.messages] == ["user", "assistant"] + mgr._apply_compaction.assert_not_called() + mgr._repair_restored_history.assert_not_called() diff --git a/backend/tests/agents/main_agent/test_voice_agent.py b/backend/tests/agents/main_agent/test_voice_agent.py index a67ecdf3b..ae25ed517 100644 --- a/backend/tests/agents/main_agent/test_voice_agent.py +++ b/backend/tests/agents/main_agent/test_voice_agent.py @@ -78,20 +78,58 @@ def test_voice_agent_imports_the_current_provider_name(self): 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.""" + def test_provider_takes_audio_voice_and_region_kwargs(self): + """1.57 split `voice` out of the audio config; region stays a kwarg.""" source = self._provider_source() - assert "audio: AudioConfig | None = None" in source + assert "audio: BedrockNovaSonicAudioConfig | None = None" in source + assert "voice: str =" 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_audio_config_carries_the_per_direction_rates_we_send(self): + """VoiceAgent sends {"input": {"sample_rate"}, "output": {"sample_rate"}}.""" + from strands.experimental.bidi.models.configs import ( + BedrockNovaSonicAudioConfig, + BedrockNovaSonicAudioStreamConfig, ) + assert {"input", "output"} == set(BedrockNovaSonicAudioConfig.__annotations__) + assert set(BedrockNovaSonicAudioStreamConfig.__annotations__) == {"sample_rate"} + assert Defaults.NOVA_SONIC_INPUT_RATE in (8000, 16000, 24000) + assert Defaults.NOVA_SONIC_OUTPUT_RATE in (8000, 16000, 24000) + + def test_agent_send_accepts_the_input_shapes_we_use(self): + """send_audio / send_text build `audio_delta` and `text` dicts.""" + import importlib.util + import pathlib + + spec = importlib.util.find_spec("strands.experimental.bidi") + source = (pathlib.Path(spec.origin).parent / "agent" / "agent.py").read_text() + assert '"audio_delta" in content_data' in source + assert '"text" in content_data' in source + + def test_output_event_names_the_wire_adapter_translates(self): + """If upstream renames these again, VoiceWireAdapter must follow.""" + import importlib.util + import pathlib + + spec = importlib.util.find_spec("strands.experimental.bidi") + source = (pathlib.Path(spec.origin).parent / "types" / "events.py").read_text() + for name in ( + "bidi_response_start", + "bidi_response_stop", + "bidi_transcript_start", + "bidi_transcript_delta", + "bidi_transcript_stop", + "bidi_audio_start", + "bidi_audio_delta", + "bidi_audio_stop", + "bidi_barge_in", + "bidi_usage", + "bidi_connection_stop", + ): + assert f'"type": "{name}"' in source, name + 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() @@ -230,3 +268,153 @@ def test_pyaudio_in_sys_modules(self): # After importing voice_agent, pyaudio should be mocked import agents.main_agent.voice_agent # noqa: F401 assert "pyaudio" in sys.modules + + +class TestVoiceWireAdapter: + """Strands 1.57 Bidi events -> the 1.55 wire contract the SPA speaks.""" + + def _turn(self, *events): + from agents.main_agent.voice_agent import VoiceWireAdapter + + adapter = VoiceWireAdapter() + out = [] + for event in events: + out.extend(adapter.translate(event)) + return out + + def test_user_speech_is_flushed_before_the_assistant_starts(self): + out = self._turn( + {"type": "bidi_response_start", "response_id": "r1"}, + {"type": "bidi_transcript_start", "role": "user", "content_id": "u"}, + {"type": "bidi_transcript_delta", "delta": "hello", "role": "user", "content_id": "u"}, + {"type": "bidi_transcript_stop", "transcript": "hello", "role": "user", "content_id": "u"}, + {"type": "bidi_transcript_start", "role": "assistant", "content_id": "a"}, + {"type": "bidi_transcript_delta", "delta": "hi there", "role": "assistant", "content_id": "a"}, + {"type": "bidi_audio_start"}, + {"type": "bidi_audio_delta", "audio": "QUJD", "format": "pcm", "sample_rate": 16000, "channels": 1}, + {"type": "bidi_audio_stop"}, + {"type": "bidi_transcript_stop", "transcript": "hi there", "role": "assistant", "content_id": "a"}, + {"type": "bidi_response_stop", "response_id": "r1"}, + ) + assert [e["type"] for e in out] == [ + "bidi_transcript_stream", + "bidi_response_start", + "bidi_transcript_stream", + "bidi_audio_stream", + "bidi_response_complete", + ] + assert out[0]["role"] == "user" and out[0]["delta"] == {"text": "hello"} + assert out[1]["response_id"] == "r1" + assert out[2]["role"] == "assistant" and out[2]["is_final"] is True + assert out[3] == { + "type": "bidi_audio_stream", + "audio": "QUJD", + "format": "pcm", + "sample_rate": 16000, + "channels": 1, + } + assert out[4] == {"type": "bidi_response_complete", "response_id": "r1", "stop_reason": "complete"} + + def test_exactly_one_start_per_response(self): + out = self._turn( + {"type": "bidi_response_start", "response_id": "r1"}, + {"type": "bidi_transcript_start", "role": "assistant", "content_id": "a"}, + {"type": "bidi_audio_start"}, + {"type": "bidi_response_stop", "response_id": "r1"}, + {"type": "bidi_response_start", "response_id": "r2"}, + {"type": "bidi_audio_start"}, + {"type": "bidi_response_stop", "response_id": "r2"}, + ) + assert [e["type"] for e in out].count("bidi_response_start") == 2 + + def test_barge_in_maps_to_interruption_and_an_interrupted_complete(self): + out = self._turn( + {"type": "bidi_response_start", "response_id": "r1"}, + {"type": "bidi_audio_start"}, + {"type": "bidi_barge_in", "reason": "user_speech"}, + {"type": "bidi_response_stop", "response_id": "r1"}, + ) + assert out[1] == {"type": "bidi_interruption", "reason": "user_speech"} + assert out[-1]["stop_reason"] == "interrupted" + + def test_a_response_with_no_assistant_output_still_opens_before_it_closes(self): + out = self._turn( + {"type": "bidi_response_start", "response_id": "r1"}, + {"type": "bidi_response_stop", "response_id": "r1"}, + ) + assert [e["type"] for e in out] == ["bidi_response_start", "bidi_response_complete"] + + def test_connection_stop_maps_to_close_and_the_rest_pass_through(self): + out = self._turn( + {"type": "bidi_connection_start", "connection_id": "c", "model": "m"}, + {"type": "bidi_usage", "inputTokens": 1, "outputTokens": 2, "totalTokens": 3}, + {"type": "bidi_connection_stop", "connection_id": "c", "reason": "complete"}, + ) + assert out[0]["type"] == "bidi_connection_start" + assert out[1]["type"] == "bidi_usage" and out[1]["totalTokens"] == 3 + assert out[2] == {"type": "bidi_connection_close", "connection_id": "c", "reason": "complete"} + + +class TestVoiceAgentSend: + """send_audio / send_text speak the 1.57 BidiAgent.send() input shapes.""" + + def _agent(self): + from unittest.mock import AsyncMock + + from agents.main_agent.voice_agent import VoiceAgent + + agent = VoiceAgent.__new__(VoiceAgent) + agent._bidi_agent = MagicMock() + agent._bidi_agent.send = AsyncMock() + return agent + + @pytest.mark.asyncio + async def test_send_audio_decodes_base64_into_an_audio_delta(self): + import base64 + + agent = self._agent() + await agent.send_audio(base64.b64encode(b"\x00\x01").decode(), sample_rate=16000) + agent._bidi_agent.send.assert_awaited_once_with( + {"audio_delta": {"format": "pcm", "source": {"bytes": b"\x00\x01"}}} + ) + + @pytest.mark.asyncio + async def test_send_text_sends_a_text_block(self): + agent = self._agent() + await agent.send_text("hello") + agent._bidi_agent.send.assert_awaited_once_with({"text": "hello"}) + + @pytest.mark.asyncio + async def test_receive_events_counts_turns_on_the_translated_stream(self): + from agents.main_agent.voice_agent import VoiceWireAdapter + + class _Evt(dict): + def as_dict(self): + return dict(self) + + raw = [ + _Evt(type="bidi_response_start", response_id="r1"), + _Evt(type="bidi_audio_start"), + _Evt(type="bidi_usage", inputTokens=10, outputTokens=5, totalTokens=15), + _Evt(type="bidi_response_stop", response_id="r1"), + ] + + async def _receive(): + for event in raw: + yield event + + agent = self._agent() + agent._bidi_agent.receive = _receive + agent._wire = VoiceWireAdapter() + agent._accumulated_usage = {"inputTokens": 0, "outputTokens": 0, "totalTokens": 0} + agent._per_turn_usage = [] + agent._turn_count = 0 + agent._response_start_count = 0 + + with patch("apis.shared.costs.pricing_config.get_model_pricing", side_effect=RuntimeError("no pricing")): + out = [e async for e in agent.receive_events()] + + assert [e["type"] for e in out] == ["bidi_response_start", "bidi_usage", "bidi_response_complete"] + assert agent.response_start_count == 1 + assert agent.turn_count == 1 + assert agent.per_turn_usage == [{"inputTokens": 10, "outputTokens": 5, "totalTokens": 15}] diff --git a/backend/tests/shared/test_context_window_resolution.py b/backend/tests/shared/test_context_window_resolution.py index da478315b..c2d5731ab 100644 --- a/backend/tests/shared/test_context_window_resolution.py +++ b/backend/tests/shared/test_context_window_resolution.py @@ -34,18 +34,29 @@ def test_haiku_45_really_is_200k(self): @pytest.mark.parametrize( "model_id", [ - "us.openai.gpt-6-astra", - "us.openai.gpt-5.6-sol", "us.moonshotai.kimi-k3", "us.deepseek.v3-2", ], ) def test_non_anthropic_ids_are_absent(self, model_id): - # The fallback is a partial safety net. If this starts returning values, - # the 272K pricing cap on the OpenAI rows needs re-checking (it would - # begin tripping the disagreement warning). + # The fallback is a partial safety net. If one of these starts + # returning a value, check it against the catalog row the way the + # hosted OpenAI rows below were checked. assert cw.sdk_context_window(model_id) is None + @pytest.mark.parametrize("model_id", ["us.openai.gpt-6-astra", "us.openai.gpt-5.6-sol"]) + def test_hosted_openai_ids_now_resolve_to_their_full_window(self, model_id): + # strands-agents 1.56+ strips nested prefixes (`us.` then `openai.`) + # and its table carries these at 1,050,000. The curated rows pin + # maxInputTokens at 272,000 on purpose (the pricing tier), so the + # catalog value still wins; the pair now logs one disagreement warning + # per process, which is expected rather than a stale row. + assert cw.sdk_context_window(model_id) == 1_050_000 + + def test_the_curated_272k_cap_still_wins_over_the_sdk_window(self): + window, source = cw.resolve_context_window("us.openai.gpt-6-astra", 272_000) + assert (window, source) == (272_000, "disagreement") + def test_missing_model_id_is_not_an_error(self): assert cw.sdk_context_window(None) is None assert cw.sdk_context_window("") is None @@ -77,7 +88,7 @@ def test_sdk_fills_an_absent_catalog_value(self): assert (window, source) == (1_000_000, "sdk") def test_both_absent_reports_nothing_rather_than_guessing(self): - assert cw.resolve_context_window("us.openai.gpt-6-astra", None) == (None, None) + assert cw.resolve_context_window("us.moonshotai.kimi-k3", None) == (None, None) @pytest.mark.parametrize("bad", [0, -1, "", "not-a-number", None]) def test_unusable_catalog_values_fall_through(self, bad): diff --git a/backend/tests/shared/test_usage_normalization.py b/backend/tests/shared/test_usage_normalization.py index 12cc96bfb..2cfe8ebc0 100644 --- a/backend/tests/shared/test_usage_normalization.py +++ b/backend/tests/shared/test_usage_normalization.py @@ -229,6 +229,55 @@ def test_sdk_still_reports_inclusive_input_and_drops_cache_writes(self): with pytest.raises(AssertionError): _assert_disjoint(usage) + def test_chat_completions_sdk_now_surfaces_cache_writes(self): + """strands-agents 1.57 (#4361) maps Chat Completions cache writes. + + They arrive as ``cacheWriteInputTokens`` beside an ``inputTokens`` that + is still inclusive, so the shim must subtract them exactly once — which + is what ``normalize_usage`` already does for any write bucket present. + """ + from openai.types.completion_usage import CompletionUsage + from strands.models.openai import OpenAIModel + + wire = { + "prompt_tokens": 10_000, + "completion_tokens": 50, + "total_tokens": 10_050, + "prompt_tokens_details": {"cached_tokens": 6_000, "cache_write_tokens": 3_000}, + } + usage_obj = CompletionUsage.model_validate(wire) + + raw = OpenAIModel(client_args={"api_key": "test-key"}, model_id="openai.gpt-5.6").format_chunk( + {"chunk_type": "metadata", "data": usage_obj} + )["metadata"]["usage"] + assert raw["inputTokens"] == 10_000 + assert raw["cacheWriteInputTokens"] == 3_000 + + normalized = usage_normalized(OpenAIModel)( + client_args={"api_key": "test-key"}, model_id="openai.gpt-5.6" + ).format_chunk({"chunk_type": "metadata", "data": usage_obj})["metadata"]["usage"] + assert normalized["inputTokens"] == 1_000 + assert normalized["cacheReadInputTokens"] == 6_000 + assert normalized["cacheWriteInputTokens"] == 3_000 + _assert_disjoint(normalized) + + def test_bedrock_usage_is_still_disjoint_upstream(self): + """Tripwire for strands-agents/harness-sdk#4618. + + That proposal would make the Bedrock adapter fold cache tokens into + ``inputTokens``, and delete the read-time ``_total_prompt_tokens`` sum + that exists only because Bedrock reports disjoint buckets. When this + helper disappears, ``normalize_usage``'s "leave Bedrock untouched" + contract inverts and every cached Bedrock token is priced twice — the + bump that removes it must carry the matching normalization change. + """ + from strands.telemetry import metrics + + assert hasattr(metrics, "_total_prompt_tokens") + usage = {"inputTokens": 100, "outputTokens": 1, "totalTokens": 30_501, "cacheReadInputTokens": 30_000, + "cacheWriteInputTokens": 400} + assert metrics._total_prompt_tokens(usage) == 30_500 + def test_usage_normalized_raises_when_the_seam_disappears(self): class _Seamless: pass diff --git a/backend/tests/supply_chain/test_strands_agentcore_pairing.py b/backend/tests/supply_chain/test_strands_agentcore_pairing.py new file mode 100644 index 000000000..b21a9b7a5 --- /dev/null +++ b/backend/tests/supply_chain/test_strands_agentcore_pairing.py @@ -0,0 +1,207 @@ +"""Guard rails for the Strands / AgentCore / MCP pin set. + +Two rules that used to live only as prose in the kaizen queue, now asserted: + +1. **`strands-agents` >= 1.56 and `bedrock-agentcore` >= 1.23.1 move together.** + A half-bumped pair imports fine locally and in the suite, then fails on the + AgentCore Runtime as "initialization time exceeded (30s)", which reaches + the user as a 502. Nothing in a unit test would otherwise notice, so the + pairing is checked on the declared pins, on what `uv.lock` resolved, and + on the Lambda images that install `bedrock-agentcore` from their own + hand-maintained requirements files. + +2. **`mcp` stays below 2 because we declared it, not by accident.** + `strands-agents` itself allows `mcp<2.2`. Without a declared constraint the + only thing holding the 1.x line is `idna==3.15` (mcp 2.x -> httpx2 -> idna + >=3.18), so a routine security bump of `idna` would silently cross the + major version under `integrations/mcp_apps.py`'s `ClientSession` patch. + See the comment on `[tool.uv].constraint-dependencies` in pyproject.toml. +""" + +import re +import sys +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +from packaging.requirements import Requirement +from packaging.specifiers import SpecifierSet +from packaging.version import Version + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib # type: ignore[no-redef] + +BACKEND_ROOT = Path(__file__).resolve().parents[2] +PYPROJECT_PATH = BACKEND_ROOT / "pyproject.toml" +UV_LOCK_PATH = BACKEND_ROOT / "uv.lock" + +STRANDS_PAIRING_FLOOR = Version("1.56.0") +AGENTCORE_PAIRING_FLOOR = Version("1.23.1") + +# Lambda images that pip-install from their own requirements file rather +# than from uv.lock. Each says "versions match backend/uv.lock" in its header. +LAMBDA_REQUIREMENTS_FILES = [ + BACKEND_ROOT / "src" / "lambdas" / "scheduled_runs_worker" / "requirements.txt", + BACKEND_ROOT / "src" / "lambdas" / "scheduled_runs_dispatcher" / "requirements.txt", + BACKEND_ROOT / "src" / "apis" / "app_api" / "kb_sync" / "requirements.txt", + BACKEND_ROOT / "src" / "apis" / "app_api" / "kb_migration" / "requirements.txt", +] + +# Packages whose Lambda-side pins must equal the lockfile. boto3/botocore are +# included because bedrock-agentcore 1.23.1 raised its boto floor to 1.43.72: +# an agentcore bump without the boto bump fails `pip install` at image build. +LOCK_MATCHED_PACKAGES = ("bedrock-agentcore", "boto3", "botocore") + + +def _load_toml(path: Path) -> dict: + with open(path, "rb") as f: + return tomllib.load(f) + + +def _all_declared_requirements(pyproject: dict) -> List[Requirement]: + project = pyproject["project"] + reqs = [Requirement(dep) for dep in project.get("dependencies", [])] + for group, deps in project.get("optional-dependencies", {}).items(): + if group == "all": + continue + reqs.extend(Requirement(dep) for dep in deps) + return reqs + + +def _exact_pins(pyproject: dict, name: str) -> List[Tuple[Requirement, Version]]: + pins = [] + for req in _all_declared_requirements(pyproject): + if req.name != name: + continue + specs = list(req.specifier) + assert len(specs) == 1 and specs[0].operator == "==", f"{req} is not a single exact pin" + pins.append((req, Version(specs[0].version))) + return pins + + +def _declared_version(pyproject: dict, name: str) -> Version: + pins = _exact_pins(pyproject, name) + assert pins, f"{name} is not pinned in pyproject.toml" + versions = {v for _, v in pins} + assert len(versions) == 1, ( + f"{name} is pinned at more than one version across pyproject.toml " + f"groups: {sorted(str(r) for r, _ in pins)}. The base package and its " + "extras (e.g. strands-agents and strands-agents[bidi]) must move together." + ) + return versions.pop() + + +def _locked_versions() -> Dict[str, Version]: + lock = _load_toml(UV_LOCK_PATH) + return {pkg["name"]: Version(pkg["version"]) for pkg in lock["package"] if "version" in pkg} + + +def _requirements_pin(path: Path, name: str) -> Optional[Version]: + pattern = re.compile(rf"^\s*{re.escape(name)}\s*==\s*([^\s#;]+)", re.IGNORECASE) + for line in path.read_text().splitlines(): + match = pattern.match(line) + if match: + return Version(match.group(1)) + return None + + +def _paired(strands: Version, agentcore: Version) -> bool: + return (strands >= STRANDS_PAIRING_FLOOR) == (agentcore >= AGENTCORE_PAIRING_FLOOR) + + +# --------------------------------------------------------------------------- +# Guard rail 1: strands >= 1.56 <=> bedrock-agentcore >= 1.23.1 +# --------------------------------------------------------------------------- + + +def test_declared_strands_and_agentcore_pins_are_paired(): + pyproject = _load_toml(PYPROJECT_PATH) + strands = _declared_version(pyproject, "strands-agents") + agentcore = _declared_version(pyproject, "bedrock-agentcore") + + assert _paired(strands, agentcore), ( + f"strands-agents=={strands} with bedrock-agentcore=={agentcore} breaks the " + f"pairing: strands >= {STRANDS_PAIRING_FLOOR} requires bedrock-agentcore >= " + f"{AGENTCORE_PAIRING_FLOOR} and vice versa. A half-bumped pair presents on " + "the AgentCore Runtime as 'initialization time exceeded (30s)' -> 502." + ) + + +def test_locked_strands_and_agentcore_match_declared_pins(): + pyproject = _load_toml(PYPROJECT_PATH) + locked = _locked_versions() + + for name in ("strands-agents", "bedrock-agentcore"): + declared = _declared_version(pyproject, name) + assert name in locked, f"{name} missing from uv.lock" + assert locked[name] == declared, ( + f"uv.lock resolves {name}=={locked[name]} but pyproject.toml pins " + f"{declared}. Run `uv lock` in backend/." + ) + + assert _paired(locked["strands-agents"], locked["bedrock-agentcore"]) + + +def test_lambda_requirements_match_lock_for_agentcore_and_boto(): + locked = _locked_versions() + mismatches = [] + checked = 0 + + for path in LAMBDA_REQUIREMENTS_FILES: + assert path.exists(), f"Lambda requirements file not found: {path}" + for name in LOCK_MATCHED_PACKAGES: + pinned = _requirements_pin(path, name) + if pinned is None: + continue + checked += 1 + if pinned != locked[name]: + rel = path.relative_to(BACKEND_ROOT) + mismatches.append(f" {rel}: {name}=={pinned} (uv.lock has {locked[name]})") + + assert checked > 0, "No bedrock-agentcore/boto3/botocore pins found in Lambda requirements files" + assert not mismatches, "Lambda requirements drifted from backend/uv.lock:\n" + "\n".join(mismatches) + + +# --------------------------------------------------------------------------- +# Guard rail 2: mcp < 2 is held by a DECLARED constraint +# --------------------------------------------------------------------------- + + +def _declared_mcp_constraint(pyproject: dict) -> SpecifierSet: + constraints = pyproject.get("tool", {}).get("uv", {}).get("constraint-dependencies", []) + specs = [Requirement(c).specifier for c in constraints if Requirement(c).name == "mcp"] + assert specs, ( + "pyproject.toml [tool.uv].constraint-dependencies no longer constrains `mcp`. " + "strands-agents allows mcp<2.2, so nothing but an unrelated idna pin would " + "keep the resolver on mcp 1.x. Restore `mcp<2` or lift it deliberately " + "together with a test pass over integrations/mcp_apps.py." + ) + combined = SpecifierSet() + for spec in specs: + combined &= spec + return combined + + +def test_mcp_below_2_is_a_declared_constraint(): + combined = _declared_mcp_constraint(_load_toml(PYPROJECT_PATH)) + + for probe in ("2.0.0", "2.0.0rc1", "2.1.0", "2.2.0"): + assert not combined.contains(probe, prereleases=True), ( + f"[tool.uv] constraint on mcp ({combined}) admits {probe}; it must exclude every 2.x release." + ) + assert combined.contains("1.30.0"), f"[tool.uv] constraint on mcp ({combined}) excludes the 1.x line entirely" + + +def test_uv_lock_was_resolved_under_the_mcp_constraint(): + lock = _load_toml(UV_LOCK_PATH) + manifest_constraints = lock.get("manifest", {}).get("constraints", []) + mcp_entries = [c for c in manifest_constraints if c.get("name") == "mcp"] + + assert mcp_entries, "uv.lock [manifest] carries no mcp constraint; it was not resolved under pyproject's [tool.uv] constraint" + for entry in mcp_entries: + assert not SpecifierSet(entry["specifier"]).contains("2.0.0", prereleases=True) + + locked = _locked_versions() + assert "mcp" in locked, "mcp missing from uv.lock" + assert locked["mcp"] < Version("2"), f"uv.lock resolved mcp=={locked['mcp']}" diff --git a/backend/uv.lock b/backend/uv.lock index 967c3d633..a42cde6ec 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -104,9 +104,9 @@ requires-dist = [ { name = "aws-bedrock-token-generator", marker = "extra == 'agentcore'", specifier = "==1.1.0" }, { name = "aws-opentelemetry-distro", marker = "extra == 'agentcore'", specifier = "==0.19.0" }, { name = "beautifulsoup4", specifier = "==4.13.5" }, - { name = "bedrock-agentcore", marker = "extra == 'agentcore'", specifier = "==1.21.0" }, + { name = "bedrock-agentcore", marker = "extra == 'agentcore'", specifier = "==1.23.1" }, { name = "black", marker = "extra == 'dev'", specifier = "==26.3.1" }, - { name = "boto3", specifier = "==1.43.68" }, + { name = "boto3", specifier = "==1.43.103" }, { name = "cachetools", specifier = "==6.2.4" }, { name = "cryptography", specifier = "==50.0.1" }, { name = "fastapi", specifier = "==0.136.1" }, @@ -133,8 +133,8 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.12" }, { name = "soupsieve", specifier = "==2.9.0" }, { name = "starlette", specifier = "==1.3.1" }, - { name = "strands-agents", marker = "extra == 'agentcore'", specifier = "==1.55.0" }, - { name = "strands-agents", extras = ["bidi"], marker = "extra == 'bidi'", specifier = "==1.55.0" }, + { name = "strands-agents", marker = "extra == 'agentcore'", specifier = "==1.57.1" }, + { name = "strands-agents", extras = ["bidi"], marker = "extra == 'bidi'", specifier = "==1.57.1" }, { name = "strands-agents-tools", marker = "extra == 'agentcore'", specifier = "==0.8.8" }, { name = "tiktoken", marker = "extra == 'dev'", specifier = "==0.12.0" }, { name = "trafilatura", specifier = "==2.0.0" }, @@ -516,43 +516,43 @@ wheels = [ [[package]] name = "awscrt" -version = "0.32.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/4d/c2aece4af7b5537c855548f53ee077d01216a1a4adbf0fd24f23dbac52bf/awscrt-0.32.0.tar.gz", hash = "sha256:92e749fce6c61da8db1af0baa6b7e96f7acf8a5574760b3d7880d190cedee8a0", size = 36832208, upload-time = "2026-03-27T01:19:18.147Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/9f/ded80b31ea4e30be66cf8dd35ecb0bce3e5a2fee39542bd877d7c2f2c2e4/awscrt-0.32.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:211cee3a5335bbc96405afdbdebf9c17bf16f3a299e9d8b661d5c904634ade43", size = 3391016, upload-time = "2026-03-27T01:17:58.557Z" }, - { url = "https://files.pythonhosted.org/packages/a3/8e/81ad2ad2c90faba849f106f4ee8c2f00741b1137225e965ba2be4b36fd9d/awscrt-0.32.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:693f40a004270dfc7e0f92f7d36ef5fb8e40de686fa87a0603ade74a479bbf40", size = 3930641, upload-time = "2026-03-27T01:18:01.454Z" }, - { url = "https://files.pythonhosted.org/packages/b8/09/193d7da3033e0c2f283f0c1a017a6e0918c91e0f57c3e4a18314ba7c97ad/awscrt-0.32.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3b3014bd320ce95a3698f8b00312a06accc6222505ace4e6cbb524aa10ab8654", size = 4217980, upload-time = "2026-03-27T01:18:02.909Z" }, - { url = "https://files.pythonhosted.org/packages/3c/fa/63fc0f4573bf29135fde0b5a168c8d2c003344a342e7a342ab6e13ded795/awscrt-0.32.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:43ea8a3bb994259b1c2cb6fc400374cc6c84a2b235eada3370d5f012420427dd", size = 3861043, upload-time = "2026-03-27T01:18:04.424Z" }, - { url = "https://files.pythonhosted.org/packages/95/57/e64e365c9112cbe4b04982effb22d62d57728513555c9516879598ebfea9/awscrt-0.32.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7cec6175c9616840a599050a0e6157846b509e7b3a7f2ab9f8e9a64bcb342e68", size = 4101797, upload-time = "2026-03-27T01:18:05.758Z" }, - { url = "https://files.pythonhosted.org/packages/7c/2f/2a3decaa3111c47fbec0a051767838741b52c61e37c215cb9c905d74ed5b/awscrt-0.32.0-cp310-cp310-win32.whl", hash = "sha256:0a585e9089140f55a8452c2f1aefe17712d39eff5413c1c0c7aa393e749fd5b7", size = 4041085, upload-time = "2026-03-27T01:18:07.28Z" }, - { url = "https://files.pythonhosted.org/packages/b3/63/72f38c224bd38e383b1e08713c655c0756a41c6d0e7e59616e7d47b6ec35/awscrt-0.32.0-cp310-cp310-win_amd64.whl", hash = "sha256:d4423dbdb75ed37bbd9697a672bfd75a02a7354ac9b818fa2d38059726171928", size = 4204956, upload-time = "2026-03-27T01:18:08.947Z" }, - { url = "https://files.pythonhosted.org/packages/9f/74/1e63af11b71ca90e6bcc70affea6400078d4cf6605f0593fe0a91a1daeb5/awscrt-0.32.0-cp311-abi3-macosx_10_15_universal2.whl", hash = "sha256:4ca7040b279cf6014c06de93be7a29a164c9c92469eb79c70143853873e81949", size = 3391226, upload-time = "2026-03-27T01:18:10.231Z" }, - { url = "https://files.pythonhosted.org/packages/43/62/d1383a31d32b9963a3a646d926f77a46d88cee1a86536186ad0ac0c44aea/awscrt-0.32.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:15a96559710e559bc4131b7af55b93c0c79505d4b9c4c4511b3b825bbb4f82a9", size = 3889898, upload-time = "2026-03-27T01:18:11.484Z" }, - { url = "https://files.pythonhosted.org/packages/2c/92/37c25e283ed4ebe21117fb183c1dbcd947d72fb770f05f9f1e8c2b63541e/awscrt-0.32.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:11a8cf1902c35ad784ceedbe2d5b44956a34bb2b7c3c818511ce93bdb21bc386", size = 4178874, upload-time = "2026-03-27T01:18:13.128Z" }, - { url = "https://files.pythonhosted.org/packages/eb/23/6dbefd6efbe0914c73f633ea6702aa4701425337f4e0c1059ec99aedde23/awscrt-0.32.0-cp311-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f1eea556144c2999e105966bbe97a0460d10d656e331bc2c875f15ece3315c3b", size = 3800811, upload-time = "2026-03-27T01:18:14.848Z" }, - { url = "https://files.pythonhosted.org/packages/5d/2e/0af9a203fea97504e0bd11261b12422ff555745699c176dee1767f09c9f6/awscrt-0.32.0-cp311-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:d7ef6f0e728c1b7a0a4b771d4c677a00bc1bf377b8b2dd59a1dd626b920efc3b", size = 4039356, upload-time = "2026-03-27T01:18:16.684Z" }, - { url = "https://files.pythonhosted.org/packages/c4/8a/6371dc9dc7b4a8dfafe7ed1b4f30500cc22e239413a6fdcaab72f8b80b8e/awscrt-0.32.0-cp311-abi3-win32.whl", hash = "sha256:8cee2fea902452a36f67f9d79e6eb406d4359854dad6df439b3c671f07059763", size = 4039614, upload-time = "2026-03-27T01:18:17.979Z" }, - { url = "https://files.pythonhosted.org/packages/75/c2/0bd9346f22ced5f11dac7039876ea2824cc3e268b6b681e2a8a29b1e8701/awscrt-0.32.0-cp311-abi3-win_amd64.whl", hash = "sha256:8213ee3b3c1adb5364a48a87420cde4426688f0438a88f6381595586be7ffc17", size = 4201989, upload-time = "2026-03-27T01:18:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/d3/67/2093978f8496dad5e90d77c3f59f07d3f040e32eda60f3592f1b45d48d65/awscrt-0.32.0-cp313-abi3-macosx_10_15_universal2.whl", hash = "sha256:d1efd89302eeee14878ca2067de6525c85d4973cd4473bc537e8807ecca660d3", size = 3390414, upload-time = "2026-03-27T01:18:21.006Z" }, - { url = "https://files.pythonhosted.org/packages/1f/4d/c2f8a732fef457ce58e7d798b2775deea16a3a840d8a9dd43bb21b80ec63/awscrt-0.32.0-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e36af2cfec50b0a2d270921217245c36f6722b9aae756394ca050dee535883", size = 3881094, upload-time = "2026-03-27T01:18:22.602Z" }, - { url = "https://files.pythonhosted.org/packages/b9/be/3cd7ad30fbc65eed95c1df2d50f9f1facb82335137f1842ec4e2152d3a51/awscrt-0.32.0-cp313-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a94d7c85486908adf07693519b3a8ec7c61b86cc0901fed266ff2239babef6ce", size = 4172552, upload-time = "2026-03-27T01:18:23.857Z" }, - { url = "https://files.pythonhosted.org/packages/eb/af/e299192ae380cb688ff505aa9145a9b4e9c31bf12275d67074bfd6aff899/awscrt-0.32.0-cp313-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:68e36b744ed8089be80a2f7c8ed9bd46573f00870d1429707c1c847f3dc99a6f", size = 3791182, upload-time = "2026-03-27T01:18:25.144Z" }, - { url = "https://files.pythonhosted.org/packages/96/ab/ffa769df6417720a4f9bddd9f8cc077f671d0a970d88d2c80e0e06eb0890/awscrt-0.32.0-cp313-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:b1a9f3952f382feee264614e727d78e7fa12bad138a025e832affc84780fdc89", size = 4035286, upload-time = "2026-03-27T01:18:26.444Z" }, - { url = "https://files.pythonhosted.org/packages/50/f2/cf80de14e9735750af9c4d11173150da997b8333ca6e68dc4850b6768a7c/awscrt-0.32.0-cp313-abi3-win32.whl", hash = "sha256:56c418ac23102e34ad95ea68ad5527ce534b7c79bb3ec3b908b647d90ee3ce41", size = 4034631, upload-time = "2026-03-27T01:18:28.247Z" }, - { url = "https://files.pythonhosted.org/packages/96/d6/d95df41f0e3a9434a750a33fbe465c8638212a46ec469f301511fd4791f7/awscrt-0.32.0-cp313-abi3-win_amd64.whl", hash = "sha256:7a3a464b0c23d1c2cca23b210035da203b1ead7cafcda2cebb87af3de20cc2b2", size = 4197293, upload-time = "2026-03-27T01:18:29.666Z" }, - { url = "https://files.pythonhosted.org/packages/9a/aa/5d85dc363c2269a205d33305e13b07298beaf582f653f10f6fc70531dc29/awscrt-0.32.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:f61cc36f645444d4e27cdf2c8fc5d3fdca77de35f341ad0e6c65f6c097ef5afd", size = 3401086, upload-time = "2026-03-27T01:18:31.388Z" }, - { url = "https://files.pythonhosted.org/packages/52/9e/fac5cab0cd7a94a4978daade9ef6d77c3b1037f470476d16ae822b54c97e/awscrt-0.32.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c51510e2171a3cab33612b98333a4baca391ed76a1185e9f6ec5433196e646af", size = 4006884, upload-time = "2026-03-27T01:18:32.696Z" }, - { url = "https://files.pythonhosted.org/packages/e1/cc/abd847148100a62616abaa5bdf9731686646e2a6f73e44bb63a718d6fd1e/awscrt-0.32.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f22723fc84ec31ed13591105083bea57fcbb7a9b20600dcfda8b5c28fa8047f8", size = 4293086, upload-time = "2026-03-27T01:18:34.092Z" }, - { url = "https://files.pythonhosted.org/packages/3d/8e/05572eaab9ff7a0a40f17c084d61389602c5018c7f4b7bb7ad9e58b4bea9/awscrt-0.32.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:4bc6492b7622cbad46d65492fb12239d946e890797c5da6c30a878a04c694623", size = 3934220, upload-time = "2026-03-27T01:18:35.438Z" }, - { url = "https://files.pythonhosted.org/packages/8f/4d/6e2dc94c69b32aadf037e5057f550d8e1bf3271573e5e95cc8934499a579/awscrt-0.32.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:dd6df896ae0650977de05c8e83fc5f3f7472b4d8de7744560526c13a63da8fd0", size = 4168581, upload-time = "2026-03-27T01:18:36.885Z" }, - { url = "https://files.pythonhosted.org/packages/42/4d/099e4fc39839ff130716713401d36ed9fa8b78feb5dcbf273e1e1aa71dd2/awscrt-0.32.0-cp313-cp313t-win32.whl", hash = "sha256:5fb05ab256b90c2d39386702d20419159b605a1f0e95d0fade715ccc9a76856a", size = 4091134, upload-time = "2026-03-27T01:18:38.532Z" }, - { url = "https://files.pythonhosted.org/packages/c6/a6/e0c63b8b73424f91a9fab52f100f412864ddd47e01ce84a6aeae35a12b7b/awscrt-0.32.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c1c69543cdeab10f7fcbd3f238996ee1ed73fb8f88dd9701fffc872d73bd256d", size = 4247510, upload-time = "2026-03-27T01:18:40.02Z" }, - { url = "https://files.pythonhosted.org/packages/b7/64/e7d5eac410e305b5d62da268d4c486dd003b065b3119031679a6cf242861/awscrt-0.32.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:f82a7969c025875fa419fc6349c8013bc88359ce264cb6c2399d03f42fbae0e3", size = 3401104, upload-time = "2026-03-27T01:18:41.354Z" }, - { url = "https://files.pythonhosted.org/packages/27/36/20d11e4b2a32337b712f1aa683a7d4bed777d1bfdff5d7803c7b952556c2/awscrt-0.32.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:877e6061448abc91b1926f3f8c73808ce2a170a404065ba185a407fbfd2eb8e7", size = 4007601, upload-time = "2026-03-27T01:18:43.005Z" }, - { url = "https://files.pythonhosted.org/packages/47/b6/d1d21aaa4c3affc82ff3b4ae75bad10d80234ea01d8d239a578efa23646b/awscrt-0.32.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b83427cf90f1606a34dbbe29b1544f945899b3abdb8608a57f004c6f459fd1fd", size = 4293839, upload-time = "2026-03-27T01:18:44.628Z" }, - { url = "https://files.pythonhosted.org/packages/97/2b/bb17205da426d175991ca9f9a5873c8c50a0620ec12c299ee3d80941552a/awscrt-0.32.0-cp314-cp314t-win32.whl", hash = "sha256:de44db7677361a05a1cdce9a1c29b6628542094599cb33105b99b97b4b9580ea", size = 4171775, upload-time = "2026-03-27T01:18:46.184Z" }, - { url = "https://files.pythonhosted.org/packages/39/2c/65ac451a08b57d9d66c8ffc2cbdf3c32e7da84d0a1887bdf6a3da4877585/awscrt-0.32.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7b9eb088e4e17539d3c5ec8f40f04363fabb807f9d509653d2443056d22b3506", size = 4347754, upload-time = "2026-03-27T01:18:47.641Z" }, +version = "0.32.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/cb/980fe60c4209af71d036276217f8b9f372f958e290c15d2849a3de4dcd23/awscrt-0.32.2.tar.gz", hash = "sha256:a4f48805e8a66237923f03b7b692d213994cff42d1ff08125d1d60c74fcaf872", size = 36862073, upload-time = "2026-04-24T22:59:55.835Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/93/08c8dde6e10c2aa5e50c08730e7913fd68cc861536260115bdf109f20bb8/awscrt-0.32.2-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:a9ed98e20ca6fcad8ef32e3c6779aaa3526a5ff3f0aa99d59e0deeff59640375", size = 3405893, upload-time = "2026-04-24T22:58:34.276Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d8/f838299003df9c77b9d582411f009bf2e5bc07fc68566685b434de249755/awscrt-0.32.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acb707df280079d21d20ad1735b38a80d4939133cbaecfc2e4927dd8fc0190bc", size = 3946562, upload-time = "2026-04-24T22:58:37.467Z" }, + { url = "https://files.pythonhosted.org/packages/34/95/a9d9ff694e15550a1fa2f83ac9b3b50cc5f809d66dacc57af4d84cc01c7f/awscrt-0.32.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab06479bcdcb42b2956f59c0e4138049e5b44c885b7584ea05e39cc4d71b1f99", size = 4236332, upload-time = "2026-04-24T22:58:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/24/bd/92fcf514966e7a139b146282396608493b99e25f745b332075ebe18e129b/awscrt-0.32.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2792fc2877335673058d0b4e8249ef73dc36b22689fda939506aea6eceb42054", size = 3883087, upload-time = "2026-04-24T22:58:40.292Z" }, + { url = "https://files.pythonhosted.org/packages/5c/7d/cf7ec60d4997ee966ca003a3c7bfc556ee34db10f230f03e5608edf022ca/awscrt-0.32.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0d2e8a13063a3d9b61eee0e2885d133a75b38de3600f6b62437d8559f7d664e3", size = 4118974, upload-time = "2026-04-24T22:58:41.913Z" }, + { url = "https://files.pythonhosted.org/packages/65/bb/769f8923d7d7986e762898fa98a9d16130681a47da2ff668e6fb07bae0b4/awscrt-0.32.2-cp310-cp310-win32.whl", hash = "sha256:4e975223f3af4faa581c733f0fdd316514b987c42ce85ef3bcd6d9c02eb48f77", size = 4067395, upload-time = "2026-04-24T22:58:43.308Z" }, + { url = "https://files.pythonhosted.org/packages/83/42/17ab1d701f2628adf7b1b993cae9993111ed760aaf08f5772c2bf37c6b7a/awscrt-0.32.2-cp310-cp310-win_amd64.whl", hash = "sha256:a13c0a555bd930c829c72e6b2b2df70442b3b414037fd488984495b5beff5ddc", size = 4224532, upload-time = "2026-04-24T22:58:45.039Z" }, + { url = "https://files.pythonhosted.org/packages/03/99/a6c712a2f638c766b0879da0eacd9fe5695c64deb89c0357bcc4f1f4df9b/awscrt-0.32.2-cp311-abi3-macosx_10_15_universal2.whl", hash = "sha256:32785f54d0786e07b6491b51f9c2f75ea9e17decd39bb6b66fdc60cd871a49ef", size = 3406247, upload-time = "2026-04-24T22:58:46.268Z" }, + { url = "https://files.pythonhosted.org/packages/a7/03/665c81f3b9d3c56fcdfb8f353c22501bc538d192c431cfaaf307f867d404/awscrt-0.32.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f8a586c52f41ff14c2f1b8afeb764e231ad3d66acfd42a6b9fb6c8afd8da8fe2", size = 3906890, upload-time = "2026-04-24T22:58:47.96Z" }, + { url = "https://files.pythonhosted.org/packages/31/ad/5db0691a0c72d55a17c03c47149cf15d4602b83b470355c322c9a7f115e8/awscrt-0.32.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ef1664588d35dcad2115377120667e689cd7a517da52a481373c9536811ed96", size = 4196631, upload-time = "2026-04-24T22:58:49.244Z" }, + { url = "https://files.pythonhosted.org/packages/9a/66/63a4654b2996158f33e88d74d79178a5812d94a7e0d6c94ec475115dff18/awscrt-0.32.2-cp311-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7ed7c209136650fe25659436bb4150e5af6eb43d71a0bf294f0bf414428736ee", size = 3818090, upload-time = "2026-04-24T22:58:50.657Z" }, + { url = "https://files.pythonhosted.org/packages/cd/26/b8fee227918465830a0bbba48f54ebf0a5029c3a7d11d4fa973838a79262/awscrt-0.32.2-cp311-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cfd437122d5a2ec7eb9fffaf2cd8b96543d4a0d7e906b9515b79672005a1607a", size = 4056453, upload-time = "2026-04-24T22:58:52.373Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/f5b9e9677bf90d1840d4db1513ba92e73601ef82e61a16e747a83493d35c/awscrt-0.32.2-cp311-abi3-win32.whl", hash = "sha256:5197d1e4e780d755632f79f8d32a09a30a9101cccb51ca1694fe25c711b2e801", size = 4066027, upload-time = "2026-04-24T22:58:53.752Z" }, + { url = "https://files.pythonhosted.org/packages/57/0b/fd1798551ecdc8a28d61ecdeda248f99faa3457f83aefa10ab797f466889/awscrt-0.32.2-cp311-abi3-win_amd64.whl", hash = "sha256:80420aa19c074a4c0335f2bd0e4aee3381fa452328d937795a1e0c779f0c052d", size = 4221984, upload-time = "2026-04-24T22:58:55.115Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a3/075e29b39945f398adfb5d5ee4d533e00d37c0f5c68d79b250a1bdb087ba/awscrt-0.32.2-cp313-abi3-macosx_10_15_universal2.whl", hash = "sha256:d2f7aee3bce261ab1ceba1fac404de4d496aa866237161d4257cef92bff9d828", size = 3404901, upload-time = "2026-04-24T22:58:56.492Z" }, + { url = "https://files.pythonhosted.org/packages/e3/41/1c4783b32bf4ec7383156787570ea1221c95c037c2c0f11cbc9e9529ba48/awscrt-0.32.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff0fff9c2b613d7fabc298b0fd81f0d7056353f3d20271a852a719c5b2f7ccf4", size = 3897627, upload-time = "2026-04-24T22:58:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/af/2d/5736128847ff4a877d50720ea7a48d7e50a56b78741919e4b6ffabffb1ad/awscrt-0.32.2-cp313-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:79bb11d5d1dfdcfd867aac4a026bee11afbd2154279e12b66588442d8c14bdf7", size = 4189579, upload-time = "2026-04-24T22:58:59.56Z" }, + { url = "https://files.pythonhosted.org/packages/90/f5/064b23d4c927e9b63725ee60c88edb75a1e8f5fd1e97d56d4d6a63fe954b/awscrt-0.32.2-cp313-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:47330f421948207a122092042f235cf82d48fa145c446ff4db12cc8cd3a418b6", size = 3809647, upload-time = "2026-04-24T22:59:00.884Z" }, + { url = "https://files.pythonhosted.org/packages/f5/36/f14ea531cccb6ff85c4d50c9e79e61030675520a393b4af23685d24ea018/awscrt-0.32.2-cp313-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5ec4d8200eb0158b60e35fbb65faa96ef1eb7763f6ce1192827886ee24c6df99", size = 4051532, upload-time = "2026-04-24T22:59:02.593Z" }, + { url = "https://files.pythonhosted.org/packages/64/aa/b12e721c8148a1bbc58a22cbd204d88d0a6263331049d40c057f9dcd3e11/awscrt-0.32.2-cp313-abi3-win32.whl", hash = "sha256:a81f30a501d2eb6ba52c769cd6ecb3f7005512fba4a533211dc717e1115b0d94", size = 4062484, upload-time = "2026-04-24T22:59:04.251Z" }, + { url = "https://files.pythonhosted.org/packages/5c/26/9f5f23647465a4fe1b28afce334e54a4264e23b69365024c28955f0c4119/awscrt-0.32.2-cp313-abi3-win_amd64.whl", hash = "sha256:8d731edda20dce5afc15a04731d91136a31779a244672d1f0a292a8b04aa0fd3", size = 4220425, upload-time = "2026-04-24T22:59:05.627Z" }, + { url = "https://files.pythonhosted.org/packages/dd/76/18077410c1d7b524a7a7486550bc969218f6575288f66e285157dc78e143/awscrt-0.32.2-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:f2a085d0dd4eef974f2ae5467ae3717d2fc08dd8cd508c4fd7a5acb658c68616", size = 3414715, upload-time = "2026-04-24T22:59:07.01Z" }, + { url = "https://files.pythonhosted.org/packages/d3/12/465d72ea6afffc7829f7f48f408a707d4dab9e3f2b694f4e0e63e011478a/awscrt-0.32.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d8a4e52f7312e5e435461119aa903f6424e9996d93a040101fb1eb7b9c4e58dc", size = 4028634, upload-time = "2026-04-24T22:59:08.303Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ee/2e9d3716cf6a24f30b85af24691d473c913c119dc996bcd8c9b2b3fe2c17/awscrt-0.32.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6cb3c858e96ba023de691a3b6478bed9fb59085433042dff78c42e59eed19cf2", size = 4311846, upload-time = "2026-04-24T22:59:10.02Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ce/aa08aad92e48929cfe243ed7da5cf039fbb137c391e84af79e88c848a37a/awscrt-0.32.2-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:7f92b8f40a104a2a87ea5f428b3799220666ec1450b3a90665867d3715749e91", size = 3952242, upload-time = "2026-04-24T22:59:11.632Z" }, + { url = "https://files.pythonhosted.org/packages/42/e3/09ad98aa7dae9cd3ad578c7721b64e9da03f9a5ed444e518c63e82db05a9/awscrt-0.32.2-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:9fd2dbca02f4e22fb5c02d1505327e6e6e9320dbe8ca80fc033cbbb29ed8631a", size = 4187982, upload-time = "2026-04-24T22:59:14.477Z" }, + { url = "https://files.pythonhosted.org/packages/26/be/897b1ad519d83a837d721f4e8a87d98e6f36e5b985fa697f3ffed512a8eb/awscrt-0.32.2-cp313-cp313t-win32.whl", hash = "sha256:023a2f4595804a0f1d61ab49b64dda5612be9bfbe9b13759331e8e31658dda3f", size = 4111958, upload-time = "2026-04-24T22:59:15.857Z" }, + { url = "https://files.pythonhosted.org/packages/71/54/6cca298e8acb6769c8078b39bedbc507f17a3f7fe6ea768d8256d584d4ed/awscrt-0.32.2-cp313-cp313t-win_amd64.whl", hash = "sha256:a2f513f0fb3aafdf7cdf29d7f6f0c46bf4cc7880380c86e88635b7818565e76d", size = 4271679, upload-time = "2026-04-24T22:59:17.425Z" }, + { url = "https://files.pythonhosted.org/packages/c7/09/51fca333d42b53ddb04881f53e3cc0f4462872ac81426fbb34f5d0b1d1fb/awscrt-0.32.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7404cb551046bbe7cd454ea75f88b4a1b26f018d1b9bea83dbe46c174789d835", size = 3414716, upload-time = "2026-04-24T22:59:18.918Z" }, + { url = "https://files.pythonhosted.org/packages/39/3a/0ae52a8dfb0e3c37f0129232d79307c65e6ee1ea13a3cbdcf301aaad0a6a/awscrt-0.32.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:51d6132e9d70de40da07dfad5f17780a652dd4b351c35ca97c79d0fa0186d645", size = 4028980, upload-time = "2026-04-24T22:59:20.316Z" }, + { url = "https://files.pythonhosted.org/packages/2d/7f/7f52020bfcf29122cad77e936d82abaa1f6857a5a70453e8cc734119f0e2/awscrt-0.32.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:193cc3ecf03a1dd6f989853b6c21549a0a9750c855520fedc8c3c8fbcd32e1bc", size = 4312564, upload-time = "2026-04-24T22:59:21.851Z" }, + { url = "https://files.pythonhosted.org/packages/25/86/2c9b5479e08b8198459d2a781df5524e45d4d0f9c6329bad26979a4d1e85/awscrt-0.32.2-cp314-cp314t-win32.whl", hash = "sha256:25c7e7e6535cb2d2a4d22fd6264f621672d3903491318364dbc59066b63c7186", size = 4195784, upload-time = "2026-04-24T22:59:23.769Z" }, + { url = "https://files.pythonhosted.org/packages/ab/85/a12515514de8969b9e7fa40bb782501a336a8a985cc3093309120a80b627/awscrt-0.32.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a6782c19a00f354c7b232b675f09cde94d1ca37bcf29009b8779b3f6395b27b4", size = 4366435, upload-time = "2026-04-24T22:59:25.53Z" }, ] [[package]] @@ -588,7 +588,7 @@ wheels = [ [[package]] name = "bedrock-agentcore" -version = "1.21.0" +version = "1.23.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "boto3" }, @@ -600,9 +600,9 @@ dependencies = [ { name = "uvicorn" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6c/86/1923fb5c5249768d4bf70ecd145b68f7e5fc8c8ef5de524d05d9744f7a4a/bedrock_agentcore-1.21.0.tar.gz", hash = "sha256:429174e42e2ebd2b82c691862caf0a6f2ad9c3182f1f6c48912570691491b7ce", size = 1106118, upload-time = "2026-08-06T14:47:42.461Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/59/c1cdaf26a6d1c055f62569883aade2e8dc321daf42a2e9095adbc283bb08/bedrock_agentcore-1.23.1.tar.gz", hash = "sha256:580e60e77e5cfa1f403cdbaf333be0e46229af4d2bfad82c295f197e84a78663", size = 1268270, upload-time = "2026-09-16T16:45:32.472Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/11/e917fd7ef38a612f7f955d6abb47978d860531f24f9b702ea20d1fd28802/bedrock_agentcore-1.21.0-py3-none-any.whl", hash = "sha256:be1ce7440455b994e9c5bbbc6329cce8528512441590240aa1a6f25cadbe1dcf", size = 482831, upload-time = "2026-08-06T14:47:41.006Z" }, + { url = "https://files.pythonhosted.org/packages/28/c1/8371a0135da363723ac296ed904ec539bf924df99ee1b608792c3e917121/bedrock_agentcore-1.23.1-py3-none-any.whl", hash = "sha256:46826124a39b16118dbd2e76082923ebd457c492d6c61f556cfebb754c4497d1", size = 519479, upload-time = "2026-09-16T16:45:30.907Z" }, ] [[package]] @@ -651,30 +651,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.43.68" +version = "1.43.103" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/35/40/95db6388539e6194b7d8863e4228263e7a778fff0164b17d0e4530d44ce2/boto3-1.43.68.tar.gz", hash = "sha256:4be7531c45fbf8eb145ecd0f385c7b8f9d66ba2fd0c256522717260e67fca89d", size = 112664, upload-time = "2026-08-10T19:22:07.864Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/59/012898d78087105e9c20fe31605d3f1999921745e1890ee0f0d993846e2e/boto3-1.43.103.tar.gz", hash = "sha256:524821052527f6446d249bf710847b032d9b12135e346c9751777a0a2811cf04", size = 112687, upload-time = "2026-09-25T19:26:30.427Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/bd/36dd3f5718160ea7f8ef5a81b461cfb9d0f0b289bb31ed416ae6a1cbfee3/boto3-1.43.68-py3-none-any.whl", hash = "sha256:4be071482312d05ea345ae8a2ea307637d3e2f4fdce44b8ebeb0192929aa2644", size = 140027, upload-time = "2026-08-10T19:22:05.592Z" }, + { url = "https://files.pythonhosted.org/packages/f2/66/6eb2e92c57b30e54c87fe14c8cd71de3aed9184f3cec95c39132a3fcc61f/boto3-1.43.103-py3-none-any.whl", hash = "sha256:574796187eb4e4fcaaf5c1b88c1724a1ba5a95bc27aeae5573676178666acc71", size = 140044, upload-time = "2026-09-25T19:26:29.085Z" }, ] [[package]] name = "botocore" -version = "1.43.68" +version = "1.43.103" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/dc/ff7e35ecb25e2584e22ce8d5a13c5f991687597fd77d7cc388f2e4768cf3/botocore-1.43.68.tar.gz", hash = "sha256:a6c7ac88724c96f2ad5a7657f87d545d8218a74a1b219ba4397e3d37481941cd", size = 15894825, upload-time = "2026-08-10T19:22:02.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/83/acbee6f2e3b1f02de935fef0c48a387511bf979149dc6b44466aaafba6a5/botocore-1.43.103.tar.gz", hash = "sha256:8c7f220e09f3b7ec99c59c716aa66cf6f5b2b57fd8731544054f3778aa744c6a", size = 16243609, upload-time = "2026-09-25T19:26:25.884Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/48/18dc095a908da94e2389d8e4127f4438b185dfd2dd9190fe6f946b9f41ac/botocore-1.43.68-py3-none-any.whl", hash = "sha256:9985c6eb9b7896f88bd89c07d543accd6985ea772af9d796087012ad40f78420", size = 15579433, upload-time = "2026-08-10T19:21:58.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/47/790ba88ec849d1e07b5866458b67e79b291164f3de7f23429b4dcb7e2ced/botocore-1.43.103-py3-none-any.whl", hash = "sha256:6a6a561a6de43845ae42bc7ff7e2c798742bfa277f5215398f9c15ed9b1bd67c", size = 15938608, upload-time = "2026-09-25T19:26:23.162Z" }, ] [[package]] @@ -2179,7 +2179,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.28.1" +version = "1.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2197,9 +2197,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/93/0142dc84a666daf8ad51a34268f34c12fd6fda4f3810c4be2504eecc8212/mcp-1.30.0.tar.gz", hash = "sha256:445414625fce5c295faa505bb11bacece661ab6f4028d57c935db57820b7a3e4", size = 680511, upload-time = "2026-09-07T14:34:15.845Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f4/e58bc33317c92a0203664daaf00bf6f41166cc0149e5d6870a03f7cd004a/mcp-1.30.0-py3-none-any.whl", hash = "sha256:666edb5009503e1047c9d60346a756f94b261f05cc2625f23d41c728ffc484d0", size = 234581, upload-time = "2026-09-07T14:34:14.266Z" }, ] [[package]] @@ -4747,7 +4747,7 @@ wheels = [ [[package]] name = "strands-agents" -version = "1.55.0" +version = "1.57.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "boto3" }, @@ -4764,17 +4764,18 @@ dependencies = [ { name = "typing-extensions" }, { name = "watchdog" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/37/04f19549c24bd7dbdbf5010317ed9b090f454aae550ed188b8b030b1be50/strands_agents-1.55.0.tar.gz", hash = "sha256:8ad2ad0b5306e1e0419cde03534d3a725d8fb80aa19ed7065c844bb2ffbd7dc1", size = 1599495, upload-time = "2026-09-08T16:55:24.132Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/2c/061ba1354b8586e8bd6edb42b2f8de3c6a4db83488b6b28d82cbc88a9185/strands_agents-1.57.1.tar.gz", hash = "sha256:109ea0a0f7da82a3b32e4c02cef12f53d46637d3e1e0fb8a285c5fa7116eb1d8", size = 1709312, upload-time = "2026-09-25T20:12:35.457Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/90/6db60946626fb7f83249ac9f8e3037947f3aeac9b93bbbf116a4edd7be6c/strands_agents-1.55.0-py3-none-any.whl", hash = "sha256:01cd3ebd0606042b0e0c099aa6e549080a945ed3f4e063c223bf34f11373cd75", size = 807217, upload-time = "2026-09-08T16:55:22.37Z" }, + { url = "https://files.pythonhosted.org/packages/8c/1b/2b7e12fafee0e6ab6ce9fa16a0ad850bb570d8e2bd6326f5cca7c2cd75a7/strands_agents-1.57.1-py3-none-any.whl", hash = "sha256:797ea3de2ba4a66aaf7e804cba8308e3d582e2412c59978433d3216973d02a9d", size = 858212, upload-time = "2026-09-25T20:12:33.361Z" }, ] [package.optional-dependencies] bidi = [ { name = "aws-sdk-bedrock-runtime", extra = ["awscrt"], marker = "python_full_version >= '3.12'" }, - { name = "prompt-toolkit" }, + { name = "awscrt", marker = "python_full_version >= '3.12'" }, { name = "smithy-aws-core", marker = "python_full_version >= '3.12'" }, { name = "smithy-core", marker = "python_full_version >= '3.12'" }, + { name = "smithy-http", extra = ["awscrt"], marker = "python_full_version >= '3.12'" }, ] [[package]]