use stable item IDs for Responses API calls

This commit is contained in:
Owen Lin
2026-06-08 15:21:01 -07:00
parent 381f0de531
commit 7b2fb1a6f4
55 changed files with 1415 additions and 409 deletions
+1 -6
View File
@@ -386,12 +386,7 @@ pub fn build_hook_prompt_message(fragments: &[HookPromptFragment]) -> Option<Res
return None;
}
Some(ResponseItem::Message {
id: Some(uuid::Uuid::new_v4().to_string()),
role: "user".to_string(),
content,
phase: None,
})
Some(ResponseItem::new_message("user", content))
}
pub fn parse_hook_prompt_message(
+178 -23
View File
@@ -9,7 +9,9 @@ use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::ser::Serializer;
use serde_json::Value;
use ts_rs::TS;
use uuid::Uuid;
use crate::permissions::FileSystemAccessMode;
use crate::permissions::FileSystemPath;
@@ -765,6 +767,10 @@ pub enum ResponseItem {
phase: Option<MessagePhase>,
},
AgentMessage {
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
id: Option<String>,
author: String,
recipient: String,
content: Vec<AgentMessageInputContent>,
@@ -822,6 +828,10 @@ pub enum ResponseItem {
// - an array of structured content items (`content_items`)
// We keep this behavior centralized in `FunctionCallOutputPayload`.
FunctionCallOutput {
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
id: Option<String>,
call_id: String,
#[ts(as = "FunctionCallOutputBody")]
#[schemars(with = "FunctionCallOutputBody")]
@@ -843,6 +853,10 @@ pub enum ResponseItem {
// `function_call_output.output` so freeform tools can return either plain
// text or structured content items.
CustomToolCallOutput {
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
id: Option<String>,
call_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
@@ -852,6 +866,10 @@ pub enum ResponseItem {
output: FunctionCallOutputPayload,
},
ToolSearchOutput {
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
id: Option<String>,
call_id: Option<String>,
status: String,
execution: String,
@@ -896,6 +914,10 @@ pub enum ResponseItem {
},
#[serde(alias = "compaction_summary")]
Compaction {
#[serde(default, skip_serializing)]
#[ts(skip)]
#[schemars(skip)]
id: Option<String>,
encrypted_content: String,
},
CompactionTrigger,
@@ -908,13 +930,137 @@ pub enum ResponseItem {
Other,
}
/// Responses API item kinds for stable IDs Codex assigns to new local response items.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ResponseItemIdKind {
Message,
FunctionCallOutput,
CustomToolCallOutput,
ToolSearchOutput,
}
impl ResponseItemIdKind {
fn prefix(self) -> &'static str {
match self {
Self::Message => "msg",
Self::FunctionCallOutput => "fco",
Self::CustomToolCallOutput => "ctco",
Self::ToolSearchOutput => "tso",
}
}
/// Generates a new stable Responses API item ID with this kind's server-recognized prefix.
pub fn new_id(self) -> String {
format!("{}_{}", self.prefix(), Uuid::new_v4().simple())
}
}
impl ResponseItem {
/// Constructs a new Codex-owned message with a fresh stable Responses API ID.
pub fn new_message(role: impl Into<String>, content: Vec<ContentItem>) -> Self {
Self::Message {
id: Some(ResponseItemIdKind::Message.new_id()),
role: role.into(),
content,
phase: None,
}
}
/// Constructs a new Codex-owned function call output with a fresh stable Responses API ID.
pub fn new_function_call_output(
call_id: impl Into<String>,
output: FunctionCallOutputPayload,
) -> Self {
Self::FunctionCallOutput {
id: Some(ResponseItemIdKind::FunctionCallOutput.new_id()),
call_id: call_id.into(),
output,
}
}
/// Constructs a new Codex-owned unnamed custom tool output with a fresh stable Responses API ID.
pub fn new_custom_tool_call_output(
call_id: impl Into<String>,
output: FunctionCallOutputPayload,
) -> Self {
Self::CustomToolCallOutput {
id: Some(ResponseItemIdKind::CustomToolCallOutput.new_id()),
call_id: call_id.into(),
name: None,
output,
}
}
/// Constructs a new Codex-owned named custom tool output with a fresh stable Responses API ID.
pub fn new_named_custom_tool_call_output(
call_id: impl Into<String>,
name: impl Into<String>,
output: FunctionCallOutputPayload,
) -> Self {
Self::CustomToolCallOutput {
id: Some(ResponseItemIdKind::CustomToolCallOutput.new_id()),
call_id: call_id.into(),
name: Some(name.into()),
output,
}
}
/// Constructs a new Codex-owned tool search output with a fresh stable Responses API ID.
pub fn new_tool_search_output(
call_id: impl Into<String>,
status: impl Into<String>,
execution: impl Into<String>,
tools: Vec<Value>,
) -> Self {
Self::ToolSearchOutput {
id: Some(ResponseItemIdKind::ToolSearchOutput.new_id()),
call_id: Some(call_id.into()),
status: status.into(),
execution: execution.into(),
tools,
}
}
pub fn id(&self) -> Option<&str> {
match self {
Self::Message { id, .. }
| Self::AgentMessage { id, .. }
| Self::LocalShellCall { id, .. }
| Self::FunctionCall { id, .. }
| Self::ToolSearchCall { id, .. }
| Self::FunctionCallOutput { id, .. }
| Self::CustomToolCall { id, .. }
| Self::CustomToolCallOutput { id, .. }
| Self::ToolSearchOutput { id, .. }
| Self::WebSearchCall { id, .. }
| Self::Compaction { id, .. } => id.as_deref(),
Self::Reasoning { id, .. } | Self::ImageGenerationCall { id, .. } => Some(id),
Self::CompactionTrigger | Self::ContextCompaction { .. } | Self::Other => None,
}
}
pub fn attach_id_to_json(&self, value: &mut Value) {
let Some(id) = self.id().filter(|id| !id.is_empty()) else {
return;
};
let Some(obj) = value.as_object_mut() else {
return;
};
obj.insert("id".to_string(), Value::String(id.to_string()));
}
/// Returns whether this item is an ordinary user-role message.
pub fn is_user_message(&self) -> bool {
matches!(self, Self::Message { role, .. } if role == "user")
}
}
pub(crate) fn attach_all_response_item_ids(values: &mut [Value], items: &[ResponseItem]) {
for (value, item) in values.iter_mut().zip(items) {
item.attach_id_to_json(value);
}
}
pub const BASE_INSTRUCTIONS_DEFAULT: &str = include_str!("prompts/base_instructions/default.md");
/// Base instructions for the model in a thread. Corresponds to the `instructions` field in the ResponsesAPI.
@@ -1131,38 +1277,32 @@ impl From<ResponseInputItem> for ResponseItem {
content,
phase,
} => Self::Message {
id: Some(ResponseItemIdKind::Message.new_id()),
role,
content,
id: None,
phase,
},
ResponseInputItem::FunctionCallOutput { call_id, output } => {
Self::FunctionCallOutput { call_id, output }
Self::new_function_call_output(call_id, output)
}
ResponseInputItem::McpToolCallOutput { call_id, output } => {
let output = output.into_function_call_output_payload();
Self::FunctionCallOutput { call_id, output }
Self::new_function_call_output(call_id, output)
}
ResponseInputItem::CustomToolCallOutput {
call_id,
name,
output,
} => Self::CustomToolCallOutput {
call_id,
name,
output,
} => match name {
Some(name) => Self::new_named_custom_tool_call_output(call_id, name, output),
None => Self::new_custom_tool_call_output(call_id, output),
},
ResponseInputItem::ToolSearchOutput {
call_id,
status,
execution,
tools,
} => Self::ToolSearchOutput {
call_id: Some(call_id),
status,
execution,
tools,
},
} => Self::new_tool_search_output(call_id, status, execution, tools),
}
}
}
@@ -1672,17 +1812,24 @@ mod tests {
phase: Some(MessagePhase::Commentary),
});
let ResponseItem::Message {
id: Some(id),
role,
content,
phase,
} = item
else {
panic!("expected assistant message with generated id");
};
assert!(id.starts_with("msg_"));
assert_eq!(role, "assistant");
assert_eq!(
item,
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "still working".to_string(),
}],
phase: Some(MessagePhase::Commentary),
}
content,
vec![ContentItem::OutputText {
text: "still working".to_string(),
}]
);
assert_eq!(phase, Some(MessagePhase::Commentary));
}
#[test]
@@ -2503,6 +2650,7 @@ mod tests {
assert_eq!(
item,
ResponseItem::Compaction {
id: None,
encrypted_content: "abc".into(),
}
);
@@ -2767,9 +2915,15 @@ mod tests {
}
})],
};
let item = ResponseItem::from(input.clone());
let ResponseItem::ToolSearchOutput { id: Some(id), .. } = &item else {
panic!("expected tool search output id");
};
assert!(id.starts_with("tso_"));
assert_eq!(
ResponseItem::from(input.clone()),
item,
ResponseItem::ToolSearchOutput {
id: Some(id.clone()),
call_id: Some("search-1".to_string()),
status: "completed".to_string(),
execution: "client".to_string(),
@@ -2855,6 +3009,7 @@ mod tests {
assert_eq!(
parsed_output,
ResponseItem::ToolSearchOutput {
id: None,
call_id: None,
status: "completed".to_string(),
execution: "server".to_string(),
+79 -8
View File
@@ -43,6 +43,7 @@ use crate::models::ResponseInputItem;
use crate::models::ResponseItem;
use crate::models::SandboxEnforcement;
use crate::models::WebSearchAction;
use crate::models::attach_all_response_item_ids;
use crate::num_format::format_with_separators;
use crate::openai_models::ReasoningEffort as ReasoningEffortConfig;
use crate::parse_command::ParsedCommand;
@@ -705,15 +706,22 @@ impl InterAgentCommunication {
}
}
pub fn to_model_input_item(&self) -> ResponseItem {
pub fn to_model_input_item(&self, source_message_id: Option<&str>) -> ResponseItem {
match &self.encrypted_content {
Some(encrypted_content) => ResponseItem::AgentMessage {
author: self.author.to_string(),
recipient: self.recipient.to_string(),
content: vec![AgentMessageInputContent::EncryptedContent {
encrypted_content: encrypted_content.clone(),
}],
},
Some(encrypted_content) => {
let id = source_message_id
.and_then(|id| id.strip_prefix("msg_"))
.filter(|suffix| !suffix.is_empty())
.map(|suffix| format!("amsg_{suffix}"));
ResponseItem::AgentMessage {
id,
author: self.author.to_string(),
recipient: self.recipient.to_string(),
content: vec![AgentMessageInputContent::EncryptedContent {
encrypted_content: encrypted_content.clone(),
}],
}
}
None => self.to_response_input_item().into(),
}
}
@@ -2857,6 +2865,34 @@ pub enum RolloutItem {
EventMsg(EventMsg),
}
impl RolloutItem {
pub fn attach_response_item_ids_to_json(&self, value: &mut Value) {
let Some(payload) = value.get_mut("payload") else {
return;
};
match self {
Self::ResponseItem(item) => item.attach_id_to_json(payload),
Self::Compacted(CompactedItem {
replacement_history: Some(items),
..
}) => {
let Some(Value::Array(values)) = payload.get_mut("replacement_history") else {
return;
};
attach_all_response_item_ids(values, items);
}
Self::SessionMeta(_)
| Self::Compacted(CompactedItem {
replacement_history: None,
..
})
| Self::TurnContext(_)
| Self::EventMsg(_) => {}
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)]
pub struct CompactedItem {
pub message: String,
@@ -4085,6 +4121,41 @@ mod tests {
);
}
#[test]
fn encrypted_inter_agent_communication_model_input_uses_source_message_id() {
let communication = InterAgentCommunication::new_encrypted(
AgentPath::root(),
AgentPath::root().join("reviewer").expect("recipient path"),
Vec::new(),
"encrypted-payload".to_string(),
/*trigger_turn*/ true,
);
let expected = ResponseItem::AgentMessage {
id: Some("amsg_stable".to_string()),
author: "/root".to_string(),
recipient: "/root/reviewer".to_string(),
content: vec![AgentMessageInputContent::EncryptedContent {
encrypted_content: "encrypted-payload".to_string(),
}],
};
assert_eq!(
communication.to_model_input_item(Some("msg_stable")),
expected
);
assert_eq!(
communication.to_model_input_item(/*source_message_id*/ None),
ResponseItem::AgentMessage {
id: None,
author: "/root".to_string(),
recipient: "/root/reviewer".to_string(),
content: vec![AgentMessageInputContent::EncryptedContent {
encrypted_content: "encrypted-payload".to_string(),
}],
}
);
}
#[test]
fn session_source_from_startup_arg_normalizes_custom_values() {
assert_eq!(