From cf79d1da96b0a5bae127e9f4891ac761b9a1882d Mon Sep 17 00:00:00 2001 From: Nikhil Date: Sun, 30 Aug 2026 11:22:10 +0530 Subject: [PATCH 1/6] fix(voice): accept every NumPy spelling of a supported TTS dtype TTSModelSettings.dtype is typed as DTypeLike, and dictionary settings keep it as the string spelling ("float32"). The audio buffer transform compared that value with == np.int16 / == np.float32, which is False for strings, so the pipeline raised "Invalid output dtype" inside the TTS task after the speech request had already been sent. Resolve the configured value with np.dtype() before comparing so any spelling NumPy resolves to int16 or float32 works, and keep raising the same UserError for unsupported or unresolvable dtypes. --- src/agents/voice/result.py | 11 +++++-- tests/voice/test_pipeline.py | 58 ++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/agents/voice/result.py b/src/agents/voice/result.py index a01f7d762c..83c70613be 100644 --- a/src/agents/voice/result.py +++ b/src/agents/voice/result.py @@ -106,9 +106,16 @@ def _transform_audio_buffer( np_array = np.frombuffer(combined_buffer, dtype=np.int16) - if output_dtype == np.int16: + # Resolve the configured dtype the way NumPy does so that every spelling of a supported + # dtype is accepted, including the strings that dictionary-based settings carry. + try: + resolved_dtype = np.dtype(output_dtype) + except TypeError: + raise UserError("Invalid output dtype") from None + + if resolved_dtype == np.int16: return np_array - elif output_dtype == np.float32: + elif resolved_dtype == np.float32: return (np_array.astype(np.float32) / 32767.0).reshape(-1, 1) else: raise UserError("Invalid output dtype") diff --git a/tests/voice/test_pipeline.py b/tests/voice/test_pipeline.py index 02b825376d..3820f5aa13 100644 --- a/tests/voice/test_pipeline.py +++ b/tests/voice/test_pipeline.py @@ -15,6 +15,7 @@ import agents._debug as _debug from agents import trace +from agents.exceptions import UserError from tests.testing_processor import fetch_events, fetch_ordered_spans, fetch_span_errors try: @@ -1325,6 +1326,63 @@ async def test_voicepipeline_float32() -> None: await fake_tts.verify_audio("out_1", audio_chunks[0], dtype=np.float32) +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("dtype_spelling", "expected_dtype"), + [ + ("float32", np.float32), + ("int16", np.int16), + (np.dtype("float32"), np.float32), + ], + ids=["float32-string", "int16-string", "float32-dtype-instance"], +) +async def test_voicepipeline_accepts_numpy_dtype_spellings( + dtype_spelling: npt.DTypeLike, expected_dtype: type[np.int16] | type[np.float32] +) -> None: + """Dictionary settings carry ``dtype`` as the spelling NumPy resolves, not the type object.""" + fake_stt = QueuedSTTModel(["first"]) + workflow = QueuedVoiceWorkflow([["out_1"]]) + fake_tts = ZeroPcmTTSModel() + pipeline = VoicePipeline( + workflow=workflow, + stt_model=fake_stt, + tts_model=fake_tts, + config={"tts_settings": {"buffer_size": 1, "dtype": dtype_spelling}}, + ) + result = await pipeline.run(AudioInput(buffer=np.zeros(2, dtype=np.int16))) + + events: list[str] = [] + audio_dtypes: list[np.dtype[Any]] = [] + async for event in result.stream(): + if isinstance(event, VoiceStreamEventAudio): + assert event.data is not None + audio_dtypes.append(event.data.dtype) + events.append("audio") + elif isinstance(event, VoiceStreamEventLifecycle): + events.append(event.event) + + assert events == ["turn_started", "audio", "turn_ended", "session_ended"] + assert audio_dtypes == [np.dtype(expected_dtype)] + + +@pytest.mark.asyncio +async def test_voicepipeline_rejects_unsupported_output_dtype() -> None: + fake_stt = QueuedSTTModel(["first"]) + workflow = QueuedVoiceWorkflow([["out_1"]]) + fake_tts = ZeroPcmTTSModel() + pipeline = VoicePipeline( + workflow=workflow, + stt_model=fake_stt, + tts_model=fake_tts, + config={"tts_settings": {"buffer_size": 1, "dtype": "int32"}}, + ) + result = await pipeline.run(AudioInput(buffer=np.zeros(2, dtype=np.int16))) + + with pytest.raises(UserError, match="Invalid output dtype"): + async for _ in result.stream(): + pass + + @pytest.mark.asyncio async def test_voicepipeline_transform_data() -> None: # Single turn. Should produce a single audio output, which is the TTS output for "out_1". From 2cb8c3b8f95f7bd92c9cc00efbfc2a0951594990 Mon Sep 17 00:00:00 2001 From: Nikhil Date: Sun, 30 Aug 2026 22:23:49 +0530 Subject: [PATCH 2/6] Keep the SDK error for dtypes NumPy cannot parse np.dtype() reports an unparseable dtype as either TypeError or ValueError, and the handler only caught TypeError. A malformed structured dtype such as {"names": ["x"], "formats": []} therefore escaped as a NumPy ValueError instead of the UserError the consumer gets for every other unsupported dtype. Catch both, and cover the unresolvable case in the existing rejection test alongside a dtype that resolves but is not supported. --- src/agents/voice/result.py | 6 ++++-- tests/voice/test_pipeline.py | 15 +++++++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/agents/voice/result.py b/src/agents/voice/result.py index 83c70613be..f690ad9421 100644 --- a/src/agents/voice/result.py +++ b/src/agents/voice/result.py @@ -107,10 +107,12 @@ def _transform_audio_buffer( np_array = np.frombuffer(combined_buffer, dtype=np.int16) # Resolve the configured dtype the way NumPy does so that every spelling of a supported - # dtype is accepted, including the strings that dictionary-based settings carry. + # dtype is accepted, including the strings that dictionary-based settings carry. NumPy + # reports an unparseable dtype as either TypeError or ValueError, and both keep the + # SDK-owned error rather than surfacing a NumPy parse failure to the consumer. try: resolved_dtype = np.dtype(output_dtype) - except TypeError: + except (TypeError, ValueError): raise UserError("Invalid output dtype") from None if resolved_dtype == np.int16: diff --git a/tests/voice/test_pipeline.py b/tests/voice/test_pipeline.py index 3820f5aa13..a9cf3d6071 100644 --- a/tests/voice/test_pipeline.py +++ b/tests/voice/test_pipeline.py @@ -1366,7 +1366,18 @@ async def test_voicepipeline_accepts_numpy_dtype_spellings( @pytest.mark.asyncio -async def test_voicepipeline_rejects_unsupported_output_dtype() -> None: +@pytest.mark.parametrize( + "dtype_spelling", + [ + "int32", + {"names": ["x"], "formats": []}, + ], + ids=["resolvable-but-unsupported", "unresolvable-structured-dtype"], +) +async def test_voicepipeline_rejects_unsupported_output_dtype( + dtype_spelling: npt.DTypeLike, +) -> None: + """An unsupported dtype keeps the SDK error, whether or not NumPy can parse it.""" fake_stt = QueuedSTTModel(["first"]) workflow = QueuedVoiceWorkflow([["out_1"]]) fake_tts = ZeroPcmTTSModel() @@ -1374,7 +1385,7 @@ async def test_voicepipeline_rejects_unsupported_output_dtype() -> None: workflow=workflow, stt_model=fake_stt, tts_model=fake_tts, - config={"tts_settings": {"buffer_size": 1, "dtype": "int32"}}, + config={"tts_settings": {"buffer_size": 1, "dtype": dtype_spelling}}, ) result = await pipeline.run(AudioInput(buffer=np.zeros(2, dtype=np.int16))) From 0834d1153a2ee02f0cd95441f831e7a7a228e2db Mon Sep 17 00:00:00 2001 From: Nikhil Date: Mon, 31 Aug 2026 22:19:16 +0530 Subject: [PATCH 3/6] Pin the byte-order rejection so it is a decision, not an accident A byte-swapped request like ">i2" does not compare equal to np.int16 on a little-endian host, so it already lands on UserError. Leaving that undocumented made it look accidental. Accepting it would mean converting the samples, since they are read from the PCM stream in native order and handing the array back unchanged would give the caller different values than it asked to read. That is more than this fix needs, so the behaviour stays and the rejection test covers it. --- tests/voice/test_pipeline.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/voice/test_pipeline.py b/tests/voice/test_pipeline.py index a9cf3d6071..97c4e3a48f 100644 --- a/tests/voice/test_pipeline.py +++ b/tests/voice/test_pipeline.py @@ -1371,13 +1371,24 @@ async def test_voicepipeline_accepts_numpy_dtype_spellings( [ "int32", {"names": ["x"], "formats": []}, + ">i2", + ], + ids=[ + "resolvable-but-unsupported", + "unresolvable-structured-dtype", + "non-native-byte-order", ], - ids=["resolvable-but-unsupported", "unresolvable-structured-dtype"], ) async def test_voicepipeline_rejects_unsupported_output_dtype( dtype_spelling: npt.DTypeLike, ) -> None: - """An unsupported dtype keeps the SDK error, whether or not NumPy can parse it.""" + """An unsupported dtype keeps the SDK error, whether or not NumPy can parse it. + + A non-native byte order is rejected on purpose. The emitted samples are read from the + PCM stream in native order, so honoring a byte-swapped request would need the samples + converted rather than relabeled, and returning them as they are would hand back + different values than the caller asked to read. + """ fake_stt = QueuedSTTModel(["first"]) workflow = QueuedVoiceWorkflow([["out_1"]]) fake_tts = ZeroPcmTTSModel() From c1894276028a87c24c1b46adf8817b105be700af Mon Sep 17 00:00:00 2001 From: Nikhil Date: Mon, 31 Aug 2026 22:28:19 +0530 Subject: [PATCH 4/6] Derive the byte-order case from the running host Hardcoding ">i2" assumed a little-endian machine. On a big-endian host that spelling is the native int16, so the pipeline would emit audio and the case would fail for the wrong reason. Swap the native dtype instead, which gives the non-native order on either kind of host. --- tests/voice/test_pipeline.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/voice/test_pipeline.py b/tests/voice/test_pipeline.py index 97c4e3a48f..c06ef6175d 100644 --- a/tests/voice/test_pipeline.py +++ b/tests/voice/test_pipeline.py @@ -1371,7 +1371,7 @@ async def test_voicepipeline_accepts_numpy_dtype_spellings( [ "int32", {"names": ["x"], "formats": []}, - ">i2", + np.dtype(np.int16).newbyteorder("S"), ], ids=[ "resolvable-but-unsupported", @@ -1387,7 +1387,8 @@ async def test_voicepipeline_rejects_unsupported_output_dtype( A non-native byte order is rejected on purpose. The emitted samples are read from the PCM stream in native order, so honoring a byte-swapped request would need the samples converted rather than relabeled, and returning them as they are would hand back - different values than the caller asked to read. + different values than the caller asked to read. That case is swapped from the running + host's own order so the expectation holds on a big-endian machine too. """ fake_stt = QueuedSTTModel(["first"]) workflow = QueuedVoiceWorkflow([["out_1"]]) From 620820d8dcc2fc11099de8c799c7b4681709d40b Mon Sep 17 00:00:00 2001 From: Nikhil Date: Mon, 31 Aug 2026 22:57:32 +0530 Subject: [PATCH 5/6] Say which dtype case is coverage and which is a pin Every accepted spelling now resolves to the same dtype and takes the same branch, so the resolved-dtype case cannot fail where the string cases pass. It is there to hold the spelling that worked before this change, not to add coverage, and the old id read like protection it does not give. --- tests/voice/test_pipeline.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/voice/test_pipeline.py b/tests/voice/test_pipeline.py index c06ef6175d..9ba9cac426 100644 --- a/tests/voice/test_pipeline.py +++ b/tests/voice/test_pipeline.py @@ -1334,12 +1334,17 @@ async def test_voicepipeline_float32() -> None: ("int16", np.int16), (np.dtype("float32"), np.float32), ], - ids=["float32-string", "int16-string", "float32-dtype-instance"], + ids=["float32-string", "int16-string", "already-supported-spelling"], ) async def test_voicepipeline_accepts_numpy_dtype_spellings( dtype_spelling: npt.DTypeLike, expected_dtype: type[np.int16] | type[np.float32] ) -> None: - """Dictionary settings carry ``dtype`` as the spelling NumPy resolves, not the type object.""" + """Dictionary settings carry ``dtype`` as the spelling NumPy resolves, not the type object. + + The string cases are the ones that fail before this change. The resolved-dtype case is a + pin on the spelling that already worked rather than new coverage, since every accepted + spelling now resolves to the same dtype and takes the same branch. + """ fake_stt = QueuedSTTModel(["first"]) workflow = QueuedVoiceWorkflow([["out_1"]]) fake_tts = ZeroPcmTTSModel() From 8d07ccfb466ffd7d392a1b43073cc48e7a962e87 Mon Sep 17 00:00:00 2001 From: Nikhil Date: Tue, 1 Sep 2026 22:50:54 +0530 Subject: [PATCH 6/6] Defend the dtype contract and keep the NumPy cause Two arms of the contract were stated but not held by the suite. Narrowing the handler to ValueError alone, or swapping the np.dtype() resolution for a fixed set of names, both left the whole of tests/voice green while "not-a-dtype" leaked a raw TypeError and "f4" and " None: [ ("float32", np.float32), ("int16", np.int16), + ("f4", np.float32), (np.dtype("float32"), np.float32), ], - ids=["float32-string", "int16-string", "already-supported-spelling"], + ids=["float32-string", "int16-string", "alias-spelling", "already-supported-spelling"], ) async def test_voicepipeline_accepts_numpy_dtype_spellings( dtype_spelling: npt.DTypeLike, expected_dtype: type[np.int16] | type[np.float32] ) -> None: """Dictionary settings carry ``dtype`` as the spelling NumPy resolves, not the type object. - The string cases are the ones that fail before this change. The resolved-dtype case is a - pin on the spelling that already worked rather than new coverage, since every accepted - spelling now resolves to the same dtype and takes the same branch. + The string cases are the ones that fail before this change, and the alias holds the + property the fix rests on: the value is resolved the way NumPy resolves it rather than + matched against a fixed set of names. The resolved-dtype case is a pin on the spelling + that already worked rather than new coverage, since every accepted spelling now resolves + to the same dtype and takes the same branch. """ fake_stt = QueuedSTTModel(["first"]) workflow = QueuedVoiceWorkflow([["out_1"]]) @@ -1372,20 +1375,22 @@ async def test_voicepipeline_accepts_numpy_dtype_spellings( @pytest.mark.asyncio @pytest.mark.parametrize( - "dtype_spelling", + ("dtype_spelling", "expected_cause"), [ - "int32", - {"names": ["x"], "formats": []}, - np.dtype(np.int16).newbyteorder("S"), + ("int32", None), + ({"names": ["x"], "formats": []}, ValueError), + ("not-a-dtype", TypeError), + (np.dtype(np.int16).newbyteorder("S"), None), ], ids=[ "resolvable-but-unsupported", "unresolvable-structured-dtype", + "unparseable-string", "non-native-byte-order", ], ) async def test_voicepipeline_rejects_unsupported_output_dtype( - dtype_spelling: npt.DTypeLike, + dtype_spelling: npt.DTypeLike, expected_cause: type[Exception] | None ) -> None: """An unsupported dtype keeps the SDK error, whether or not NumPy can parse it. @@ -1394,6 +1399,10 @@ async def test_voicepipeline_rejects_unsupported_output_dtype( converted rather than relabeled, and returning them as they are would hand back different values than the caller asked to read. That case is swapped from the running host's own order so the expectation holds on a big-endian machine too. + + A value NumPy cannot parse keeps the parse failure attached as the cause, since it names + the spelling that failed. A value NumPy resolves to an unsupported dtype has no cause, + because nothing was raised on the way to rejecting it. """ fake_stt = QueuedSTTModel(["first"]) workflow = QueuedVoiceWorkflow([["out_1"]]) @@ -1406,10 +1415,15 @@ async def test_voicepipeline_rejects_unsupported_output_dtype( ) result = await pipeline.run(AudioInput(buffer=np.zeros(2, dtype=np.int16))) - with pytest.raises(UserError, match="Invalid output dtype"): + with pytest.raises(UserError, match="Invalid output dtype") as raised: async for _ in result.stream(): pass + if expected_cause is None: + assert raised.value.__cause__ is None + else: + assert isinstance(raised.value.__cause__, expected_cause) + @pytest.mark.asyncio async def test_voicepipeline_transform_data() -> None: