Skip to content

Commit c59e89e

Browse files
committed
build - bidi - isolate nova provider
1 parent 25f1ce6 commit c59e89e

File tree

25 files changed

+279
-289
lines changed

25 files changed

+279
-289
lines changed

pyproject.toml

Lines changed: 8 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -70,17 +70,18 @@ a2a = [
7070
"starlette>=0.46.2,<1.0.0",
7171
]
7272

73-
bidi = [
74-
"aws_sdk_bedrock_runtime; python_version>='3.12'",
73+
bidi-io = [
7574
"prompt_toolkit>=3.0.0,<4.0.0",
7675
"pyaudio>=0.2.13,<1.0.0",
77-
"smithy-aws-core>=0.0.1; python_version>='3.12'",
7876
]
7977
bidi-gemini = ["google-genai>=1.32.0,<2.0.0"]
78+
bidi-nova = [
79+
"aws_sdk_bedrock_runtime; python_version>='3.12'",
80+
"smithy-aws-core>=0.0.1; python_version>='3.12'",
81+
]
8082
bidi-openai = ["websockets>=15.0.0,<16.0.0"]
8183

82-
all = ["strands-agents[a2a,anthropic,docs,gemini,litellm,llamaapi,mistral,ollama,openai,writer,sagemaker,otel]"]
83-
bidi-all = ["strands-agents[a2a,bidi,bidi-gemini,bidi-openai,docs,otel]"]
84+
all = ["strands-agents[a2a,anthropic,bidi-io,bidi-gemini,bidi-openai,docs,gemini,litellm,llamaapi,mistral,ollama,openai,writer,sagemaker,otel]"]
8485

8586
dev = [
8687
"commitizen>=4.4.0,<5.0.0",
@@ -130,7 +131,7 @@ format-fix = [
130131
]
131132
lint-check = [
132133
"ruff check",
133-
"mypy ./src"
134+
"mypy -p src"
134135
]
135136
lint-fix = [
136137
"ruff check --fix"
@@ -204,16 +205,10 @@ warn_no_return = true
204205
warn_unreachable = true
205206
follow_untyped_imports = true
206207
ignore_missing_imports = false
207-
exclude = ["src/strands/experimental/bidi"]
208-
209-
[[tool.mypy.overrides]]
210-
module = ["strands.experimental.bidi.*"]
211-
follow_imports = "skip"
212208

213209
[tool.ruff]
214210
line-length = 120
215211
include = ["examples/**/*.py", "src/**/*.py", "tests/**/*.py", "tests_integ/**/*.py"]
216-
exclude = ["src/strands/experimental/bidi/**/*.py", "tests/strands/experimental/bidi/**/*.py", "tests_integ/bidi/**/*.py"]
217212

218213
[tool.ruff.lint]
219214
select = [
@@ -236,16 +231,14 @@ convention = "google"
236231
[tool.pytest.ini_options]
237232
testpaths = ["tests"]
238233
asyncio_default_fixture_loop_scope = "function"
239-
addopts = "--ignore=tests/strands/experimental/bidi --ignore=tests_integ/bidi"
240-
234+
addopts = "--ignore=tests/strands/experimental/bidi/models/test_nova_sonic.py --ignore=tests_integ/bidi"
241235

242236
[tool.coverage.run]
243237
branch = true
244238
source = ["src"]
245239
context = "thread"
246240
parallel = true
247241
concurrency = ["thread", "multiprocessing"]
248-
omit = ["src/strands/experimental/bidi/*"]
249242

250243
[tool.coverage.report]
251244
show_missing = true
@@ -275,48 +268,3 @@ style = [
275268
["text", ""],
276269
["disabled", "fg:#858585 italic"]
277270
]
278-
279-
# =========================
280-
# Bidi development configs
281-
# =========================
282-
283-
[tool.hatch.envs.bidi]
284-
dev-mode = true
285-
features = ["dev", "bidi-all"]
286-
installer = "uv"
287-
288-
[tool.hatch.envs.bidi.scripts]
289-
prepare = [
290-
"hatch run bidi-lint:format-fix",
291-
"hatch run bidi-lint:quality-fix",
292-
"hatch run bidi-lint:type-check",
293-
"hatch run bidi-test:test-cov",
294-
]
295-
296-
[tools.hatch.envs.bidi-lint]
297-
template = "bidi"
298-
299-
[tool.hatch.envs.bidi-lint.scripts]
300-
format-check = "format-fix --check"
301-
format-fix = "ruff format {args} --target-version py312 ./src/strands/experimental/bidi/**/*.py"
302-
quality-check = "ruff check {args} --target-version py312 ./src/strands/experimental/bidi/**/*.py"
303-
quality-fix = "quality-check --fix"
304-
type-check = "mypy {args} --python-version 3.12 ./src/strands/experimental/bidi/**/*.py"
305-
306-
[tool.hatch.envs.bidi-test]
307-
template = "bidi"
308-
309-
[tool.hatch.envs.bidi-test.scripts]
310-
test = "pytest {args} tests/strands/experimental/bidi"
311-
test-cov = """
312-
test \
313-
--cov=strands.experimental.bidi \
314-
--cov-config= \
315-
--cov-branch \
316-
--cov-report=term-missing \
317-
--cov-report=xml:build/coverage/bidi-coverage.xml \
318-
--cov-report=html:build/coverage/bidi-html
319-
"""
320-
321-
[[tool.hatch.envs.bidi-test.matrix]]
322-
python = ["3.13", "3.12"]

src/strands/agent/agent.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -624,8 +624,7 @@ async def _run_loop(
624624
try:
625625
yield InitEventLoopEvent()
626626

627-
for message in messages:
628-
await self._append_message(message)
627+
await self._append_messages(*messages)
629628

630629
structured_output_context = StructuredOutputContext(
631630
structured_output_model or self._default_structured_output_model
@@ -715,7 +714,7 @@ async def _convert_prompt_to_messages(self, prompt: AgentInput) -> Messages:
715714
tool_use_ids = [
716715
content["toolUse"]["toolUseId"] for content in self.messages[-1]["content"] if "toolUse" in content
717716
]
718-
await self._append_message(
717+
await self._append_messages(
719718
{
720719
"role": "user",
721720
"content": generate_missing_tool_result_content(tool_use_ids),
@@ -811,10 +810,11 @@ def _initialize_system_prompt(
811810
else:
812811
return None, None
813812

814-
async def _append_message(self, message: Message) -> None:
815-
"""Appends a message to the agent's list of messages and invokes the callbacks for the MessageCreatedEvent."""
816-
self.messages.append(message)
817-
await self.hooks.invoke_callbacks_async(MessageAddedEvent(agent=self, message=message))
813+
async def _append_messages(self, *messages: Message) -> None:
814+
"""Appends messages to history and invoke the callbacks for the MessageAddedEvent."""
815+
for message in messages:
816+
self.messages.append(message)
817+
await self.hooks.invoke_callbacks_async(MessageAddedEvent(agent=self, message=message))
818818

819819
def _redact_user_content(self, content: list[ContentBlock], redact_message: str) -> list[ContentBlock]:
820820
"""Redact user content preserving toolResult blocks.

src/strands/event_loop/event_loop.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ async def event_loop_cycle(
230230
)
231231
structured_output_context.set_forced_mode()
232232
logger.debug("Forcing structured output tool")
233-
await agent._append_message(
233+
await agent._append_messages(
234234
{"role": "user", "content": [{"text": "You must format the previous response as structured output."}]}
235235
)
236236

src/strands/experimental/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
This module implements experimental features that are subject to change in future revisions without notice.
44
"""
55

6-
from . import steering, tools
6+
from . import bidi, steering, tools
77
from .agent_config import config_to_agent
88

9-
__all__ = ["config_to_agent", "tools", "steering"]
9+
__all__ = ["bidi", "config_to_agent", "tools", "steering"]

src/strands/experimental/bidi/__init__.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
11
"""Bidirectional streaming package."""
22

3-
import sys
4-
5-
if sys.version_info < (3, 12):
6-
raise ImportError("bidi only supported for >= Python 3.12")
7-
83
# Main components - Primary user interface
94
# Re-export standard agent events for tool handling
105
from ...types._events import (
@@ -19,7 +14,6 @@
1914

2015
# Model interface (for custom implementations)
2116
from .models.model import BidiModel
22-
from .models.nova_sonic import BidiNovaSonicModel
2317

2418
# Built-in tools
2519
from .tools import stop_conversation
@@ -48,8 +42,6 @@
4842
"BidiAgent",
4943
# IO channels
5044
"BidiAudioIO",
51-
# Model providers
52-
"BidiNovaSonicModel",
5345
# Built-in tools
5446
"stop_conversation",
5547
# Input Event types

src/strands/experimental/bidi/_async/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ async def stop_all(*funcs: Callable[..., Awaitable[None]]) -> None:
1616
funcs: Stop functions to call in sequence.
1717
1818
Raises:
19-
ExceptionGroup: If any stop function raises an exception.
19+
RuntimeError: If any stop function raises an exception.
2020
"""
2121
exceptions = []
2222
for func in funcs:
@@ -26,4 +26,8 @@ async def stop_all(*funcs: Callable[..., Awaitable[None]]) -> None:
2626
exceptions.append(exception)
2727

2828
if exceptions:
29-
raise ExceptionGroup("failed stop sequence", exceptions)
29+
exceptions.append(RuntimeError("failed stop sequence"))
30+
for i in range(1, len(exceptions)):
31+
exceptions[i].__cause__ = exceptions[i - 1]
32+
33+
raise exceptions[-1]

src/strands/experimental/bidi/agent/agent.py

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,12 @@
2626
from ....tools.executors._executor import ToolExecutor
2727
from ....tools.registry import ToolRegistry
2828
from ....tools.watcher import ToolWatcher
29-
from ....types.content import Messages
29+
from ....types.content import Message, Messages
3030
from ....types.tools import AgentTool
31-
from ...hooks.events import BidiAgentInitializedEvent
31+
from ...hooks.events import BidiAgentInitializedEvent, BidiMessageAddedEvent
3232
from ...tools import ToolProvider
3333
from .._async import stop_all
3434
from ..models.model import BidiModel
35-
from ..models.nova_sonic import BidiNovaSonicModel
3635
from ..types.agent import BidiAgentInput
3736
from ..types.events import (
3837
BidiAudioInputEvent,
@@ -100,13 +99,13 @@ def __init__(
10099
ValueError: If model configuration is invalid or state is invalid type.
101100
TypeError: If model type is unsupported.
102101
"""
103-
self.model = (
104-
BidiNovaSonicModel()
105-
if not model
106-
else BidiNovaSonicModel(model_id=model)
107-
if isinstance(model, str)
108-
else model
109-
)
102+
if isinstance(model, BidiModel):
103+
self.model = model
104+
else:
105+
from ..models.nova_sonic import BidiNovaSonicModel
106+
107+
self.model = BidiNovaSonicModel(model_id=model) if isinstance(model, str) else BidiNovaSonicModel()
108+
110109
self.system_prompt = system_prompt
111110
self.messages = messages or []
112111

@@ -167,6 +166,9 @@ def __init__(
167166
# TODO: Determine if full support is required
168167
self._interrupt_state = _InterruptState()
169168

169+
# Lock to ensure that paired messages are added to history in sequence without interference.
170+
self._message_lock = asyncio.Lock()
171+
170172
self._started = False
171173

172174
@property
@@ -387,12 +389,33 @@ async def run_outputs(inputs_task: asyncio.Task) -> None:
387389
for start in [*input_starts, *output_starts]:
388390
await start(self)
389391

390-
async with asyncio.TaskGroup() as task_group:
391-
inputs_task = task_group.create_task(run_inputs())
392-
task_group.create_task(run_outputs(inputs_task))
392+
inputs_task = asyncio.create_task(run_inputs())
393+
outputs_task = asyncio.create_task(run_outputs(inputs_task))
394+
395+
try:
396+
await asyncio.gather(inputs_task, outputs_task)
397+
except (Exception, asyncio.CancelledError):
398+
inputs_task.cancel()
399+
outputs_task.cancel()
400+
await asyncio.gather(inputs_task, outputs_task, return_exceptions=True)
401+
raise
393402

394403
finally:
395404
input_stops = [input_.stop for input_ in inputs if isinstance(input_, BidiInput)]
396405
output_stops = [output.stop for output in outputs if isinstance(output, BidiOutput)]
397406

398407
await stop_all(*input_stops, *output_stops, self.stop)
408+
409+
async def _append_messages(self, *messages: Message) -> None:
410+
"""Append messages to history in sequence without interference.
411+
412+
The message lock ensures that paired messages are added to history in sequence without interference. For
413+
example, tool use and tool result messages must be added adjacent to each other.
414+
415+
Args:
416+
*messages: List of messages to add into history.
417+
"""
418+
async with self._message_lock:
419+
for message in messages:
420+
self.messages.append(message)
421+
await self.hooks.invoke_callbacks_async(BidiMessageAddedEvent(agent=self, message=message))

src/strands/experimental/bidi/agent/loop.py

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
BidiAfterInvocationEvent,
1616
BidiBeforeConnectionRestartEvent,
1717
BidiBeforeInvocationEvent,
18-
BidiMessageAddedEvent,
1918
)
2019
from ...hooks.events import (
2120
BidiInterruptionEvent as BidiInterruptionHookEvent,
@@ -51,8 +50,6 @@ class _BidiAgentLoop:
5150
that tools can access via their invocation_state parameter.
5251
_send_gate: Gate the sending of events to the model.
5352
Blocks when agent is reseting the model connection after timeout.
54-
_message_lock: Lock to ensure that paired messages are added to history in sequence without interference.
55-
For example, tool use and tool result messages must be added adjacent to each other.
5653
"""
5754

5855
def __init__(self, agent: "BidiAgent") -> None:
@@ -70,7 +67,6 @@ def __init__(self, agent: "BidiAgent") -> None:
7067
self._invocation_state: dict[str, Any]
7168

7269
self._send_gate = asyncio.Event()
73-
self._message_lock = asyncio.Lock()
7470

7571
async def start(self, invocation_state: dict[str, Any] | None = None) -> None:
7672
"""Start the agent loop.
@@ -145,7 +141,7 @@ async def send(self, event: BidiInputEvent | ToolResultEvent) -> None:
145141

146142
if isinstance(event, BidiTextInputEvent):
147143
message: Message = {"role": "user", "content": [{"text": event.text}]}
148-
await self._add_messages(message)
144+
await self._agent._append_messages(message)
149145

150146
await self._agent.model.send(event)
151147

@@ -224,7 +220,7 @@ async def _run_model(self) -> None:
224220
if isinstance(event, BidiTranscriptStreamEvent):
225221
if event["is_final"]:
226222
message: Message = {"role": event["role"], "content": [{"text": event["text"]}]}
227-
await self._add_messages(message)
223+
await self._agent._append_messages(message)
228224

229225
elif isinstance(event, ToolUseStreamEvent):
230226
tool_use = event["current_tool_use"]
@@ -282,7 +278,7 @@ async def _run_tool(self, tool_use: ToolUse) -> None:
282278

283279
tool_use_message: Message = {"role": "assistant", "content": [{"toolUse": tool_use}]}
284280
tool_result_message: Message = {"role": "user", "content": [{"toolResult": tool_result_event.tool_result}]}
285-
await self._add_messages(tool_use_message, tool_result_message)
281+
await self._agent._append_messages(tool_use_message, tool_result_message)
286282

287283
await self._event_queue.put(ToolResultMessageEvent(tool_result_message))
288284

@@ -300,16 +296,3 @@ async def _run_tool(self, tool_use: ToolUse) -> None:
300296

301297
except Exception as error:
302298
await self._event_queue.put(error)
303-
304-
async def _add_messages(self, *messages: Message) -> None:
305-
"""Add messages to history in sequence without interference.
306-
307-
Args:
308-
*messages: List of messages to add into history.
309-
"""
310-
async with self._message_lock:
311-
for message in messages:
312-
self._agent.messages.append(message)
313-
await self._agent.hooks.invoke_callbacks_async(
314-
BidiMessageAddedEvent(agent=self._agent, message=message)
315-
)
Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
"""Bidirectional model interfaces and implementations."""
22

33
from .model import BidiModel, BidiModelTimeoutError
4-
from .nova_sonic import BidiNovaSonicModel
54

65
__all__ = [
76
"BidiModel",
87
"BidiModelTimeoutError",
9-
"BidiNovaSonicModel",
108
]

src/strands/experimental/bidi/models/model.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"""
1515

1616
import logging
17-
from typing import Any, AsyncIterable, Protocol
17+
from typing import Any, AsyncIterable, Protocol, runtime_checkable
1818

1919
from ....types._events import ToolResultEvent
2020
from ....types.content import Messages
@@ -27,6 +27,7 @@
2727
logger = logging.getLogger(__name__)
2828

2929

30+
@runtime_checkable
3031
class BidiModel(Protocol):
3132
"""Protocol for bidirectional streaming models.
3233

0 commit comments

Comments
 (0)