2025-09-12 13:07:10 -07:00
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
|
|
|
|
use super::AgentTask;
|
|
|
|
|
use super::Session;
|
|
|
|
|
use super::TurnContext;
|
|
|
|
|
use super::get_last_assistant_message_from_turn;
|
|
|
|
|
use crate::Prompt;
|
|
|
|
|
use crate::client_common::ResponseEvent;
|
|
|
|
|
use crate::error::CodexErr;
|
|
|
|
|
use crate::error::Result as CodexResult;
|
|
|
|
|
use crate::protocol::AgentMessageEvent;
|
|
|
|
|
use crate::protocol::CompactedItem;
|
|
|
|
|
use crate::protocol::ErrorEvent;
|
|
|
|
|
use crate::protocol::Event;
|
|
|
|
|
use crate::protocol::EventMsg;
|
|
|
|
|
use crate::protocol::InputItem;
|
|
|
|
|
use crate::protocol::InputMessageKind;
|
|
|
|
|
use crate::protocol::TaskCompleteEvent;
|
|
|
|
|
use crate::protocol::TaskStartedEvent;
|
|
|
|
|
use crate::protocol::TurnContextItem;
|
|
|
|
|
use crate::util::backoff;
|
|
|
|
|
use askama::Template;
|
|
|
|
|
use codex_protocol::models::ContentItem;
|
|
|
|
|
use codex_protocol::models::ResponseInputItem;
|
|
|
|
|
use codex_protocol::models::ResponseItem;
|
|
|
|
|
use codex_protocol::protocol::RolloutItem;
|
|
|
|
|
use futures::prelude::*;
|
|
|
|
|
|
|
|
|
|
pub(super) const COMPACT_TRIGGER_TEXT: &str = "Start Summarization";
|
|
|
|
|
const SUMMARIZATION_PROMPT: &str = include_str!("../../templates/compact/prompt.md");
|
|
|
|
|
|
|
|
|
|
#[derive(Template)]
|
|
|
|
|
#[template(path = "compact/history_bridge.md", escape = "none")]
|
|
|
|
|
struct HistoryBridgeTemplate<'a> {
|
|
|
|
|
user_messages_text: &'a str,
|
|
|
|
|
summary_text: &'a str,
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-18 18:21:52 +01:00
|
|
|
pub(super) async fn spawn_compact_task(
|
2025-09-12 13:07:10 -07:00
|
|
|
sess: Arc<Session>,
|
|
|
|
|
turn_context: Arc<TurnContext>,
|
|
|
|
|
sub_id: String,
|
|
|
|
|
input: Vec<InputItem>,
|
|
|
|
|
) {
|
|
|
|
|
let task = AgentTask::compact(
|
|
|
|
|
sess.clone(),
|
|
|
|
|
turn_context,
|
|
|
|
|
sub_id,
|
|
|
|
|
input,
|
|
|
|
|
SUMMARIZATION_PROMPT.to_string(),
|
|
|
|
|
);
|
2025-09-18 18:21:52 +01:00
|
|
|
sess.set_task(task).await;
|
2025-09-12 13:07:10 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(super) async fn run_inline_auto_compact_task(
|
|
|
|
|
sess: Arc<Session>,
|
|
|
|
|
turn_context: Arc<TurnContext>,
|
|
|
|
|
) {
|
|
|
|
|
let sub_id = sess.next_internal_sub_id();
|
|
|
|
|
let input = vec![InputItem::Text {
|
|
|
|
|
text: COMPACT_TRIGGER_TEXT.to_string(),
|
|
|
|
|
}];
|
|
|
|
|
run_compact_task_inner(
|
|
|
|
|
sess,
|
|
|
|
|
turn_context,
|
|
|
|
|
sub_id,
|
|
|
|
|
input,
|
|
|
|
|
SUMMARIZATION_PROMPT.to_string(),
|
|
|
|
|
false,
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(super) async fn run_compact_task(
|
|
|
|
|
sess: Arc<Session>,
|
|
|
|
|
turn_context: Arc<TurnContext>,
|
|
|
|
|
sub_id: String,
|
|
|
|
|
input: Vec<InputItem>,
|
|
|
|
|
compact_instructions: String,
|
|
|
|
|
) {
|
2025-09-18 16:34:16 +01:00
|
|
|
let start_event = Event {
|
|
|
|
|
id: sub_id.clone(),
|
|
|
|
|
msg: EventMsg::TaskStarted(TaskStartedEvent {
|
|
|
|
|
model_context_window: turn_context.client.get_model_context_window(),
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
sess.send_event(start_event).await;
|
2025-09-12 13:07:10 -07:00
|
|
|
run_compact_task_inner(
|
2025-09-18 16:34:16 +01:00
|
|
|
sess.clone(),
|
2025-09-12 13:07:10 -07:00
|
|
|
turn_context,
|
2025-09-18 16:34:16 +01:00
|
|
|
sub_id.clone(),
|
2025-09-12 13:07:10 -07:00
|
|
|
input,
|
|
|
|
|
compact_instructions,
|
|
|
|
|
true,
|
|
|
|
|
)
|
|
|
|
|
.await;
|
2025-09-18 16:34:16 +01:00
|
|
|
let event = Event {
|
|
|
|
|
id: sub_id,
|
|
|
|
|
msg: EventMsg::TaskComplete(TaskCompleteEvent {
|
|
|
|
|
last_agent_message: None,
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
sess.send_event(event).await;
|
2025-09-12 13:07:10 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn run_compact_task_inner(
|
|
|
|
|
sess: Arc<Session>,
|
|
|
|
|
turn_context: Arc<TurnContext>,
|
|
|
|
|
sub_id: String,
|
|
|
|
|
input: Vec<InputItem>,
|
|
|
|
|
compact_instructions: String,
|
|
|
|
|
remove_task_on_completion: bool,
|
|
|
|
|
) {
|
|
|
|
|
let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input);
|
|
|
|
|
let instructions_override = compact_instructions;
|
2025-09-18 18:21:52 +01:00
|
|
|
let turn_input = sess
|
|
|
|
|
.turn_input_with_history(vec![initial_input_for_turn.clone().into()])
|
|
|
|
|
.await;
|
2025-09-12 13:07:10 -07:00
|
|
|
|
|
|
|
|
let prompt = Prompt {
|
|
|
|
|
input: turn_input,
|
|
|
|
|
tools: Vec::new(),
|
|
|
|
|
base_instructions_override: Some(instructions_override),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let max_retries = turn_context.client.get_provider().stream_max_retries();
|
|
|
|
|
let mut retries = 0;
|
|
|
|
|
|
|
|
|
|
let rollout_item = RolloutItem::TurnContext(TurnContextItem {
|
|
|
|
|
cwd: turn_context.cwd.clone(),
|
|
|
|
|
approval_policy: turn_context.approval_policy,
|
|
|
|
|
sandbox_policy: turn_context.sandbox_policy.clone(),
|
|
|
|
|
model: turn_context.client.get_model(),
|
|
|
|
|
effort: turn_context.client.get_reasoning_effort(),
|
|
|
|
|
summary: turn_context.client.get_reasoning_summary(),
|
|
|
|
|
});
|
|
|
|
|
sess.persist_rollout_items(&[rollout_item]).await;
|
|
|
|
|
|
|
|
|
|
loop {
|
|
|
|
|
let attempt_result = drain_to_completed(&sess, turn_context.as_ref(), &prompt).await;
|
|
|
|
|
|
|
|
|
|
match attempt_result {
|
|
|
|
|
Ok(()) => {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
Err(CodexErr::Interrupted) => {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
if retries < max_retries {
|
|
|
|
|
retries += 1;
|
|
|
|
|
let delay = backoff(retries);
|
|
|
|
|
sess.notify_stream_error(
|
|
|
|
|
&sub_id,
|
|
|
|
|
format!(
|
|
|
|
|
"stream error: {e}; retrying {retries}/{max_retries} in {delay:?}…"
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
tokio::time::sleep(delay).await;
|
|
|
|
|
continue;
|
|
|
|
|
} else {
|
|
|
|
|
let event = Event {
|
|
|
|
|
id: sub_id.clone(),
|
|
|
|
|
msg: EventMsg::Error(ErrorEvent {
|
|
|
|
|
message: e.to_string(),
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
sess.send_event(event).await;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if remove_task_on_completion {
|
2025-09-18 18:21:52 +01:00
|
|
|
sess.remove_task(&sub_id).await;
|
2025-09-12 13:07:10 -07:00
|
|
|
}
|
|
|
|
|
let history_snapshot = {
|
2025-09-18 18:21:52 +01:00
|
|
|
let state = sess.state.lock().await;
|
2025-09-12 13:07:10 -07:00
|
|
|
state.history.contents()
|
|
|
|
|
};
|
|
|
|
|
let summary_text = get_last_assistant_message_from_turn(&history_snapshot).unwrap_or_default();
|
|
|
|
|
let user_messages = collect_user_messages(&history_snapshot);
|
2025-09-14 09:23:31 -04:00
|
|
|
let initial_context = sess.build_initial_context(turn_context.as_ref());
|
|
|
|
|
let new_history = build_compacted_history(initial_context, &user_messages, &summary_text);
|
2025-09-12 13:07:10 -07:00
|
|
|
{
|
2025-09-18 18:21:52 +01:00
|
|
|
let mut state = sess.state.lock().await;
|
2025-09-12 13:07:10 -07:00
|
|
|
state.history.replace(new_history);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let rollout_item = RolloutItem::Compacted(CompactedItem {
|
|
|
|
|
message: summary_text.clone(),
|
|
|
|
|
});
|
|
|
|
|
sess.persist_rollout_items(&[rollout_item]).await;
|
|
|
|
|
|
|
|
|
|
let event = Event {
|
|
|
|
|
id: sub_id.clone(),
|
|
|
|
|
msg: EventMsg::AgentMessage(AgentMessageEvent {
|
|
|
|
|
message: "Compact task completed".to_string(),
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
sess.send_event(event).await;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-18 13:55:53 -07:00
|
|
|
pub fn content_items_to_text(content: &[ContentItem]) -> Option<String> {
|
2025-09-12 13:07:10 -07:00
|
|
|
let mut pieces = Vec::new();
|
|
|
|
|
for item in content {
|
|
|
|
|
match item {
|
|
|
|
|
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
|
|
|
|
|
if !text.is_empty() {
|
|
|
|
|
pieces.push(text.as_str());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
ContentItem::InputImage { .. } => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if pieces.is_empty() {
|
|
|
|
|
None
|
|
|
|
|
} else {
|
|
|
|
|
Some(pieces.join("\n"))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-14 09:23:31 -04:00
|
|
|
pub(crate) fn collect_user_messages(items: &[ResponseItem]) -> Vec<String> {
|
2025-09-12 13:07:10 -07:00
|
|
|
items
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|item| match item {
|
|
|
|
|
ResponseItem::Message { role, content, .. } if role == "user" => {
|
|
|
|
|
content_items_to_text(content)
|
|
|
|
|
}
|
|
|
|
|
_ => None,
|
|
|
|
|
})
|
|
|
|
|
.filter(|text| !is_session_prefix_message(text))
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-18 13:55:53 -07:00
|
|
|
pub fn is_session_prefix_message(text: &str) -> bool {
|
2025-09-12 13:07:10 -07:00
|
|
|
matches!(
|
|
|
|
|
InputMessageKind::from(("user", text)),
|
|
|
|
|
InputMessageKind::UserInstructions | InputMessageKind::EnvironmentContext
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-14 09:23:31 -04:00
|
|
|
pub(crate) fn build_compacted_history(
|
|
|
|
|
initial_context: Vec<ResponseItem>,
|
2025-09-12 13:07:10 -07:00
|
|
|
user_messages: &[String],
|
|
|
|
|
summary_text: &str,
|
|
|
|
|
) -> Vec<ResponseItem> {
|
2025-09-14 09:23:31 -04:00
|
|
|
let mut history = initial_context;
|
2025-09-12 13:07:10 -07:00
|
|
|
let user_messages_text = if user_messages.is_empty() {
|
|
|
|
|
"(none)".to_string()
|
|
|
|
|
} else {
|
|
|
|
|
user_messages.join("\n\n")
|
|
|
|
|
};
|
|
|
|
|
let summary_text = if summary_text.is_empty() {
|
|
|
|
|
"(no summary available)".to_string()
|
|
|
|
|
} else {
|
|
|
|
|
summary_text.to_string()
|
|
|
|
|
};
|
|
|
|
|
let Ok(bridge) = HistoryBridgeTemplate {
|
|
|
|
|
user_messages_text: &user_messages_text,
|
|
|
|
|
summary_text: &summary_text,
|
|
|
|
|
}
|
|
|
|
|
.render() else {
|
|
|
|
|
return vec![];
|
|
|
|
|
};
|
|
|
|
|
history.push(ResponseItem::Message {
|
|
|
|
|
id: None,
|
|
|
|
|
role: "user".to_string(),
|
|
|
|
|
content: vec![ContentItem::InputText { text: bridge }],
|
|
|
|
|
});
|
|
|
|
|
history
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn drain_to_completed(
|
|
|
|
|
sess: &Session,
|
|
|
|
|
turn_context: &TurnContext,
|
|
|
|
|
prompt: &Prompt,
|
|
|
|
|
) -> CodexResult<()> {
|
|
|
|
|
let mut stream = turn_context.client.clone().stream(prompt).await?;
|
|
|
|
|
loop {
|
|
|
|
|
let maybe_event = stream.next().await;
|
|
|
|
|
let Some(event) = maybe_event else {
|
|
|
|
|
return Err(CodexErr::Stream(
|
|
|
|
|
"stream closed before response.completed".into(),
|
|
|
|
|
None,
|
|
|
|
|
));
|
|
|
|
|
};
|
|
|
|
|
match event {
|
|
|
|
|
Ok(ResponseEvent::OutputItemDone(item)) => {
|
2025-09-18 18:21:52 +01:00
|
|
|
let mut state = sess.state.lock().await;
|
2025-09-12 13:07:10 -07:00
|
|
|
state.history.record_items(std::slice::from_ref(&item));
|
|
|
|
|
}
|
|
|
|
|
Ok(ResponseEvent::Completed { .. }) => {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
Ok(_) => continue,
|
|
|
|
|
Err(e) => return Err(e),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use pretty_assertions::assert_eq;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn content_items_to_text_joins_non_empty_segments() {
|
|
|
|
|
let items = vec![
|
|
|
|
|
ContentItem::InputText {
|
|
|
|
|
text: "hello".to_string(),
|
|
|
|
|
},
|
|
|
|
|
ContentItem::OutputText {
|
|
|
|
|
text: String::new(),
|
|
|
|
|
},
|
|
|
|
|
ContentItem::OutputText {
|
|
|
|
|
text: "world".to_string(),
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let joined = content_items_to_text(&items);
|
|
|
|
|
|
|
|
|
|
assert_eq!(Some("hello\nworld".to_string()), joined);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn content_items_to_text_ignores_image_only_content() {
|
|
|
|
|
let items = vec![ContentItem::InputImage {
|
|
|
|
|
image_url: "file://image.png".to_string(),
|
|
|
|
|
}];
|
|
|
|
|
|
|
|
|
|
let joined = content_items_to_text(&items);
|
|
|
|
|
|
|
|
|
|
assert_eq!(None, joined);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn collect_user_messages_extracts_user_text_only() {
|
|
|
|
|
let items = vec![
|
|
|
|
|
ResponseItem::Message {
|
|
|
|
|
id: Some("assistant".to_string()),
|
|
|
|
|
role: "assistant".to_string(),
|
|
|
|
|
content: vec![ContentItem::OutputText {
|
|
|
|
|
text: "ignored".to_string(),
|
|
|
|
|
}],
|
|
|
|
|
},
|
|
|
|
|
ResponseItem::Message {
|
|
|
|
|
id: Some("user".to_string()),
|
|
|
|
|
role: "user".to_string(),
|
|
|
|
|
content: vec![
|
|
|
|
|
ContentItem::InputText {
|
|
|
|
|
text: "first".to_string(),
|
|
|
|
|
},
|
|
|
|
|
ContentItem::OutputText {
|
|
|
|
|
text: "second".to_string(),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
ResponseItem::Other,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let collected = collect_user_messages(&items);
|
|
|
|
|
|
|
|
|
|
assert_eq!(vec!["first\nsecond".to_string()], collected);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn collect_user_messages_filters_session_prefix_entries() {
|
|
|
|
|
let items = vec![
|
|
|
|
|
ResponseItem::Message {
|
|
|
|
|
id: None,
|
|
|
|
|
role: "user".to_string(),
|
|
|
|
|
content: vec![ContentItem::InputText {
|
|
|
|
|
text: "<user_instructions>do things</user_instructions>".to_string(),
|
|
|
|
|
}],
|
|
|
|
|
},
|
|
|
|
|
ResponseItem::Message {
|
|
|
|
|
id: None,
|
|
|
|
|
role: "user".to_string(),
|
|
|
|
|
content: vec![ContentItem::InputText {
|
|
|
|
|
text: "<ENVIRONMENT_CONTEXT>cwd=/tmp</ENVIRONMENT_CONTEXT>".to_string(),
|
|
|
|
|
}],
|
|
|
|
|
},
|
|
|
|
|
ResponseItem::Message {
|
|
|
|
|
id: None,
|
|
|
|
|
role: "user".to_string(),
|
|
|
|
|
content: vec![ContentItem::InputText {
|
|
|
|
|
text: "real user message".to_string(),
|
|
|
|
|
}],
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let collected = collect_user_messages(&items);
|
|
|
|
|
|
|
|
|
|
assert_eq!(vec!["real user message".to_string()], collected);
|
|
|
|
|
}
|
|
|
|
|
}
|