From fb06762289cd00d9925083e9a0ba38228a5e6c49 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Mon, 14 Sep 2026 13:20:27 +0100 Subject: [PATCH] feat(agent): add raw control sender --- .../deepgram-python-voice-agent/SKILL.md | 2 ++ .fernignore | 1 + AGENTS.md | 2 +- reference.md | 10 ++++++++ src/deepgram/agent/v1/socket_client.py | 24 +++++++++++++++++++ tests/custom/test_socket_client_shims.py | 24 +++++++++++++++++++ 6 files changed, 62 insertions(+), 1 deletion(-) diff --git a/.agents/skills/deepgram-python-voice-agent/SKILL.md b/.agents/skills/deepgram-python-voice-agent/SKILL.md index 9ba51f4e..7d97df72 100644 --- a/.agents/skills/deepgram-python-voice-agent/SKILL.md +++ b/.agents/skills/deepgram-python-voice-agent/SKILL.md @@ -197,6 +197,8 @@ agent.send_keep_alive(AgentV1KeepAlive()) agent.send_force_end_turn() ``` +For an unmodeled Agent control frame, protocol-transparent bridges can call `agent.send_raw(message)`; dictionaries are JSON-serialized without validation and serialized JSON strings pass through unchanged. + Async client equivalents are identical but `await`-prefixed: ```python diff --git a/.fernignore b/.fernignore index 74cc0cfc..1c47fb0c 100644 --- a/.fernignore +++ b/.fernignore @@ -54,6 +54,7 @@ src/deepgram/requests/shared_intents.py # - _sanitize_numeric_types in agent socket client (float→int for API) # - optional message param on control send_ methods (send_keep_alive, send_close_stream, etc.) # so users don't need to instantiate the type themselves for no-payload control messages +# - agent/v1 send_raw: public raw-control sender for protocol-transparent bridges # - listen/v2 send_configure: runtime tolerance for a raw dict alongside the # generated ListenV2Configure model # [temporarily frozen — manual patches listed above] diff --git a/AGENTS.md b/AGENTS.md index e93ae152..93710da4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,7 @@ Current temporarily frozen files: - `src/deepgram/types/speak_settings_v1provider.py`, `src/deepgram/types/deepgram.py` — validate Agent TTS `expressivity` as `pydantic.StrictInt` so Pydantic v1 rejects fractional values instead of truncating them before they reach the API. Regression coverage in `tests/custom/test_socket_client_shims.py`. Unfreeze when Fern emits a strict integer. - `src/deepgram/listen/v1/socket_client.py` — same - `src/deepgram/listen/v2/socket_client.py` — same (broad except, optional `send_close_stream` default). As of the 2026-08-11 regen the generator properly types `send_configure(ListenV2Configure)` and puts `ListenV2ConfigureSuccess` in the response Union, so those are taken from the generator; the only `send_configure` patch retained is runtime tolerance for a raw dict (sent verbatim) for back-compat with pre-typed-model callers -- `src/deepgram/agent/v1/socket_client.py` — same + `_sanitize_numeric_types` +- `src/deepgram/agent/v1/socket_client.py` — same + `_sanitize_numeric_types` and public `send_raw()` for protocol-transparent control-frame bridges - `src/deepgram/agent/v1/types/agent_v1settings_agent_context.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent.py`, `src/deepgram/agent/v1/types/agent_v1settings.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_context.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent.py`, `src/deepgram/agent/v1/requests/agent_v1settings.py` — backward-compat patches for the 2026-05-05 Agent Settings schema restructure. These preserve callable `AgentV1SettingsAgent(...)`, keep `AgentV1Settings.agent` accepting both that wrapper and `agent_id` strings, restore the legacy request TypedDict shapes, remap legacy `messages=[...]` / nested `context=AgentV1SettingsAgentContext(messages=[...])` usage into the new `context={"messages": [...]}` wire shape, and keep read-side `obj.messages` access working. - `src/deepgram/core/api_error.py`, `src/deepgram/core/parse_error.py` — credential redaction. Every websocket `connect()` path raises `ApiError(headers=dict(headers), ...)` with the full request headers, and both error types stringify that dict, so an unredacted `Authorization` reached `str(e)`, tracebacks, log aggregators and error trackers (which serialise attributes as well as the message). Both now mask credential values at construction via `_secure_logging.redact_sensitive_headers`, preserving non-sensitive headers (`dg-request-id`) for debugging. This is the same threat `_secure_logging.py` covers for the `websockets` DEBUG handshake logs, via the other path to it. Regression coverage in `tests/custom/test_api_error_redaction.py`. Unfreeze if the generator starts redacting credentials itself. - `src/deepgram/core/query_encoder.py` — coerces Python bools to lowercase `"true"`/`"false"` before they reach `urllib.parse.urlencode` (which would otherwise produce `"True"`/`"False"` via `str()` and break websocket query strings). Only the four `*/connect()` paths call `urlencode`; HTTP raw clients hand params to httpx, which lowercases bools itself, so the patch is a no-op for the HTTP path. Once Fern's websocket codegen normalizes bools (or the spec types these as `boolean` end-to-end), this can be unfrozen. diff --git a/reference.md b/reference.md index 21bc8a6c..aebf23e3 100644 --- a/reference.md +++ b/reference.md @@ -6421,6 +6421,16 @@ asyncio.run(main())
+**`send_raw(message: dict[str, typing.Any] | str)`** — Send an unvalidated JSON control frame for protocol-transparent bridges + +- Dictionaries are JSON-serialized without model validation; serialized JSON strings are sent unchanged. + +
+
+ +
+
+ **`send_keep_alive()`** — Keep the connection alive - `agent.send_keep_alive()` diff --git a/src/deepgram/agent/v1/socket_client.py b/src/deepgram/agent/v1/socket_client.py index 6d504d3e..521ea617 100644 --- a/src/deepgram/agent/v1/socket_client.py +++ b/src/deepgram/agent/v1/socket_client.py @@ -197,6 +197,18 @@ async def send_force_end_turn(self, message: typing.Optional[AgentV1ForceEndTurn """ await self._send_model(message or AgentV1ForceEndTurn(type="ForceEndTurn")) + async def send_raw(self, message: typing.Union[typing.Dict[str, typing.Any], str]) -> None: + """Send a JSON control frame without model validation. + + Dictionaries are serialized to JSON. Serialized JSON strings are sent + unchanged, allowing protocol-transparent bridges to forward unknown + control frames. + + Values are sent as given; whole-number floats are not converted to + integers, so pass ints where the API expects them. + """ + await self._send(message) + async def send_media(self, message: bytes) -> None: """ Send a message to the websocket connection. @@ -351,6 +363,18 @@ def send_force_end_turn(self, message: typing.Optional[AgentV1ForceEndTurn] = No """ self._send_model(message or AgentV1ForceEndTurn(type="ForceEndTurn")) + def send_raw(self, message: typing.Union[typing.Dict[str, typing.Any], str]) -> None: + """Send a JSON control frame without model validation. + + Dictionaries are serialized to JSON. Serialized JSON strings are sent + unchanged, allowing protocol-transparent bridges to forward unknown + control frames. + + Values are sent as given; whole-number floats are not converted to + integers, so pass ints where the API expects them. + """ + self._send(message) + def send_media(self, message: bytes) -> None: """ Send a message to the websocket connection. diff --git a/tests/custom/test_socket_client_shims.py b/tests/custom/test_socket_client_shims.py index c34aaf07..7cdd1299 100644 --- a/tests/custom/test_socket_client_shims.py +++ b/tests/custom/test_socket_client_shims.py @@ -227,6 +227,30 @@ async def test_v1_control_async_no_arg(socket_client, method, message_type): assert _sent_json(ws)["type"] == message_type +class TestAgentRawControlSender: + def test_sync_send_raw_serializes_an_unknown_control_frame(self): + ws = _FakeWebSocket() + V1SocketClient(websocket=ws).send_raw({"type": "FutureControl", "sample_rate": 44100.0}) + assert ws.sent == ['{"type": "FutureControl", "sample_rate": 44100.0}'] + + def test_sync_send_raw_preserves_serialized_json(self): + ws = _FakeWebSocket() + message = '{"type":"FutureControl","option":true}' + V1SocketClient(websocket=ws).send_raw(message) + assert ws.sent == [message] + + async def test_async_send_raw_serializes_an_unknown_control_frame(self): + ws = _FakeAsyncWebSocket() + await AsyncV1SocketClient(websocket=ws).send_raw({"type": "FutureControl", "sample_rate": 44100.0}) + assert ws.sent == ['{"type": "FutureControl", "sample_rate": 44100.0}'] + + async def test_async_send_raw_preserves_serialized_json(self): + ws = _FakeAsyncWebSocket() + message = '{"type":"FutureControl","option":true}' + await AsyncV1SocketClient(websocket=ws).send_raw(message) + assert ws.sent == [message] + + class TestAgentSettingsSerialization: @pytest.mark.parametrize("expressivity", [1.5, True, "2"]) def test_expressivity_requires_a_plain_integer(self, expressivity):