Files
codex/codex-rs/ext/web-search/src/history.rs
sayan-oai a22706dfae standalone websearch extension (#23823)
## Summary

Add the extension-backed standalone `web.run` tool so Codex can call the
standalone search endpoint through the `codex-api` search client and
return its encrypted output to Responses.

- gate the new tool behind `standalone_web_search`
- install the extension in the app-server thread registry and hide
hosted `web_search` when standalone search is enabled for OpenAI
providers so the two paths stay mutually exclusive
- build search context from persisted history using a small tail
heuristic: previous user message, assistant text between the last two
user turns capped at about 1k tokens, and current user message

## Test Plan

- `cargo test -p codex-web-search-extension`
- `cargo test -p codex-api`
- `cargo test -p codex-core
hosted_tools_follow_provider_auth_model_and_config_gates`
2026-05-26 11:12:24 -07:00

171 lines
5.5 KiB
Rust

use codex_api::SearchInput;
use codex_core::parse_turn_item;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_tools::retain_tail_from_last_n_user_messages;
use codex_tools::truncate_assistant_output_text_to_token_budget;
const ASSISTANT_CONTEXT_TOKEN_LIMIT: usize = 1_000;
const ASSISTANT_ROLE: &str = "assistant";
const USER_ROLE: &str = "user";
/// Builds the conversation tail for standalone web search.
///
/// The tail keeps the previous user text message, up to 1k tokens of assistant
/// text that followed it, and the current user text message.
pub(crate) fn recent_input(items: &[ResponseItem]) -> Option<SearchInput> {
let mut messages = Vec::new();
for item in items {
push_visible_message(&mut messages, item);
}
retain_tail_from_last_n_user_messages(&mut messages, /*user_message_count*/ 2);
truncate_assistant_output_text_to_token_budget(&mut messages, ASSISTANT_CONTEXT_TOKEN_LIMIT);
(!messages.is_empty()).then_some(SearchInput::Items(messages))
}
fn push_visible_message(messages: &mut Vec<ResponseItem>, item: &ResponseItem) {
match item {
ResponseItem::Message { role, .. } if role == ASSISTANT_ROLE => {
messages.push(item.clone());
}
ResponseItem::Message {
id,
role,
content,
phase,
} if role == USER_ROLE
&& matches!(parse_turn_item(item), Some(TurnItem::UserMessage(_))) =>
{
let content = content
.iter()
.filter(|item| matches!(item, ContentItem::InputText { .. }))
.cloned()
.collect::<Vec<_>>();
if !content.is_empty() {
messages.push(ResponseItem::Message {
id: id.clone(),
role: role.clone(),
content,
phase: phase.clone(),
});
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use codex_api::SearchInput;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use pretty_assertions::assert_eq;
use super::ASSISTANT_ROLE;
use super::USER_ROLE;
use super::recent_input;
fn message(role: &str, text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: role.to_string(),
content: vec![if role == ASSISTANT_ROLE {
ContentItem::OutputText {
text: text.to_string(),
}
} else {
ContentItem::InputText {
text: text.to_string(),
}
}],
phase: None,
}
}
#[test]
fn keeps_current_user_and_previous_visible_turn() {
let items = vec![
message("system", "system"),
message(USER_ROLE, "old user"),
message(ASSISTANT_ROLE, "old assistant"),
message(USER_ROLE, "previous user"),
ResponseItem::FunctionCall {
id: None,
name: "tool".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
message(ASSISTANT_ROLE, "previous assistant"),
message("developer", "developer"),
message(USER_ROLE, "current user"),
message(ASSISTANT_ROLE, "current commentary"),
];
assert_eq!(
recent_input(&items),
Some(SearchInput::Items(vec![
message(USER_ROLE, "previous user"),
message(ASSISTANT_ROLE, "previous assistant"),
message(USER_ROLE, "current user"),
]))
);
}
#[test]
fn keeps_only_text_from_recent_user_messages() {
let previous_user = ResponseItem::Message {
id: None,
role: USER_ROLE.to_string(),
content: vec![
ContentItem::InputText {
text: "previous user".to_string(),
},
ContentItem::InputImage {
image_url: "data:image/png;base64,image".to_string(),
detail: None,
},
],
phase: None,
};
let items = vec![
previous_user,
message(ASSISTANT_ROLE, "previous assistant"),
message(USER_ROLE, "current user"),
];
assert_eq!(
recent_input(&items),
Some(SearchInput::Items(vec![
message(USER_ROLE, "previous user"),
message(ASSISTANT_ROLE, "previous assistant"),
message(USER_ROLE, "current user"),
]))
);
}
#[test]
fn ignores_contextual_user_messages_when_selecting_recent_turns() {
let items = vec![
message(USER_ROLE, "previous user"),
message(ASSISTANT_ROLE, "previous assistant"),
message(
USER_ROLE,
"<environment_context>\n<cwd>/tmp</cwd>\n</environment_context>",
),
message(USER_ROLE, "current user"),
];
assert_eq!(
recent_input(&items),
Some(SearchInput::Items(vec![
message(USER_ROLE, "previous user"),
message(ASSISTANT_ROLE, "previous assistant"),
message(USER_ROLE, "current user"),
]))
);
}
}