Allow custom Codex instructions for realtime transitions (#36408)

## What changed

- Add optional `realtimeStartInstructions` and `realtimeEndInstructions` fields to `thread/realtime/start`.
- Apply the instructions when Codex enters and leaves realtime mode, while preserving the existing defaults when either field is omitted.
- Limit each field to 8,192 estimated tokens.

## Testing

- Cover custom entry and exit instructions across realtime state transitions.
- Cover token-limit validation for both fields.

GitOrigin-RevId: b9cfa1ff22e084d8a6b2ed56bd27d95ffdd6b6bd
This commit is contained in:
guinness-oai
2026-07-31 23:51:33 +00:00
committed by copyberry
parent 64b2a3008e
commit 670f69416b
18 changed files with 395 additions and 33 deletions
@@ -3632,6 +3632,8 @@ mod tests {
text: "Understood.".to_string(),
},
]),
realtime_start_instructions: Some("Use realtime output channels.".to_string()),
realtime_end_instructions: Some("Resume normal text responses.".to_string()),
prompt: Some(Some("You are on a call".to_string())),
realtime_session_id: Some("sess_456".to_string()),
transport: None,
@@ -3668,6 +3670,8 @@ mod tests {
"text": "Understood."
}
],
"realtimeStartInstructions": "Use realtime output channels.",
"realtimeEndInstructions": "Resume normal text responses.",
"prompt": "You are on a call",
"realtimeSessionId": "sess_456",
"transport": null,
@@ -3696,6 +3700,8 @@ mod tests {
output_modality: RealtimeOutputModality::Audio,
include_startup_context: None,
initial_items: None,
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: None,
realtime_session_id: None,
transport: None,
@@ -3719,6 +3725,8 @@ mod tests {
"outputModality": "audio",
"includeStartupContext": null,
"initialItems": null,
"realtimeStartInstructions": null,
"realtimeEndInstructions": null,
"realtimeSessionId": null,
"transport": null,
"version": null,
@@ -3742,6 +3750,8 @@ mod tests {
output_modality: RealtimeOutputModality::Audio,
include_startup_context: None,
initial_items: None,
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(None),
realtime_session_id: None,
transport: None,
@@ -3765,6 +3775,8 @@ mod tests {
"outputModality": "audio",
"includeStartupContext": null,
"initialItems": null,
"realtimeStartInstructions": null,
"realtimeEndInstructions": null,
"prompt": null,
"realtimeSessionId": null,
"transport": null,
@@ -3988,6 +4000,8 @@ mod tests {
output_modality: RealtimeOutputModality::Audio,
include_startup_context: None,
initial_items: None,
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("You are on a call".to_string())),
realtime_session_id: None,
transport: None,
@@ -106,6 +106,12 @@ pub struct ThreadRealtimeStartParams {
/// limited to 128 items and 8,192 estimated text tokens in total.
#[ts(optional = nullable)]
pub initial_items: Option<Vec<ThreadRealtimeInitialItem>>,
/// Developer instructions given to the backing Codex model when this realtime session starts.
#[ts(optional = nullable)]
pub realtime_start_instructions: Option<String>,
/// Developer instructions given to the backing Codex model when this realtime session ends.
#[ts(optional = nullable)]
pub realtime_end_instructions: Option<String>,
#[serde(
default,
deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option",
+8 -1
View File
@@ -180,7 +180,7 @@ Example with notification opt-out:
- `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success.
- `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`.
- `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`.
- `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, optionally pass `model` and `version` to override configured realtime selection for this session only, pass `includeStartupContext: false` to omit Codex's generated startup context, and optionally pass `initialItems` to seed V3 with complete role-bearing text messages at session creation. Version `"v1"` uses legacy Bidi `conversation.handoff.*`, `"v2"` uses the Realtime Voice API, and `"v3"` preserves V1 Codex Voice behavior while using Frameless Bidi `delegation.*`. For V3 automatic Codex text, `codexResponseHandoffMode` accepts `"thinking"` (the default; all output uses channel-less thinking appends), `"commentary"` (all output uses the commentary channel), or `"bemTags"` (the raw BEM envelope selects the API channel: BEM `analysis` and `commentary` use `commentary`, while BEM `final` and unparsable output use `speakable`). The BEM envelope remains in the appended text for the frontend model to interpret. V1 and V2 ignore this setting. V3 handoffs do not prepend the legacy `"Agent Final Message"` label. Pass `clientManagedHandoffs: true` to disable automatic Codex response delivery so only the client's explicit append calls produce handoffs. Pass `codexResponsesAsItems: true` to send automatic Codex responses as realtime conversation items instead, and optionally pass `codexResponseItemPrefix` to prepend experiment instructions to those items. Returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a Bidi WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`. Conversation `version: "v2"` requests remain unsupported for WebRTC.
- `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, optionally pass `model` and `version` to override configured realtime selection for this session only, pass `includeStartupContext: false` to omit Codex's generated startup context, and optionally pass `initialItems` to seed V3 with complete role-bearing text messages at session creation. Pass `realtimeStartInstructions` and `realtimeEndInstructions` to control the developer instructions given to the backing Codex model when this session starts and ends. Version `"v1"` uses legacy Bidi `conversation.handoff.*`, `"v2"` uses the Realtime Voice API, and `"v3"` preserves V1 Codex Voice behavior while using Frameless Bidi `delegation.*`. For V3 automatic Codex text, `codexResponseHandoffMode` accepts `"thinking"` (the default; all output uses channel-less thinking appends), `"commentary"` (all output uses the commentary channel), or `"bemTags"` (the raw BEM envelope selects the API channel: BEM `analysis` and `commentary` use `commentary`, while BEM `final` and unparsable output use `speakable`). The BEM envelope remains in the appended text for the frontend model to interpret. V1 and V2 ignore this setting. V3 handoffs do not prepend the legacy `"Agent Final Message"` label. Pass `clientManagedHandoffs: true` to disable automatic Codex response delivery so only the client's explicit append calls produce handoffs. Pass `codexResponsesAsItems: true` to send automatic Codex responses as realtime conversation items instead, and optionally pass `codexResponseItemPrefix` to prepend experiment instructions to those items. Returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a Bidi WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`. Conversation `version: "v2"` requests remain unsupported for WebRTC.
- `thread/realtime/appendAudio` — append an input audio chunk to the active realtime session (experimental); returns `{}`.
- `thread/realtime/appendText` — append text input to the active realtime session with a required `role` of `user`, `developer`, or `assistant` (experimental); returns `{}`. Older clients that omit `role` default to `user`.
- `thread/realtime/appendSpeech` — append text that the realtime model should speak to the user (experimental); returns `{}`.
@@ -983,6 +983,13 @@ Then send `offer.sdp` to app-server. Core uses `experimental_realtime_ws_backend
Omit `prompt` to use Codex's default realtime backend prompt. Send `prompt: null` or
`prompt: ""` when the session should start without that default backend prompt.
Pass `realtimeStartInstructions` to provide the developer instructions given to
the backing Codex model when the thread enters realtime mode, and
`realtimeEndInstructions` to provide its developer instructions when the session
ends. These instructions configure Codex, not the realtime frontend model. They
are emitted on realtime state transitions, rather than repeated on every turn.
Each instructions field is limited to 8,192 estimated tokens. Omitting either
field preserves Codex's existing default instructions.
Clients may also pass `model` on `thread/realtime/start` to select a
different realtime session configuration without changing thread or user config.
Clients may pass `version` to select the realtime protocol for this session
@@ -1095,6 +1095,8 @@ impl TurnRequestProcessor {
role: item.role,
})
.collect(),
realtime_start_instructions: params.realtime_start_instructions,
realtime_end_instructions: params.realtime_end_instructions,
prompt: params.prompt,
realtime_session_id: params.realtime_session_id,
transport: params.transport.map(|transport| match transport {
@@ -100,6 +100,8 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R
output_modality: RealtimeOutputModality::Audio,
include_startup_context: None,
initial_items: None,
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("hello".to_string())),
realtime_session_id: None,
transport: None,
@@ -231,6 +233,8 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result<
output_modality: RealtimeOutputModality::Audio,
include_startup_context: None,
initial_items: None,
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("hello".to_string())),
realtime_session_id: None,
transport: Some(ThreadRealtimeStartTransport::Webrtc {
@@ -374,6 +374,8 @@ impl RealtimeE2eHarness {
output_modality: RealtimeOutputModality::Audio,
include_startup_context: None,
initial_items: None,
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: Some(ThreadRealtimeStartTransport::Webrtc {
@@ -432,6 +434,8 @@ impl RealtimeE2eHarness {
output_modality: RealtimeOutputModality::Audio,
include_startup_context: None,
initial_items: None,
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -466,6 +470,8 @@ impl RealtimeE2eHarness {
output_modality: RealtimeOutputModality::Audio,
include_startup_context: None,
initial_items,
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -484,6 +490,25 @@ impl RealtimeE2eHarness {
read_notification(&mut self.mcp, method).await
}
async fn complete_turn(&mut self, text: &str) -> Result<()> {
let request_id = self
.mcp
.send_turn_start_request(TurnStartParams {
thread_id: self.thread_id.clone(),
input: vec![V2UserInput::Text {
text: text.to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let _: TurnStartResponse =
timeout(DEFAULT_TIMEOUT, self.mcp.read_response(request_id)).await??;
self.read_notification::<TurnCompletedNotification>("turn/completed")
.await?;
Ok(())
}
/// Returns the nth JSON message app-server wrote to the fake Realtime API
/// sideband websocket.
async fn sideband_outbound_request(&self, request_index: usize) -> Value {
@@ -715,6 +740,8 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
output_modality: RealtimeOutputModality::Audio,
include_startup_context: None,
initial_items: None,
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: None,
realtime_session_id: None,
transport: None,
@@ -994,6 +1021,8 @@ async fn realtime_start_can_skip_startup_context() -> Result<()> {
output_modality: RealtimeOutputModality::Audio,
include_startup_context: Some(false),
initial_items: None,
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: None,
realtime_session_id: None,
transport: None,
@@ -1089,6 +1118,8 @@ async fn realtime_text_output_modality_requests_text_output_and_final_transcript
output_modality: RealtimeOutputModality::Text,
include_startup_context: None,
initial_items: None,
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: None,
realtime_session_id: None,
transport: None,
@@ -1265,6 +1296,8 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> {
output_modality: RealtimeOutputModality::Audio,
include_startup_context: None,
initial_items: None,
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -1300,6 +1333,114 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn realtime_mode_uses_client_instructions_on_entry_and_exit() -> Result<()> {
skip_if_no_network!(Ok(()));
let start_instructions = "Use [analysis], [final], and ::realtime-inline for voice output.";
let end_instructions = "Voice has ended. Resume the normal text output protocol.";
let mut harness = RealtimeE2eHarness::new(
RealtimeTestVersion::V1,
main_loop_responses(vec![
create_final_assistant_message_sse_response("first voice response")?,
create_final_assistant_message_sse_response("second voice response")?,
create_final_assistant_message_sse_response("text response after voice")?,
]),
realtime_sideband(vec![open_realtime_sideband_connection(vec![
vec![session_updated("sess_client_controlled_mode")],
vec![],
vec![],
])]),
)
.await?;
let start_request_id = harness
.mcp
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
thread_id: harness.thread_id.clone(),
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: None,
initial_items: None,
realtime_start_instructions: Some(start_instructions.to_string()),
realtime_end_instructions: Some(end_instructions.to_string()),
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
version: None,
voice: None,
})
.await?;
let _: ThreadRealtimeStartResponse =
timeout(DEFAULT_TIMEOUT, harness.mcp.read_response(start_request_id)).await??;
harness
.read_notification::<ThreadRealtimeStartedNotification>("thread/realtime/started")
.await?;
for input in ["first voice turn", "second voice turn"] {
harness.complete_turn(input).await?;
}
let stop_request_id = harness
.mcp
.send_thread_realtime_stop_request(ThreadRealtimeStopParams {
thread_id: harness.thread_id.clone(),
})
.await?;
let _: ThreadRealtimeStopResponse =
timeout(DEFAULT_TIMEOUT, harness.mcp.read_response(stop_request_id)).await??;
harness
.read_notification::<ThreadRealtimeClosedNotification>("thread/realtime/closed")
.await?;
harness.complete_turn("continue in text").await?;
let requests = harness.main_loop_responses_requests().await?;
assert_eq!(requests.len(), 3);
assert!(response_request_contains_text(
&requests[0],
start_instructions
));
assert!(!response_request_contains_text(
&requests[0],
end_instructions
));
assert!(!response_request_contains_text(
&requests[1],
end_instructions
));
assert!(response_request_contains_text(
&requests[2],
end_instructions
));
assert!(response_request_contains_text(
&requests[2],
&format!("<realtime_conversation>\n{end_instructions}\n</realtime_conversation>"),
));
let start_message_count = requests[1]["input"]
.as_array()
.context("second voice Responses request should contain input")?
.iter()
.filter(|item| {
item["role"] == "developer" && response_request_contains_text(item, start_instructions)
})
.count();
assert!(
start_message_count <= 1,
"realtime entry instructions should not be injected again on subsequent turns"
);
harness.shutdown().await;
Ok(())
}
#[tokio::test]
async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1362,6 +1503,8 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> {
output_modality: RealtimeOutputModality::Audio,
include_startup_context: None,
initial_items: None,
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: Some(ThreadRealtimeStartTransport::Webrtc {
@@ -2997,6 +3140,8 @@ async fn realtime_webrtc_start_surfaces_backend_error() -> Result<()> {
output_modality: RealtimeOutputModality::Audio,
include_startup_context: None,
initial_items: None,
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: Some(ThreadRealtimeStartTransport::Webrtc {
@@ -3060,6 +3205,8 @@ async fn realtime_conversation_requires_feature_flag() -> Result<()> {
output_modality: RealtimeOutputModality::Audio,
include_startup_context: None,
initial_items: None,
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -5,13 +5,17 @@ use codex_protocol::protocol::REALTIME_CONVERSATION_OPEN_TAG;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RealtimeEndInstructions {
reason: String,
instructions: Option<String>,
}
impl RealtimeEndInstructions {
pub(crate) fn new(reason: impl Into<String>) -> Self {
pub(crate) fn new() -> Self {
Self { instructions: None }
}
pub(crate) fn with_instructions(instructions: impl Into<String>) -> Self {
Self {
reason: reason.into(),
instructions: Some(instructions.into()),
}
}
}
@@ -32,11 +36,11 @@ impl ContextualUserFragment for RealtimeEndInstructions {
)
}
fn matches_text(text: &str) -> bool {
text.contains(END_INSTRUCTIONS.trim())
}
fn body(&self) -> String {
format!("\n{}\n\nReason: {}\n", END_INSTRUCTIONS.trim(), self.reason)
let instructions = self
.instructions
.as_deref()
.unwrap_or_else(|| END_INSTRUCTIONS.trim());
format!("\n{instructions}\n")
}
}
@@ -12,6 +12,7 @@ use serde::Serialize;
pub(crate) struct RealtimeState {
snapshot: RealtimeSnapshot,
start_instructions: Option<String>,
end_instructions: Option<String>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
@@ -20,10 +21,15 @@ pub(crate) struct RealtimeSnapshot {
}
impl RealtimeState {
pub(crate) fn new(active: bool, start_instructions: Option<&str>) -> Self {
pub(crate) fn new(
active: bool,
start_instructions: Option<&str>,
end_instructions: Option<&str>,
) -> Self {
Self {
snapshot: RealtimeSnapshot { active },
start_instructions: start_instructions.map(str::to_string),
end_instructions: end_instructions.map(str::to_string),
}
}
@@ -37,7 +43,12 @@ impl RealtimeState {
fn render_transition(&self, previous_active: bool) -> Option<Box<dyn ContextualUserFragment>> {
match (previous_active, self.snapshot.active) {
(false, true) => Some(self.render_start()),
(true, false) => Some(Box::new(RealtimeEndInstructions::new("inactive"))),
(true, false) => Some(match self.end_instructions.as_deref() {
Some(instructions) => {
Box::new(RealtimeEndInstructions::with_instructions(instructions))
}
None => Box::new(RealtimeEndInstructions::new()),
}),
(false, false) | (true, true) => None,
}
}
@@ -52,9 +63,7 @@ impl WorldStateSection for RealtimeState {
}
fn matches_legacy_fragment(role: &str, text: &str) -> bool {
role == "developer"
&& RealtimeStartInstructions::matches_text(text)
&& !RealtimeEndInstructions::matches_text(text)
role == "developer" && RealtimeStartInstructions::matches_text(text)
}
fn has_retained_fragment_matcher() -> bool {
@@ -2,7 +2,7 @@ use super::super::test_support::render_section_cases;
use super::*;
fn state(active: bool, start_instructions: Option<&str>) -> RealtimeState {
RealtimeState::new(active, start_instructions)
RealtimeState::new(active, start_instructions, /*end_instructions*/ None)
}
#[test]
@@ -34,10 +34,10 @@ fn snapshots() {
}
#[test]
fn retained_fragment_matcher_only_matches_starts() {
fn retained_fragment_matcher_matches_realtime_fragments() {
let start = RealtimeStartWithInstructions::new("custom instructions").render();
let end = RealtimeEndInstructions::new("inactive").render();
let end = RealtimeEndInstructions::new().render();
assert!(RealtimeState::matches_legacy_fragment("developer", &start));
assert!(!RealtimeState::matches_legacy_fragment("developer", &end));
assert!(RealtimeState::matches_legacy_fragment("developer", &end));
}
@@ -50,8 +50,6 @@ None
Realtime conversation ended.
Subsequent user input will return to typed text rather than transcript-style text. Do not assume recognition errors or missing punctuation once realtime has ended. Resume normal chat behavior.
Reason: inactive
</realtime_conversation>
Unknown -> {"active":true} (role - developer)
+51 -3
View File
@@ -92,6 +92,7 @@ const REALTIME_STARTUP_CONTEXT_TOKEN_BUDGET: usize = 5_300;
const REALTIME_ASSISTANT_OUTPUT_TOKEN_BUDGET: usize = 1_000;
const REALTIME_INITIAL_ITEMS_MAX_COUNT: usize = 128;
const REALTIME_INITIAL_ITEMS_MAX_TOKENS: usize = 8_192;
const REALTIME_MODE_INSTRUCTIONS_MAX_TOKENS: usize = 8_192;
const HANDOFF_STREAM_FLUSH_INTERVAL: Duration = Duration::from_millis(200);
const HANDOFF_STREAM_TRUNCATION_MARKER: &str = "\n…output truncated…\n";
const AGENT_FINAL_MESSAGE_PREFIX: &str = "\"Agent Final Message\":\n\n";
@@ -122,6 +123,13 @@ enum RealtimeFanoutTaskStop {
pub(crate) struct RealtimeConversationManager {
state: Mutex<Option<ConversationState>>,
mode_instructions: Mutex<Option<RealtimeModeInstructions>>,
}
#[derive(Clone, Debug)]
pub(crate) struct RealtimeModeInstructions {
pub(crate) start: Option<String>,
pub(crate) end: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -484,9 +492,14 @@ impl RealtimeConversationManager {
pub(crate) fn new() -> Self {
Self {
state: Mutex::new(None),
mode_instructions: Mutex::new(None),
}
}
pub(crate) async fn mode_instructions(&self) -> Option<RealtimeModeInstructions> {
self.mode_instructions.lock().await.clone()
}
pub(crate) async fn running_state(&self) -> Option<()> {
let state = self.state.lock().await;
state
@@ -504,7 +517,11 @@ impl RealtimeConversationManager {
)
}
async fn start(&self, start: RealtimeStart) -> CodexResult<RealtimeStartOutput> {
async fn start(
&self,
start: RealtimeStart,
mode_instructions: RealtimeModeInstructions,
) -> CodexResult<RealtimeStartOutput> {
let previous_state = {
let mut guard = self.state.lock().await;
guard.take()
@@ -513,7 +530,9 @@ impl RealtimeConversationManager {
stop_conversation_state(state, RealtimeFanoutTaskStop::Await).await;
}
self.start_inner(start).await
let output = self.start_inner(start).await?;
*self.mode_instructions.lock().await = Some(mode_instructions);
Ok(output)
}
async fn start_inner(&self, start: RealtimeStart) -> CodexResult<RealtimeStartOutput> {
@@ -1104,6 +1123,8 @@ struct PreparedRealtimeConversationStart {
codex_response_item_prefix: Option<String>,
codex_response_handoff_mode: CodexResponseHandoffMode,
codex_response_handoff_channel_prefixes: Option<BTreeMap<String, Vec<String>>>,
realtime_start_instructions: Option<String>,
realtime_end_instructions: Option<String>,
realtime_call_api_provider: Option<ApiProvider>,
requested_realtime_session_id: Option<String>,
version: RealtimeWsVersion,
@@ -1198,6 +1219,8 @@ async fn prepare_realtime_start(
codex_response_item_prefix: params.codex_response_item_prefix,
codex_response_handoff_mode: params.codex_response_handoff_mode,
codex_response_handoff_channel_prefixes: params.codex_response_handoff_channel_prefixes,
realtime_start_instructions: params.realtime_start_instructions,
realtime_end_instructions: params.realtime_end_instructions,
realtime_call_api_provider,
requested_realtime_session_id,
version,
@@ -1229,6 +1252,25 @@ pub(crate) async fn build_realtime_session_config(
version: RealtimeWsVersion,
configured_voice: ConfiguredRealtimeVoice,
) -> CodexResult<RealtimeSessionConfig> {
for (name, instructions) in [
(
"realtime start instructions",
params.realtime_start_instructions.as_deref(),
),
(
"realtime end instructions",
params.realtime_end_instructions.as_deref(),
),
] {
if instructions.is_some_and(|instructions| {
approx_token_count(instructions) > REALTIME_MODE_INSTRUCTIONS_MAX_TOKENS
}) {
return Err(CodexErr::InvalidRequest(format!(
"{name} must not exceed {REALTIME_MODE_INSTRUCTIONS_MAX_TOKENS} estimated tokens"
)));
}
}
let config = sess.get_config().await;
let prompt = prepare_realtime_backend_prompt(
params.prompt.clone(),
@@ -1398,6 +1440,8 @@ async fn handle_start_inner(
codex_response_item_prefix,
codex_response_handoff_mode,
codex_response_handoff_channel_prefixes,
realtime_start_instructions,
realtime_end_instructions,
realtime_call_api_provider,
requested_realtime_session_id,
version,
@@ -1409,6 +1453,10 @@ async fn handle_start_inner(
ConversationStartTransport::Websocket => None,
ConversationStartTransport::Webrtc { sdp } => Some(sdp),
};
let mode_instructions = RealtimeModeInstructions {
start: realtime_start_instructions,
end: realtime_end_instructions,
};
let start = RealtimeStart {
api_provider,
realtime_sideband_base_url,
@@ -1424,7 +1472,7 @@ async fn handle_start_inner(
model_client: sess.services.model_client.clone(),
sdp,
};
let start_output = sess.conversation.start(start).await?;
let start_output = sess.conversation.start(start, mode_instructions).await?;
info!("realtime conversation started");
+1 -1
View File
@@ -8808,7 +8808,7 @@ async fn record_context_updates_emits_realtime_end_when_session_stops_being_live
assert!(
developer_texts
.iter()
.any(|text| text.contains("Reason: inactive")),
.any(|text| text.contains("<realtime_conversation>")),
"expected a realtime end update, got {developer_texts:?}"
);
}
+11 -4
View File
@@ -96,12 +96,19 @@ impl Session {
{
world_state.add_section(ContextWindowGuidanceState::new(guidance));
}
let realtime_mode_instructions = self.conversation.mode_instructions().await;
world_state.add_section(RealtimeState::new(
turn_context.realtime_active,
turn_context
.config
.experimental_realtime_start_instructions
.as_deref(),
realtime_mode_instructions
.as_ref()
.and_then(|instructions| instructions.start.as_deref())
.or(turn_context
.config
.experimental_realtime_start_instructions
.as_deref()),
realtime_mode_instructions
.as_ref()
.and_then(|instructions| instructions.end.as_deref()),
));
world_state.add_section(AgentsMdState::new(step_context.loaded_agents_md.as_deref()));
if turn_context.config.include_permissions_instructions {
+3 -5
View File
@@ -253,6 +253,8 @@ async fn start_realtime_conversation(codex: &codex_core::CodexThread) -> Result<
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -300,10 +302,6 @@ fn assert_request_contains_realtime_start(request: &responses::ResponsesRequest)
body.contains("<realtime_conversation>"),
"expected request to restate realtime instructions"
);
assert!(
!body.contains("Reason: inactive"),
"expected request to use realtime start instructions"
);
}
fn assert_request_contains_custom_realtime_start(
@@ -332,7 +330,7 @@ fn assert_request_contains_realtime_end(request: &responses::ResponsesRequest) {
"expected request to restate realtime instructions"
);
assert!(
body.contains("Reason: inactive"),
body.contains("Realtime conversation ended."),
"expected request to use realtime end instructions"
);
}
@@ -296,6 +296,8 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> {
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -446,6 +448,8 @@ async fn conversation_start_defaults_to_v2_and_gpt_realtime_1_5() -> Result<()>
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -537,6 +541,8 @@ async fn conversation_webrtc_frameless_chatgpt_sends_codex_headers_to_backend()
output_modality: RealtimeOutputModality::Audio,
include_startup_context: false,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: Some(ConversationStartTransport::Webrtc {
@@ -661,6 +667,8 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> {
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: Some(ConversationStartTransport::Webrtc {
@@ -852,6 +860,8 @@ async fn conversation_webrtc_start_uses_avas_query() -> Result<()> {
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: Some(ConversationStartTransport::Webrtc {
@@ -954,6 +964,8 @@ async fn conversation_webrtc_default_v1_ignores_configured_v2_voice() -> Result<
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: Some(ConversationStartTransport::Webrtc {
@@ -1018,6 +1030,8 @@ async fn conversation_webrtc_default_v1_rejects_explicit_v2_voice() -> Result<()
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: Some(ConversationStartTransport::Webrtc {
@@ -1092,6 +1106,8 @@ async fn conversation_webrtc_start_uses_configured_call_base_url_for_avas() -> R
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: Some(ConversationStartTransport::Webrtc {
@@ -1203,6 +1219,8 @@ async fn conversation_webrtc_close_while_sideband_connecting_drops_pending_join(
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: Some(ConversationStartTransport::Webrtc {
@@ -1307,6 +1325,8 @@ async fn conversation_webrtc_sideband_connect_failure_closes_with_error() -> Res
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: Some(ConversationStartTransport::Webrtc {
@@ -1404,6 +1424,8 @@ async fn conversation_start_uses_openai_env_key_fallback_with_chatgpt_auth() ->
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -1499,6 +1521,8 @@ async fn assert_transport_close_tail_flush(
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -1619,6 +1643,8 @@ async fn conversation_start_preflight_failure_emits_realtime_error_only() -> Res
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -1674,6 +1700,8 @@ async fn conversation_start_connect_failure_emits_realtime_error_only() -> Resul
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -1777,6 +1805,8 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> {
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("old".to_string())),
realtime_session_id: Some("conv_old".to_string()),
transport: None,
@@ -1811,6 +1841,8 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> {
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("new".to_string())),
realtime_session_id: Some("conv_new".to_string()),
transport: None,
@@ -1916,6 +1948,8 @@ async fn conversation_uses_experimental_realtime_ws_base_url_override() -> Resul
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -1989,6 +2023,8 @@ async fn conversation_uses_default_realtime_backend_prompt() -> Result<()> {
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: None,
realtime_session_id: None,
transport: None,
@@ -2070,6 +2106,8 @@ async fn conversation_uses_empty_instructions_for_null_or_empty_prompt() -> Resu
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt,
realtime_session_id: None,
transport: None,
@@ -2144,6 +2182,8 @@ async fn conversation_uses_explicit_start_voice() -> Result<()> {
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -2210,6 +2250,8 @@ async fn conversation_uses_configured_realtime_voice() -> Result<()> {
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -2264,6 +2306,8 @@ async fn conversation_rejects_voice_for_wrong_realtime_version() -> Result<()> {
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -2319,6 +2363,8 @@ async fn conversation_uses_experimental_realtime_ws_backend_prompt_override() ->
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("prompt from op".to_string())),
realtime_session_id: None,
transport: None,
@@ -2400,6 +2446,8 @@ async fn conversation_uses_experimental_realtime_ws_startup_context_override() -
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("prompt from op".to_string())),
realtime_session_id: None,
transport: None,
@@ -2475,6 +2523,8 @@ async fn conversation_disables_realtime_startup_context_with_empty_override() ->
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("prompt from op".to_string())),
realtime_session_id: None,
transport: None,
@@ -2543,6 +2593,8 @@ async fn conversation_start_injects_startup_context_from_thread_history() -> Res
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -2666,6 +2718,8 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -2782,6 +2836,8 @@ async fn conversation_startup_context_falls_back_to_workspace_map() -> Result<()
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -2850,6 +2906,8 @@ async fn conversation_startup_context_is_truncated_and_sent_once_per_start() ->
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -2939,6 +2997,8 @@ async fn conversation_user_text_turn_is_not_sent_to_realtime() -> Result<()> {
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -3044,6 +3104,8 @@ async fn realtime_v2_noop_tool_call_returns_empty_function_output_without_respon
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -3151,6 +3213,8 @@ async fn conversation_mirrors_assistant_message_text_to_realtime_handoff() -> Re
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -3325,6 +3389,8 @@ async fn conversation_flushes_assistant_deltas_every_200ms_for_v3_handoff() -> R
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -3495,6 +3561,8 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() ->
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -3655,6 +3723,8 @@ async fn inbound_handoff_request_starts_turn() -> Result<()> {
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -3773,6 +3843,8 @@ async fn inbound_handoff_request_uses_active_transcript() -> Result<()> {
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -3884,6 +3956,8 @@ async fn inbound_handoff_request_sends_transcript_delta_after_each_handoff() ->
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -4014,6 +4088,8 @@ async fn conversation_close_routes_only_remaining_transcript_tail_once() -> Resu
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -4105,6 +4181,8 @@ async fn inbound_conversation_item_does_not_start_turn_and_still_forwards_audio(
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -4236,6 +4314,8 @@ async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_au
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -4397,6 +4477,8 @@ async fn inbound_handoff_request_does_not_block_realtime_event_forwarding() -> R
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -4547,6 +4629,8 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> {
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -4708,6 +4792,8 @@ async fn inbound_handoff_request_starts_turn_and_does_not_block_realtime_audio()
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
@@ -150,6 +150,32 @@ async fn initial_items_enforce_aggregate_token_limit() -> Result<()> {
.await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn realtime_start_instructions_enforce_token_limit() -> Result<()> {
skip_if_no_network!(Ok(()));
let mut params = start_params(RealtimeConversationVersion::V3);
params.realtime_start_instructions = Some("x".repeat(8_192 * 4 + 1));
assert_start_error(
params,
"realtime start instructions must not exceed 8192 estimated tokens",
)
.await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn realtime_end_instructions_enforce_token_limit() -> Result<()> {
skip_if_no_network!(Ok(()));
let mut params = start_params(RealtimeConversationVersion::V3);
params.realtime_end_instructions = Some("x".repeat(8_192 * 4 + 1));
assert_start_error(
params,
"realtime end instructions must not exceed 8192 estimated tokens",
)
.await
}
async fn assert_start_error(params: ConversationStartParams, expected_error: &str) -> Result<()> {
let api_server = start_mock_server().await;
let test = test_codex().build_with_auto_env(&api_server).await?;
@@ -195,6 +221,8 @@ fn start_params(version: RealtimeConversationVersion) -> ConversationStartParams
role: ConversationTextRole::Assistant,
},
],
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
+4
View File
@@ -220,6 +220,10 @@ pub struct ConversationStartParams {
pub include_startup_context: bool,
/// Complete role-bearing text items to include in the initial realtime session history.
pub initial_items: Vec<ConversationTextParams>,
/// Developer instructions given to Codex when this realtime session starts.
pub realtime_start_instructions: Option<String>,
/// Developer instructions given to Codex when this realtime session ends.
pub realtime_end_instructions: Option<String>,
pub prompt: Option<Option<String>>,
pub realtime_session_id: Option<String>,
pub transport: Option<ConversationStartTransport>,