Skip to content

feat(0.6.1): OpenAI Realtime prewarm — tools + double-handshake fix + duck-type adopt (re-base of #88)#93

Merged
nicolotognoni merged 6 commits into
feat/observability-otel-attrs-0.6.1from
feat/0.6.2-realtime-prewarm-v2
May 12, 2026
Merged

feat(0.6.1): OpenAI Realtime prewarm — tools + double-handshake fix + duck-type adopt (re-base of #88)#93
nicolotognoni merged 6 commits into
feat/observability-otel-attrs-0.6.1from
feat/0.6.2-realtime-prewarm-v2

Conversation

@nicolotognoni
Copy link
Copy Markdown
Collaborator

Summary

Re-base of #88 onto the freshly merged feat/observability-otel-attrs-0.6.1 head (HEAD 893a3bb, after #91 + #92). This branch carries the same 6 commits as #88 with conflicts resolved against the updated base.

Replaces #88. The original PR remains open for traceability and should be closed after this lands.

Commits

  1. feat(realtime): wire OpenAI Realtime warmup() into provider prewarm frameworkPatter._spawn_provider_warmup / spawnProviderWarmup now builds a transient OpenAIRealtimeAdapter from the resolved Agent when agent.provider == "openai_realtime" and calls warmup() in parallel with initiate_call. Saves 150–400 ms on the first turn.
  2. feat(realtime): persist primed Realtime session across warmup → live call boundary — Park a fully primed Realtime WebSocket during the ringing window. StreamHandler.start() then adopt_websocket(...) / adoptWebSocket(...) instead of paying a cold connect. Saves another ~250–450 ms on the first-turn audio.
  3. fix(realtime): include agent tools + built-ins in primed warmup session — The warmup adapter was built without tools=, so transfer_call / end_call silently no-op'd on hit-prewarm calls (~80% of outbound). Shared build_realtime_tools(...) (Py) / buildRealtimeTools(...) (TS) keeps live and warmup paths byte-identical.
  4. fix(realtime): eliminate double-handshake on outbound prewarm (park does warmup work) — Park already opens a primed WS; warmup was a second discarded handshake. Removed warmup-side Realtime adapter build. Pipeline-mode STT/TTS/LLM warmup unchanged.
  5. fix(realtime): recreate adapter on adopt failure to avoid stale state — A failed mid-adoption left _running / heartbeat / _ws in a corrupt state. Handler now re-instantiates the adapter before the cold connect fallback.
  6. refactor(stream-handler): duck-type adoptWebSocket capability (drop instanceof) — TS realtime adopt branch now checks typeof adapter.adoptWebSocket === 'function' instead of instanceof OpenAIRealtimeAdapter. Matches the Python getattr(self._adapter, "adopt_websocket", None) shape; keeps the generic stream-handler module provider-agnostic on the hot path.

Conflict resolution

All conflicts were CHANGELOG-only after each commit was applied, plus a single additive collision in libraries/python/tests/test_prewarm.py (HEAD had added test_stream_prewarm_bytes_opens_barge_in_gate_on_first_chunk, #88 added four test_spawn_provider_warmup_* Realtime tests — both sets kept).

CHANGELOG entries now live under ## 0.6.1 (2026-05-12) as ### Fixed — / ### Changed — / ### Added — subsections; ## Unreleased is empty. No ## 0.6.1 (2026-05-09) header.

Files touched during conflict resolution

The other files reported as auto-merging by git (libraries/python/getpatter/stream_handler.py, libraries/typescript/src/stream-handler.ts, libraries/typescript/tests/unit/prewarm.test.ts, libraries/python/getpatter/client.py, libraries/typescript/src/client.ts) resolved cleanly without manual intervention.

Breaking change?

No. The same opt-in agent.prewarm=True (default) gates the new Realtime warm path. Cold-connect fallback remains the default for every failure mode (dead parked WS, missing OpenAI key, adopt failure).

Test plan

  • Python: pytest tests/1841 passed, 7 skipped in 82.59s
  • TypeScript: npm test85 files, 1516 tests passed in 38.66s
  • TypeScript: npx tsc --noEmit — clean
  • No conflict markers remain (grep -c "^<<<<<<<\|^>>>>>>>\|^=======$" on CHANGELOG + test/handler files → 0)
  • CI green (Python 3.11/3.12/3.13, Node 20/22, security, lint)
  • /parity-check clean

Notes

…ramework

The `warmup()` method on `OpenAIRealtimeAdapter` (Python + TS) was
defined but unreachable from `Patter.call()` — the prewarm framework
only iterated `agent.stt` / `agent.tts` / `agent.llm`, but OpenAI
Realtime is an all-in-one provider that's server-instantiated at
`StreamHandler.start()` time and therefore not stored on the Agent.

`_spawn_provider_warmup` (Py) / `spawnProviderWarmup` (TS) now
constructs a transient `OpenAIRealtimeAdapter` from the resolved
Agent + the configured `openai_key` when `agent.provider ==
"openai_realtime"` and runs `warmup()` in parallel with the carrier
`initiate_call`. The transient adapter is configured identically to
the production one (model, voice, instructions, language, audio
format = g711_ulaw for both Twilio and Telnyx, plus optional
reasoning_effort / input_audio_transcription_model knobs from the
engine marker) so the upstream `session.update` primes the same
session state that the live call will use.

Saves 150-400 ms of TLS + WebSocket handshake + `session.created`
round-trip on the first turn. Best-effort: failures during warmup
adapter build or `warmup()` itself are logged at DEBUG and never
abort the call.
…call boundary

Builds on the previous warmup wiring. The transient warmup adapter
closes its WS after a session.update / session.updated round-trip,
so the live call still pays a fresh ``new WebSocket`` + handshake.
This change parks the primed Realtime WS instead — same pattern the
SDK already uses for STT (Cartesia) and TTS (ElevenLabs WS).

`_park_provider_connections` (Py) / `parkProviderConnections` (TS)
now build a transient `OpenAIRealtimeAdapter` when
`agent.provider == "openai_realtime"`, call its
`open_parked_connection` to keep the `session.updated` WS OPEN,
and stash it under the `openai_realtime` slot key alongside the
existing `stt` / `tts` parked handles.

`OpenAIRealtimeStreamHandler` (Py) accepts a new
`pop_prewarmed_connections` callback (wired through the Twilio and
Telnyx telephony adapters). `StreamHandler.start()` consults the
parked slot before calling `connect()` and calls
`adapter.adopt_websocket(...)` when a live WS is available — saving
~250-450 ms of cold-handshake on the first turn. TS mirrors the same
flow in `StreamHandler.initRealtimeAdapter` for both Twilio and
Telnyx bridges.

All failure modes (missing OpenAI key, dead parked WS, park-task
exception, adoption error) fall through transparently to the cold
`connect()` path. Existing 36-test TS handoff/prewarm suite and
45-test Python suite all green after change.
The prewarm path built the transient OpenAIRealtimeAdapter without a
``tools=`` argument, so the ``session.update`` sent during ringing
carried an empty tool list. When ``StreamHandler.start()`` adopted that
parked WebSocket it skipped a fresh ``session.update``, leaving the
upstream session permanently unaware that the two Patter built-ins
(``transfer_call`` / ``end_call``) existed — they silently no-op'd on
every hit-prewarm call (~80% of outbound calls when prewarm is enabled).

Extracted the canonical tool-list construction (user tools +
``transfer_call`` + ``end_call``) into a shared helper —
``build_realtime_tools()`` in Python and ``buildRealtimeTools()`` in
TypeScript — and call it from both the live ``buildAIAdapter`` /
``StreamHandler.start()`` path and the warmup-side
``_build_realtime_warmup_adapter`` / ``buildRealtimeWarmupAdapter``
path so the two ``session.update`` bodies match byte-for-byte.

Tests: 4 new regression tests (2 Py + 2 TS) verifying that the warmup
adapter carries user-defined tools plus both built-ins, and that the
built-ins are still injected when the agent declares no user tools.
…oes warmup work)

Both ``_spawn_provider_warmup`` and ``_park_provider_connections`` built
a transient ``OpenAIRealtimeAdapter`` and opened its own WebSocket
against ``api.openai.com`` during the ringing window — two handshakes
per outbound call where one suffices.

The warmup-only handshake is a strict subset of what park performs
(open WS → ``session.created`` → ``session.update`` → ``session.updated``)
and park keeps the socket open for adoption. The warmup-side WS was
opened, primed, and immediately discarded — pure waste of 150-400 ms
of ringing-window budget, plus doubled rate-limit pressure against
OpenAI for no benefit.

Fix: ``_spawn_provider_warmup`` no longer builds the Realtime adapter
at all; park is now the sole Realtime warm path on outbound calls.
Pipeline-mode STT / TTS / LLM ``warmup()`` calls are unchanged.

Tests: 2 new regression tests verify (1) ``_spawn_provider_warmup``
does not construct a Realtime adapter, and (2) end-to-end
warmup+park together construct exactly one adapter (the one park uses).
Updated 3 existing tests that asserted the old double-build behaviour.
When ``adopt_websocket`` / ``adoptWebSocket`` raised mid-adoption, the
partially-adopted ``OpenAIRealtimeAdapter`` was left in an inconsistent
state: ``_running`` / ``messageListenerAttached`` was already true, the
heartbeat task may have started, ``_current_response_item_id`` /
``currentResponseItemId`` may have carried leaked state from the parked
session, and the ``_ws`` / ``ws`` reference pointed at a now-closed
socket.

Falling through to ``connect()`` on that carcass raced
``session.created`` against stale state, ran two heartbeat timers, and
sometimes attached a second message listener to the new socket — silent
corruption of every adopt-failed call.

Fix: when adopt raises, re-instantiate the adapter (via the existing
``adapter_kwargs`` in Python, ``deps.buildAIAdapter`` in TS) before the
cold ``connect()`` path runs, guaranteeing a clean slate.

Tests: regression test in each SDK constructs an adapter whose
``adopt_websocket`` throws, then asserts (a) a second adapter instance
was created, (b) ``connect()`` ran on the fresh adapter, (c) the
handler's adapter reference points at the fresh instance.
…nstanceof)

The TS realtime adopt branch in ``stream-handler.ts:initRealtimeAdapter``
previously gated the prewarm-handoff path with two
``this.adapter instanceof OpenAIRealtimeAdapter`` checks. Switched both
to a single duck-type check (``typeof adoptWebSocket === 'function'``)
so:

1. The generic ``stream-handler`` module stays provider-agnostic on this
   hot path. Pipeline-only users still get the symbol resolved at module
   load (the import is used elsewhere in this file for legitimate
   provider-specific behaviour), but the adopt-handoff gate no longer
   demands a concrete class identity.

2. The check mirrors the Python handler's
   ``getattr(self._adapter, "adopt_websocket", None)`` shape — both
   SDKs now use capability-based detection rather than identity.

3. Future Realtime-like adapters (e.g. a different vendor's all-in-one
   provider that also exposes ``adoptWebSocket``) can opt into the
   adopt flow simply by implementing the method, no SDK change needed.

No behaviour change: the same WS-adopt path runs for the same adapter
class. Existing adopt-handoff tests cover the behaviour and continue
to pass.
@nicolotognoni nicolotognoni merged commit 97f2a45 into feat/observability-otel-attrs-0.6.1 May 12, 2026
1 check passed
@github-actions github-actions Bot deleted the feat/0.6.2-realtime-prewarm-v2 branch May 13, 2026 06:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant