Add TwoLayerLLMService: a roll-your-own two-layer bot with any frontend - #5689
Draft
kompfner wants to merge 11 commits into
Draft
Add TwoLayerLLMService: a roll-your-own two-layer bot with any frontend#5689kompfner wants to merge 11 commits into
kompfner wants to merge 11 commits into
Conversation
Codecov Report❌ Patch coverage is
... and 65 files with indirect coverage changes 🚀 New features to boost your workflow:
|
The two helpers behind OpenAI Live's client delegation become public API: a request is text the caller composes, and a transcript renderer covers the case where the frontend's model hands off without wording one.
The same backend worker behind two frontends that are not OpenAI Live: a cascade pipeline, and a speech-to-speech model.
A result that lands after the model has already answered is delivered alone; the guidance used to have it re-answer the user first, which had the cascade frontend re-tell a joke before relaying the weather.
The guidance makes the model likelier to deliver a late result on its own; it does not guarantee it.
kompfner
force-pushed
the
pk/openai-live-roll-your-own
branch
from
September 10, 2026 18:10
b253855 to
b7f3d3d
Compare
17 tasks
A caller reads a delegation's outputs with async for, the final answer last with is_final set, instead of passing an on_update callback and also receiving the answer as a return value. The job update payload names its type, so a stream can carry other kinds of update later without changing what the iterator yields.
A parent told to end or cancel over the bus passes that on to its children, but a pipeline that ends by itself, on an EndFrame it queued, an idle timeout or a fatal error, was never told anything, so its children ran on and the runner waited on them forever. The worker now cancels its children as it finishes, whichever way it got there.
…elegates to Any LLM service, text or speech-to-speech, becomes the frontend of a two-layer bot by wrapping it with a BackendLLMWorker in TwoLayerLLMService, which drops into a pipeline's LLM slot. The service installs the delegate tool on the frontend, appends the connector's guidance to its prompt, and registers a local backend worker with the pipeline worker; a backend given by name is one registered elsewhere, such as another process on a shared bus. A BackendConnector joins the layers from two strategies. The request strategy defines the delegate tool and composes the backend's request: the transcript since the previous delegation for a text frontend, or a request the model words itself for a speech-to-speech one, whose context can lag the audio. The reply strategy turns each backend output into what the frontend hears: the backend's prefers_spoken flag followed strictly, or passed on for the frontend to weigh and answer with a silence marker the service drops, or the final answer only, which is all a speech-to-speech function call can take. The mechanism prose moves out of app prompts: the strategies carry the frontend's guidance and BackendLLMWorker appends the output-style guidance to its own model, so an app writes persona and what the backend is for.
The cascade and realtime frontends no longer carry a delegate tool or the mechanism prose that went with it; each is a persona, one sentence saying what the backend is for, and the service. The two share their prompts word for word, which is what the service is for. A third example puts the frontend in charge of what backend progress gets spoken, with a slow lookup so there is progress to decide about. The README covers a backend running in another process over a shared bus.
…cel fix in the changelog
TwoLayerLLMService and OpenAILiveLLMService are the callers of the job contract; a custom request or reply strategy works with FunctionCallParams and BackendOutput and never puts a request to the backend itself. The helpers stay module-private until something outside the package needs them.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
Lets developers roll their own OpenAI-Live-like two-layer architecture: a conversational frontend that holds the conversation with a fast model, plus a smarter-but-slower backend it delegates to for anything needing tools, current information or careful reasoning.
OpenAILiveLLMServiceis this shape with the frontend fixed; this PR makes the shape available with any LLM service, text or speech-to-speech, as the frontend.What's added
TwoLayerLLMService(pipecat.pipeline.two_layer_llm_service) wraps the frontend so the pair drops into a pipeline's LLM slot, the wayLLMSwitcherdoes. It installs thedelegatetool on the frontend (adding it to the context's tools as they pass), appends the connector's guidance to the frontend's system instruction, and registers a local backend worker with the pipeline worker, so the app never wires the backend up.backendmay also be a name, for a worker registered elsewhere: the app's own registration, or another process on aRedisBus/PgmqBus.BackendConnectorsets how a delegation crosses, from two strategy objects with defaults picked by frontend kind:TranscriptBackendRequestStrategy: the conversation since the previous delegation; the tool takes no argumentsStrictSpeechFlagBackendReplyStrategy: progress relayed as intermediate tool results, the frontend run on those the backend'sprefers_spokenflag asks forExplicitBackendRequestStrategy: the model words the request itself (its context can lag the audio); the tool takesrequestFinalOnlyBackendReplyStrategy: the answer only, since a realtime function call takes one resultA third reply strategy,
AdvisorySpeechFlagBackendReplyStrategy, passes the flag on as advice: the frontend is run on every piece of progress and decides for itself, answering with a silence marker (∅) alone when it chooses to say nothing, which the service drops so nothing is said. Text frontends only.The request strategy owns the tool's interface. It supplies the
delegatetool's description and parameters and reads them back incompose_request(), so a custom strategy can declare whatever it needs.Prompts shrink to persona. The mechanism prose lives in the strategies (frontend guidance, appended next to the existing async-tool instructions) and in
BackendLLMWorker, which appends output-style guidance to its own model. The cascade and realtime examples share their prompts word for word.The job update payload names its
type, so the stream can carry other kinds later (tool-call metadata for RTVI) without changing what a reader of outputs sees. The worker-side helpers that put a request to the backend and render the transcript stay module-private: a custom strategy works withFunctionCallParamsandBackendOutputand never needs them.PipelineWorkercancels its children when its pipeline ends on its own. A parent told to end over the bus already passed that on; one that ended by itself (anEndFrameit queued, an idle timeout, a fatal error) never told its children, so they ran on and the runner waited forever. The same gap applied toOpenAILiveLLMService's client-delegation backend.Async-tool guidance (pipecat-wide):
ASYNC_TOOL_INSTRUCTIONSand the final-result message distinguish a result arriving while the user has an unanswered request from one arriving on a run of its own, so the model delivers just the result in the latter case instead of re-answering first. A minimal edit of the wording from fix(llm): state async tool handling in the system instruction #5278; replaying the motivating context against gpt-5.4-mini: 8/8 repeats with the old wording, 0/16 with the new; fix(llm): state async tool handling in the system instruction #5278's own release-eval sweep passes 159/160.Usage
All defaults. A text frontend hands the backend the conversation. Every piece of the backend's progress is recorded in the frontend's context as an intermediate tool result, but the frontend is only run on the ones the backend flags
prefers_spoken, which by default is just the final answer: the frontend knows what the backend is doing, and speaks when the backend says to. A realtime frontend words the request and takes the answer only. Both examples,cascade-frontend.pyandrealtime-frontend.py, are this with different frontends:Which outputs ask to be spoken is the backend worker's
transform_outputto change (from #5688). To have the frontend voice what the backend says on its way to the answer, not only the answer:The frontend decides what progress to speak (
advisory-speech-flag.py). Progress is recorded the same way, but the frontend is run on every piece of it, with the backend'sprefers_spokenflag passed along as advice; its model speaks or answers with the silence marker:A custom request strategy, declaring its own tool parameters:
A backend in another process: give the service the worker's name and run the backend under its own
WorkerRunneron a shared bus (see the examples' README).Notes
Realtime frontends take the answer only. A realtime function call accepts one result, so nothing mid-delegation (progress, or a future
speak_to_userline) reaches a realtime frontend.BackendConnectorrefuses a reply strategy that streams with a realtime frontend at construction.The handoff is kept out of RTVI tool-call events. The examples pass
function_call_report_level={"delegate": DISABLED}. Reporting it stays the app's decision. The backend's own calls run in the backend worker's pipeline and are not reported; see #5688's compatibility analysis, to which this adds the roll-your-own shape.Not in this PR: refactoring
OpenAILiveLLMService's own client delegation ontoBackendConnector. Live's delegation is driven by the model'ssession.delegation.createdevent, not a tool call; it shares the worker, the job contract and the transcript rendering with this.Validation
uv run pytest tests/test_two_layer_llm_service.py tests/test_backend_llm_worker.py tests/test_openai_live_service.py tests/test_base_worker.py tests/test_runner.py tests/test_ui_worker.py tests/test_pipeline.py— 252 pass. The new file covers the connector defaults by frontend kind, both request strategies, all three reply strategies, the silence filter, tool advertisement on context and tool-change frames with the frontend's own direct functions still registered, and the whole path end to end: a scripted backend worker added by the service itself, called through thedelegatetool, answering as tool results.uv run pytest tests/test_async_tool_messages.py— 24 pass.ruffandpyrightclean, examples included.gemma4:12bjudge), one session each:live_joke_while_waiting: 3/3 turns. The frontend tells the joke itself while the backend works, then relays the weather.live_joke_while_waiting: 3/3 turns; the frontend used the run on the backend's first progress to tell the joke.weather_function_call_audioscenario cannot pass for a two-layer bot as written: it expects to observeget_current_weather, which runs in the backend worker's pipeline. The bot's answer in that run was correct ("75 degrees Fahrenheit with nice conditions").Follow-ups
typefield on job updates and the service, which lives in the main pipeline whereRTVIObserverlooks, are the hooks.speak_to_user-style tool on the backend — see Add OpenAILiveLLMService (OpenAI Live API, gpt-live-1) with both delegation modes #5688. Any reply strategy already handles one (it is aBackendOutput(prefers_spoken=True)on the same channel); the follow-up folds it into the worker so it passestransform_output.scripts/release-evals, judging the relayed answer rather than expecting the frontend's function call.